Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions scripts/quality-gate-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ calc_cpu_stats() {
mkdir -p "$(dirname "$BENCH_FILE")"
: > "$BENCH_FILE"

# The bench file is a synthetic transcript inside the real corpus; without
# cleanup it shows up in the UI as a project full of "quality gate payload"
# messages. Remove it (and its directory, if the gate owns it) on every exit
# so the watcher drops the conversation again.
cleanup_bench() {
rm -f "$BENCH_FILE"
rmdir "$(dirname "$BENCH_FILE")" 2>/dev/null || true
}
trap cleanup_bench EXIT

log "Building and starting container"
docker compose up -d --build chat-explorer >/dev/null
sleep 6
Expand Down
268 changes: 248 additions & 20 deletions src/analytics-web/chats_mobile.html

Large diffs are not rendered by default.

71 changes: 71 additions & 0 deletions src/analytics/core/ToolTaxonomy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* ToolTaxonomy - classify tool_use blocks and extract file changes.
*
* Ported from flight-recorder's Claude adapter (ingest/src/adapters/claude.ts).
* Pure functions, no dependencies; both the indexer (write path) and the
* database layer (backfill/queries) use this one module so the taxonomy
* cannot drift between them.
*/

/**
* Bucket a tool name into a coarse kind, extracting the MCP server name
* from the mcp__<server>__<tool> convention.
* @param {string} name - Tool name as it appears in the tool_use block
* @returns {{kind: string, mcp: ?string}} kind is one of:
* mcp | shell | file_edit | file_read | search | task | web | other
*/
function toolKind(name) {
if (typeof name !== 'string' || !name) return { kind: 'other', mcp: null };
if (name.startsWith('mcp__')) return { kind: 'mcp', mcp: name.split('__')[1] || null };
const n = name.toLowerCase();
if (['bash', 'exec_command'].includes(n)) return { kind: 'shell', mcp: null };
if (['edit', 'write', 'multiedit', 'notebookedit'].includes(n)) return { kind: 'file_edit', mcp: null };
if (['read', 'notebookread'].includes(n)) return { kind: 'file_read', mcp: null };
if (['grep', 'glob', 'websearch', 'toolsearch'].includes(n)) return { kind: 'search', mcp: null };
if (['task', 'agent'].includes(n)) return { kind: 'task', mcp: null };
if (['webfetch'].includes(n)) return { kind: 'web', mcp: null };
return { kind: 'other', mcp: null };
}

/** Count newlines-delimited lines in a string ('' and null count as 0). */
function countLines(s) {
return s ? String(s).split('\n').length : 0;
}

/**
* Extract a file change from an Edit/Write/MultiEdit/NotebookEdit tool_use
* block. Line counts derive from the old/new strings, so they describe the
* requested change, not a post-hoc diff.
* @param {string} name - Tool name
* @param {Object} input - The tool_use input payload
* @returns {?{path: string, change_kind: string, added_lines: number, removed_lines: number}}
*/
function fileChangeFromTool(name, input) {
if (!input || typeof name !== 'string') return null;
const n = name.toLowerCase();
if (n === 'write') {
if (!input.file_path) return null;
return { path: input.file_path, change_kind: 'create', added_lines: countLines(input.content), removed_lines: 0 };
}
if (n === 'edit') {
if (!input.file_path) return null;
return { path: input.file_path, change_kind: 'edit', added_lines: countLines(input.new_string), removed_lines: countLines(input.old_string) };
}
if (n === 'multiedit') {
if (!input.file_path || !Array.isArray(input.edits)) return null;
let added = 0, removed = 0;
for (const e of input.edits) {
if (!e || typeof e !== 'object') continue;
added += countLines(e.new_string);
removed += countLines(e.old_string);
}
return { path: input.file_path, change_kind: 'edit', added_lines: added, removed_lines: removed };
}
if (n === 'notebookedit') {
if (!input.notebook_path) return null;
return { path: input.notebook_path, change_kind: 'edit', added_lines: countLines(input.new_source), removed_lines: 0 };
}
return null;
}

module.exports = { toolKind, fileChangeFromTool };
44 changes: 44 additions & 0 deletions src/analytics/core/WorktreeClassifier.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* WorktreeClassifier - detect agent sessions that ran in isolated git
* worktree checkouts.
*
* Harnesses that give an agent its own worktree (the Agent tool's
* isolation mode, Claude Code's EnterWorktree, the Cyrus Linear runner)
* start the session with a cwd inside a well-known worktree directory.
* The transcript itself carries no parent linkage (isSidechain is false,
* parentUuid is null), so the cwd convention is the only reliable signal.
* Without it, every worktree shows up as its own fake top-level project.
*/

const path = require('path');

const PATTERNS = [
// <repo>/.worktrees/<name>[/...]: Agent tool / workflow worktree isolation
{ re: /^(.*)\/\.worktrees\/[^/]+(?:\/|$)/, owner: (m) => path.basename(m[1]) },
// <repo>/.claude/worktrees/<name>[/...]: Claude Code EnterWorktree
{ re: /^(.*)\/\.claude\/worktrees\/[^/]+(?:\/|$)/, owner: (m) => path.basename(m[1]) },
// ~/.cyrus/worktrees/<issue>[/...]: Cyrus checkouts carry no repo name in
// the path, so they group under a single "cyrus" project.
{ re: /^.*\/\.cyrus\/worktrees\/[^/]+(?:\/|$)/, owner: () => 'cyrus' },
];

/**
* Classify a session cwd.
* @param {?string} cwd - The session's working directory
* @returns {?{owningProject: ?string}} null when the cwd is not a recognized
* agent-worktree location; otherwise the project the session belongs to
* (null owningProject means "recognized worktree, owner unknown").
*/
function classifyAgentWorktree(cwd) {
if (typeof cwd !== 'string' || !cwd) return null;
for (const p of PATTERNS) {
const m = cwd.match(p.re);
if (m) {
const owningProject = p.owner(m);
return { owningProject: owningProject || null };
}
}
return null;
}

module.exports = { classifyAgentWorktree };
37 changes: 37 additions & 0 deletions src/analytics/data/DatabaseBackend.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,37 @@ class DatabaseBackend {
return this.searchService.facets();
}

/**
* Per-tool and per-kind usage rollups (canonical conversations only).
* @returns {{tools: Array, kinds: Array, mcpServers: Array}}
*/
toolStats() {
if (!this.db) throw new Error('Database not initialized');
const { kinds, mcpServers } = this.db.getToolKindStats();
return { tools: this.db.getToolUsageStats(), kinds, mcpServers };
}

/**
* Conversations that changed files matching a path substring.
* @param {string} pathQuery
* @param {number} [limit]
* @returns {Array}
*/
fileChanges(pathQuery, limit) {
if (!this.db) throw new Error('Database not initialized');
return this.db.getConversationsTouchingFile(pathQuery, limit);
}

/**
* Tool usage for one conversation (analytics modal shape).
* @param {string} conversationId
* @returns {Object}
*/
conversationToolUsage(conversationId) {
if (!this.db) throw new Error('Database not initialized');
return this.db.getConversationToolUsage(conversationId);
}

/**
* Role/tool-granular search (message_fts). Same transformed shape as
* searchConversationsWithSnippets, plus matchedRole/matchedSeq.
Expand Down Expand Up @@ -364,6 +395,12 @@ class DatabaseBackend {
},
// Subagent hierarchy fields
isSubagent: conv.isSubagent || false,
// Agent-worktree sessions: subagent-like, but with no parent session
// recorded in the transcript. Consumers use this to label them.
isWorktreeAgent: conv.isWorktreeAgent || false,
// Headless (sdk-*) vs interactive (cli / claude-desktop) invocation.
entrypoint: conv.entrypoint || null,
isHeadless: conv.isHeadless || false,
parentId: conv.parentId || null
};
}
Expand Down
Loading