| @@ -176,16 +176,23 @@ the source of every fixture. | ||
| | xAI Grok exported conversation JSON | `grok` | Experimental, built to the exporter schema | | ||
| | Pasted / plain-text transcripts (`User:` / `Assistant:`) | `transcript` | Built-in fallback | | ||
| - | Cursor stores chat in a `state.vscdb` SQLite database. TreeTrace ships with zero | |
| - | runtime dependencies and does not open SQLite, so the Cursor adapter ingests an | |
| - | exported chat JSON instead. Export your Cursor chat to JSON first (for example | |
| - | with a community Cursor chat exporter), then run | |
| - | `treetrace --from cursor --file your-chat.json`. | |
| - | ||
| - | The Grok adapter targets the exported conversation JSON used by Grok CLI tools | |
| - | (the xAI OpenAI-compatible `role` / `content` message shape). The widely used | |
| - | grok-cli keeps history in SQLite rather than JSON, so this adapter is marked | |
| - | experimental until validated against a captured real Grok session. | |
| + | ### Why TreeTrace does not read SQLite | |
| + | ||
| + | Cursor stores its chat in a `state.vscdb` SQLite database, and the common Grok | |
| + | CLI keeps history in SQLite as well. That raw database is rich: it holds real | |
| + | file diffs, reasoning, rejected edits, and attached-file context. TreeTrace | |
| + | deliberately does not read it, because the zero-runtime-dependency promise is a | |
| + | feature, not an accident. Nothing extra to install, a smaller supply-chain and | |
| + | attack surface, and a tool that a privacy-conscious or security team can audit in | |
| + | one sitting matter more right now than the extra signal. Adding an optional | |
| + | SQLite reader is a future option we are choosing not to take yet. | |
| + | ||
| + | So the Cursor adapter ingests an exported chat JSON instead. Export your Cursor | |
| + | chat to JSON first (for example with a community Cursor chat exporter), then run | |
| + | `treetrace --from cursor --file your-chat.json`. The Grok adapter targets the | |
| + | exported conversation JSON used by Grok CLI tools (the xAI OpenAI-compatible | |
| + | `role` / `content` message shape); it stays experimental until validated against | |
| + | a captured real Grok session. | |
| ## Schema | ||
| @@ -1,4 +1,4 @@ | ||
| - | import { newSession, finalizeSession, pushTurn, looksSynthetic } from './shared.js'; | |
| + | import { newSession, finalizeSession, pushTurn, addAction, looksSynthetic } from './shared.js'; | |
| export function detectCopilot(parsed) { | ||
| return Boolean( | ||
| @@ -21,12 +21,24 @@ function userText(message) { | ||
| return ''; | ||
| } | ||
| - | function countResponse(session, response) { | |
| + | function ingestResponse(session, response, model) { | |
| if (!Array.isArray(response)) return; | ||
| for (const item of response) { | ||
| - | if (item && (item.kind === 'toolInvocation' || item.kind === 'toolInvocationSerialized')) { | |
| - | session.stats.toolUses++; | |
| - | } | |
| + | if (!item || (item.kind !== 'toolInvocation' && item.kind !== 'toolInvocationSerialized')) continue; | |
| + | session.stats.toolUses++; | |
| + | const tsd = item.toolSpecificData || {}; | |
| + | const uri = tsd.uri; | |
| + | const file = | |
| + | uri && typeof uri === 'object' ? uri.path || uri.fsPath || null : typeof uri === 'string' ? uri : null; | |
| + | const command = | |
| + | typeof tsd.command === 'string' ? tsd.command : typeof tsd.commandLine === 'string' ? tsd.commandLine : null; | |
| + | if (file) session.stats.filesTouched.add(file); | |
| + | addAction(session, { | |
| + | tool: item.toolId || (item.prepareToolInvocation && item.prepareToolInvocation.toolName) || null, | |
| + | file: file || null, | |
| + | command, | |
| + | model: model || null, | |
| + | }); | |
| } | ||
| } | ||
| @@ -36,14 +48,14 @@ export function parseCopilot(parsed, path, sessionId) { | ||
| for (const req of parsed.requests) { | ||
| if (!req) continue; | ||
| session.stats.assistantLines++; | ||
| - | countResponse(session, req.response); | |
| - | if (req.result && req.result.metadata && req.result.metadata.modelId) { | |
| - | session.stats.models.add(req.result.metadata.modelId); | |
| - | } | |
| + | const modelId = (req.result && req.result.metadata && req.result.metadata.modelId) || null; | |
| + | if (modelId) session.stats.models.add(modelId); | |
| const text = userText(req.message); | ||
| - | if (looksSynthetic(text)) continue; | |
| - | const ts = req.timestamp ? new Date(req.timestamp).toISOString() : null; | |
| - | pushTurn(session, ++turn, text, ts); | |
| + | if (!looksSynthetic(text)) { | |
| + | const ts = req.timestamp ? new Date(req.timestamp).toISOString() : null; | |
| + | pushTurn(session, ++turn, text, ts); | |
| + | } | |
| + | ingestResponse(session, req.response, modelId); | |
| } | ||
| return finalizeSession(session); | ||
| } |
| @@ -1,4 +1,28 @@ | ||
| - | import { newSession, finalizeSession, pushTurn, looksSynthetic } from './shared.js'; | |
| + | import { newSession, finalizeSession, pushTurn, addAction, looksSynthetic } from './shared.js'; | |
| + | ||
| + | function parseCursorParams(tfd) { | |
| + | const raw = tfd && (tfd.params || tfd.rawArgs); | |
| + | if (!raw) return null; | |
| + | if (typeof raw === 'object') return raw; | |
| + | if (typeof raw === 'string') { | |
| + | try { | |
| + | return JSON.parse(raw); | |
| + | } catch { | |
| + | return null; | |
| + | } | |
| + | } | |
| + | return null; | |
| + | } | |
| + | ||
| + | function cursorToolFile(tfd) { | |
| + | const p = parseCursorParams(tfd); | |
| + | return (p && (p.file_path || p.path || p.target_file || p.relativePath)) || null; | |
| + | } | |
| + | ||
| + | function cursorToolCommand(tfd) { | |
| + | const p = parseCursorParams(tfd); | |
| + | return p && typeof p.command === 'string' ? p.command : null; | |
| + | } | |
| function isUserBubble(bubble) { | ||
| if (bubble.type === 1 || bubble.type === 'user') return true; | ||
| @@ -58,6 +82,12 @@ function parseExportedSession(parsed, path, sessionId) { | ||
| session.stats.toolUses++; | ||
| const file = call && (call.filePath || (call.args && (call.args.file_path || call.args.path))); | ||
| if (typeof file === 'string') session.stats.filesTouched.add(file); | ||
| + | addAction(session, { | |
| + | tool: (call && call.name) || null, | |
| + | file: typeof file === 'string' ? file : null, | |
| + | command: call && call.args && typeof call.args.command === 'string' ? call.args.command : null, | |
| + | model: msg.model || null, | |
| + | }); | |
| } | ||
| } | ||
| } | ||
| @@ -117,7 +147,18 @@ export function parseCursor(parsed, path, sessionId) { | ||
| pushTurn(session, ++turn, text, ts); | ||
| } else { | ||
| session.stats.assistantLines++; | ||
| - | if (Array.isArray(bubble.toolFormerData) || bubble.toolFormerData) session.stats.toolUses++; | |
| + | if (bubble.toolFormerData) { | |
| + | session.stats.toolUses++; | |
| + | const tfd = bubble.toolFormerData; | |
| + | const file = cursorToolFile(tfd); | |
| + | if (typeof file === 'string') session.stats.filesTouched.add(file); | |
| + | addAction(session, { | |
| + | tool: tfd.name || null, | |
| + | file: file || null, | |
| + | command: cursorToolCommand(tfd), | |
| + | model: bubble.model || null, | |
| + | }); | |
| + | } | |
| } | ||
| } | ||
| return finalizeSession(session); |
| @@ -2,6 +2,8 @@ import { | ||
| newSession, | ||
| finalizeSession, | ||
| pushTurn, | ||
| + | addAction, | |
| + | addThinking, | |
| flattenParts, | ||
| looksSynthetic, | ||
| readJsonl, | ||
| @@ -35,8 +37,15 @@ function ingestRecord(session, rec, counters) { | ||
| session.stats.toolUses++; | ||
| const file = call && call.args && (call.args.file_path || call.args.path || call.args.absolute_path); | ||
| if (typeof file === 'string') session.stats.filesTouched.add(file); | ||
| + | addAction(session, { | |
| + | tool: (call && call.name) || null, | |
| + | file: typeof file === 'string' ? file : null, | |
| + | command: call && call.args && typeof call.args.command === 'string' ? call.args.command : null, | |
| + | model: rec.model || null, | |
| + | }); | |
| } | ||
| } | ||
| + | if (Array.isArray(rec.thoughts) && rec.thoughts.length) addThinking(session, rec.thoughts.length); | |
| if (rec.tokens) { | ||
| session.stats.inputTokens += rec.tokens.prompt || rec.tokens.input || 0; | ||
| session.stats.outputTokens += rec.tokens.candidate || rec.tokens.output || 0; |
| @@ -162,3 +162,55 @@ test('codex import emits actions that drive a verified security signal and model | ||
| assert.deepEqual(analysis.summary.models, ['gpt-5.5']); | ||
| assert.ok(analysis.summary.thinkingBlocks >= 1); | ||
| }); | ||
| + | ||
| + | test('gemini import emits actions for a verified security signal', () => { | |
| + | const obj = { | |
| + | sessionId: 'g1', | |
| + | messages: [ | |
| + | { type: 'user', content: [{ text: 'Add rate limiting to checkout' }], timestamp: '2026-06-12T10:00:00Z' }, | |
| + | { type: 'gemini', model: 'gemini-3-flash', timestamp: '2026-06-12T10:00:05Z', toolCalls: [{ name: 'edit_file', args: { file_path: 'src/auth/middleware.ts' } }], thoughts: [{ subject: 'a', description: 'b' }] }, | |
| + | ], | |
| + | }; | |
| + | const sessions = adaptFrom('gemini', JSON.stringify(obj), fx('gemini-synth.json')); | |
| + | const analysis = analyzeTree(pipeline(sessions).tree); | |
| + | const sec = analysis.failures.find((f) => f.type === 'security_or_privacy_risk' && f.tier === 'verified'); | |
| + | assert.ok(sec, 'gemini import should produce a verified security signal'); | |
| + | assert.equal(sec.model, 'gemini-3-flash'); | |
| + | assert.ok(analysis.summary.thinkingBlocks >= 1); | |
| + | }); | |
| + | ||
| + | test('copilot import emits actions from toolSpecificData for a verified security signal', () => { | |
| + | const obj = { | |
| + | version: 3, | |
| + | requests: [ | |
| + | { | |
| + | requestId: 'r1', | |
| + | message: { text: 'Add rate limiting to checkout' }, | |
| + | result: { metadata: { modelId: 'gpt-4o-copilot' } }, | |
| + | response: [{ kind: 'toolInvocationSerialized', toolId: 'copilot_editFile', toolSpecificData: { uri: { path: 'src/auth/session.ts' } } }], | |
| + | }, | |
| + | ], | |
| + | }; | |
| + | const sessions = adaptFrom('copilot', JSON.stringify(obj), fx('copilot-synth.json')); | |
| + | const analysis = analyzeTree(pipeline(sessions).tree); | |
| + | const sec = analysis.failures.find((f) => f.type === 'security_or_privacy_risk' && f.tier === 'verified'); | |
| + | assert.ok(sec, 'copilot import should produce a verified security signal'); | |
| + | assert.equal(sec.model, 'gpt-4o-copilot'); | |
| + | }); | |
| + | ||
| + | test('cursor import emits actions from exported tool calls for a verified security signal', () => { | |
| + | const obj = { | |
| + | id: 'cur1', | |
| + | title: 'session', | |
| + | workspaceId: 'w1', | |
| + | messages: [ | |
| + | { role: 'user', content: 'Add rate limiting to checkout', timestamp: '2026-06-12T10:00:00Z' }, | |
| + | { role: 'assistant', model: 'claude-sonnet-4-6', toolCalls: [{ name: 'edit_file', filePath: 'src/auth/session.ts' }] }, | |
| + | ], | |
| + | }; | |
| + | const sessions = adaptFrom('cursor', JSON.stringify(obj), fx('cursor-synth.json')); | |
| + | const analysis = analyzeTree(pipeline(sessions).tree); | |
| + | const sec = analysis.failures.find((f) => f.type === 'security_or_privacy_risk' && f.tier === 'verified'); | |
| + | assert.ok(sec, 'cursor import should produce a verified security signal'); | |
| + | assert.equal(sec.model, 'claude-sonnet-4-6'); | |
| + | }); |