Zion Boggan zionboggan.com ↗

Adapters: Gemini, Copilot, and Cursor emit actions; document the zero-dependency SQLite decision

029c9d4   Zion Boggan committed on Jun 12, 2026 (1 week ago)
README.md +17 -10
@@ -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
src/adapters/copilot.js +24 -12
@@ -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);
}
src/adapters/cursor.js +43 -2
@@ -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);
src/adapters/gemini.js +9 -0
@@ -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;
test/adapters.test.js +52 -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');
+});