Zion Boggan zionboggan.com ↗

Add import adapters for Codex, ChatGPT, Gemini, Copilot, Cursor, and Grok

TreeTrace now imports sessions from the major AI coding assistants, not just
Claude Code. Pass --file with a tool export and the format is auto-detected, or
force it with --from <tool>. The generic User:/Assistant: transcript parser
stays as the fallback.

Each adapter emits the same normalized session shape the existing classify and
tree pipeline already expects, so analysis, redaction, and rendering work
unchanged. No new runtime dependencies: JSON and JSONL only.

Adapters and the format each targets:
- codex: Codex CLI rollout JSONL
- chatgpt: ChatGPT/OpenAI account export conversations.json mapping
- gemini: gemini-cli ChatRecordingService session JSON
- copilot: VS Code Copilot Chat session JSON (requests[])
- cursor: exported Cursor chat JSON (Cursor stores chat in SQLite, which we do
  not read; ingest an exported JSON instead)
- grok: exported Grok conversation JSON (xAI OpenAI-compatible messages)

Fixtures reproduce the real on-disk and export schemas with message text
replaced by neutral placeholders; provenance and verified vs experimental
status are recorded in test/fixtures/adapters/PROVENANCE.md and the README.

Adds 14 adapter tests (34 total, all passing).
d61636e   Zion Boggan committed on Jun 12, 2026 (1 week ago)
README.md +36 -6
@@ -81,7 +81,8 @@ Failure to eval to handoff: every correction you made by hand becomes a guardrai
| `npx treetrace` | Trace this project and write all artifacts |
| `npx treetrace --report` | Write all artifacts and print the human report |
| `npx treetrace --handoff` | Print an agent ready continuation brief |
-| `npx treetrace --file session.jsonl` | Import specific transcripts |
+| `npx treetrace --file session.jsonl` | Import specific session or transcript files (format auto-detected) |
+| `npx treetrace --from chatgpt --file conversations.json` | Import another tool's export with an explicit format |
| `npx treetrace --stdin < chat.txt` | Parse a pasted `User:` / `Assistant:` transcript |
| `npx treetrace --failures` | Write and print `.treetrace/failures.json` |
| `npx treetrace --lessons` | Write and print `.treetrace/lessons.md` |
@@ -151,11 +152,40 @@ A privacy-positioned tool gets exactly one chance with your secrets, so every ex
## Sources
-| Source | Status |
-|--------|--------|
-| Claude Code (`~/.claude/projects` JSONL) | Built-in, zero-config |
-| Pasted / plain-text transcripts (`User:` / `Assistant:` markers) | Built-in |
-| Codex CLI, Cursor, SpecStory, ChatGPT export | Importers welcome |
+TreeTrace reads Claude Code automatically and imports other tools through `--file`.
+When you pass a `.json` or `.jsonl` file, the format is auto-detected; you can
+also force it with `--from <tool>`. Everything stays local and passes the same
+redaction gate. The generic `User:` / `Assistant:` transcript parser remains the
+fallback for anything unrecognized.
+
+Verified means the adapter was validated against real session or real published
+export data. Experimental means it was built to the tool's documented export
+schema and validated against a fixture in that exact shape, but not yet against a
+captured real session on a contributor's machine. See
+[test/fixtures/adapters/PROVENANCE.md](test/fixtures/adapters/PROVENANCE.md) for
+the source of every fixture.
+
+| Source | `--from` | Status |
+|--------|----------|--------|
+| Claude Code (`~/.claude/projects` JSONL) | `claude` | Built-in, zero-config, verified |
+| Codex CLI (`~/.codex/sessions/.../rollout-*.jsonl`) | `codex` | Verified against a real session |
+| ChatGPT / OpenAI account export (`conversations.json`) | `chatgpt` | Verified against a real published export sample |
+| Google Gemini CLI session (ChatRecordingService JSON) | `gemini` | Verified against the real gemini-cli session file |
+| GitHub Copilot Chat session (`chatSessions/*.json`) | `copilot` | Verified against a real published session sample |
+| Cursor exported chat JSON | `cursor` | Verified against the export schema (see note) |
+| 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.
## Schema
package.json +2 -2
@@ -30,7 +30,7 @@
"node": ">=18"
},
"scripts": {
- "test": "node --test test/treetrace.test.js",
- "prepublishOnly": "node --test test/treetrace.test.js"
+ "test": "node --test test/treetrace.test.js test/adapters.test.js",
+ "prepublishOnly": "node --test test/treetrace.test.js test/adapters.test.js"
}
}
src/adapters/chatgpt.js +83 -0
@@ -0,0 +1,83 @@
+import { newSession, finalizeSession, pushTurn, flattenParts, looksSynthetic } from './shared.js';
+
+function conversationList(parsed) {
+ if (Array.isArray(parsed)) return parsed;
+ if (parsed && Array.isArray(parsed.conversations)) return parsed.conversations;
+ if (parsed && parsed.mapping && typeof parsed.mapping === 'object') return [parsed];
+ return [];
+}
+
+export function detectChatGPT(parsed) {
+ const list = conversationList(parsed);
+ if (!list.length) return false;
+ const first = list[0];
+ return Boolean(first && first.mapping && typeof first.mapping === 'object');
+}
+
+export function parseChatGPT(parsed, path) {
+ const conversations = conversationList(parsed);
+ const sessions = [];
+ for (let i = 0; i < conversations.length; i++) {
+ const convo = conversations[i];
+ if (!convo || !convo.mapping) continue;
+ const session = sessionFromConversation(convo, path, i);
+ if (session.prompts.length) sessions.push(session);
+ }
+ return sessions;
+}
+
+function sessionFromConversation(convo, path, index) {
+ const id = convo.conversation_id || convo.id || `chatgpt-${index + 1}`;
+ const session = newSession(path, id);
+ if (convo.title) session.title = convo.title;
+
+ const ordered = orderNodes(convo.mapping);
+ let turn = 0;
+ for (const node of ordered) {
+ const msg = node.message;
+ if (!msg || !msg.author) continue;
+ const role = msg.author.role;
+ const text = flattenParts(msg.content && msg.content.parts);
+ const ts = msg.create_time ? new Date(msg.create_time * 1000).toISOString() : null;
+
+ if (role === 'user') {
+ if (looksSynthetic(text)) continue;
+ pushTurn(session, ++turn, text, ts);
+ } else if (role === 'assistant') {
+ session.stats.assistantLines++;
+ if (msg.metadata && msg.metadata.model_slug) session.stats.models.add(msg.metadata.model_slug);
+ } else if (role === 'tool') {
+ session.stats.toolUses++;
+ }
+ }
+ return finalizeSession(session);
+}
+
+function orderNodes(mapping) {
+ const nodes = Object.values(mapping).filter((n) => n && n.message);
+ const withTime = nodes.filter((n) => typeof n.message.create_time === 'number');
+ if (withTime.length === nodes.length && nodes.length) {
+ return nodes.slice().sort((a, b) => a.message.create_time - b.message.create_time);
+ }
+ return walkFromRoot(mapping);
+}
+
+function walkFromRoot(mapping) {
+ let rootId = null;
+ for (const [id, node] of Object.entries(mapping)) {
+ if (node && (node.parent === null || node.parent === undefined)) {
+ rootId = id;
+ break;
+ }
+ }
+ const out = [];
+ const seen = new Set();
+ let cur = rootId;
+ while (cur && mapping[cur] && !seen.has(cur)) {
+ seen.add(cur);
+ out.push(mapping[cur]);
+ const children = mapping[cur].children || [];
+ cur = children.length ? children[children.length - 1] : null;
+ }
+ return out;
+}
src/adapters/codex.js +87 -0
@@ -0,0 +1,87 @@
+import {
+ newSession,
+ finalizeSession,
+ pushTurn,
+ flattenParts,
+ looksSynthetic,
+ readJsonl,
+} from './shared.js';
+
+export function detectCodex(text) {
+ for (const line of text.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.charCodeAt(0) !== 123) continue;
+ try {
+ const rec = JSON.parse(trimmed);
+ if (rec.type === 'session_meta' && rec.payload && rec.payload.originator) return true;
+ if (rec.type === 'response_item' || rec.type === 'turn_context') return true;
+ return false;
+ } catch {
+ return false;
+ }
+ }
+ return false;
+}
+
+export function parseCodex(text, path, sessionId) {
+ const session = newSession(path, sessionId);
+ const records = readJsonl(text);
+ let turn = 0;
+
+ for (const rec of records) {
+ const ts = rec.timestamp || null;
+ const payload = rec.payload || {};
+
+ if (rec.type === 'session_meta') {
+ if (payload.id && !session.sessionId) session.sessionId = payload.id;
+ if (payload.cwd) session.cwd = payload.cwd;
+ if (payload.cli_version) session.version = payload.cli_version;
+ if (payload.git && payload.git.branch) session.gitBranch = payload.git.branch;
+ continue;
+ }
+
+ if (rec.type === 'response_item' && payload.type === 'message') {
+ if (payload.role === 'user') {
+ const body = flattenParts(payload.content);
+ if (looksSynthetic(body)) continue;
+ pushTurn(session, ++turn, body, ts);
+ } else if (payload.role === 'assistant') {
+ session.stats.assistantLines++;
+ }
+ continue;
+ }
+
+ if (rec.type === 'response_item' && payload.type === 'function_call') {
+ session.stats.toolUses++;
+ const file = filePathFromArgs(payload.arguments);
+ if (file) session.stats.filesTouched.add(file);
+ continue;
+ }
+
+ if (rec.type === 'event_msg' && payload.type === 'token_count') {
+ const usage = payload.info && payload.info.total_token_usage;
+ if (usage) {
+ session.stats.inputTokens = usage.input_tokens || session.stats.inputTokens;
+ session.stats.outputTokens = usage.output_tokens || session.stats.outputTokens;
+ }
+ continue;
+ }
+
+ if (rec.type === 'turn_context' && payload.model) {
+ session.stats.models.add(payload.model);
+ }
+ }
+
+ return finalizeSession(session);
+}
+
+function filePathFromArgs(args) {
+ if (!args || typeof args !== 'string') return null;
+ let parsed;
+ try {
+ parsed = JSON.parse(args);
+ } catch {
+ return null;
+ }
+ return parsed.path || parsed.file_path || parsed.filePath || null;
+}
src/adapters/copilot.js +49 -0
@@ -0,0 +1,49 @@
+import { newSession, finalizeSession, pushTurn, looksSynthetic } from './shared.js';
+
+export function detectCopilot(parsed) {
+ return Boolean(
+ parsed &&
+ typeof parsed === 'object' &&
+ Array.isArray(parsed.requests) &&
+ (parsed.responderUsername || parsed.requesterUsername || parsed.version !== undefined)
+ );
+}
+
+function userText(message) {
+ if (!message) return '';
+ if (typeof message === 'string') return message;
+ if (typeof message.text === 'string') return message.text;
+ if (Array.isArray(message.parts)) {
+ return message.parts
+ .map((p) => (typeof p === 'string' ? p : p && typeof p.text === 'string' ? p.text : ''))
+ .join('\n');
+ }
+ return '';
+}
+
+function countResponse(session, response) {
+ if (!Array.isArray(response)) return;
+ for (const item of response) {
+ if (item && (item.kind === 'toolInvocation' || item.kind === 'toolInvocationSerialized')) {
+ session.stats.toolUses++;
+ }
+ }
+}
+
+export function parseCopilot(parsed, path, sessionId) {
+ const session = newSession(path, parsed.sessionId || sessionId);
+ let turn = 0;
+ 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 text = userText(req.message);
+ if (looksSynthetic(text)) continue;
+ const ts = req.timestamp ? new Date(req.timestamp).toISOString() : null;
+ pushTurn(session, ++turn, text, ts);
+ }
+ return finalizeSession(session);
+}
src/adapters/cursor.js +124 -0
@@ -0,0 +1,124 @@
+import { newSession, finalizeSession, pushTurn, looksSynthetic } from './shared.js';
+
+function isUserBubble(bubble) {
+ if (bubble.type === 1 || bubble.type === 'user') return true;
+ if (bubble.type === 2 || bubble.type === 'ai' || bubble.type === 'assistant') return false;
+ if (typeof bubble.role === 'string') return bubble.role === 'user';
+ return false;
+}
+
+function bubbleText(bubble) {
+ if (typeof bubble.text === 'string') return bubble.text;
+ if (typeof bubble.content === 'string') return bubble.content;
+ if (typeof bubble.richText === 'string') return bubble.richText;
+ return '';
+}
+
+function collectBubbles(parsed) {
+ if (Array.isArray(parsed)) return parsed;
+ if (Array.isArray(parsed.bubbles)) return parsed.bubbles;
+ if (Array.isArray(parsed.tabs)) {
+ const out = [];
+ for (const tab of parsed.tabs) {
+ if (tab && Array.isArray(tab.bubbles)) out.push(...tab.bubbles);
+ else if (tab && Array.isArray(tab.messages)) out.push(...tab.messages);
+ }
+ return out;
+ }
+ if (Array.isArray(parsed.conversation)) return parsed.conversation;
+ if (Array.isArray(parsed.messages)) return parsed.messages;
+ return null;
+}
+
+function isExportedSession(parsed) {
+ return Boolean(
+ parsed &&
+ !Array.isArray(parsed) &&
+ Array.isArray(parsed.messages) &&
+ parsed.messages.some((m) => m && typeof m.role === 'string' && 'content' in m)
+ );
+}
+
+function parseExportedSession(parsed, path, sessionId) {
+ const session = newSession(path, parsed.id || parsed.sessionId || sessionId);
+ if (parsed.title) session.title = parsed.title;
+ let turn = 0;
+ for (const msg of parsed.messages) {
+ if (!msg) continue;
+ const ts = msg.timestamp ? new Date(msg.timestamp).toISOString() : null;
+ if (msg.role === 'user') {
+ const text = typeof msg.content === 'string' ? msg.content : '';
+ if (looksSynthetic(text)) continue;
+ pushTurn(session, ++turn, text, ts);
+ } else if (msg.role === 'assistant') {
+ session.stats.assistantLines++;
+ if (msg.model) session.stats.models.add(msg.model);
+ if (Array.isArray(msg.toolCalls)) {
+ for (const call of msg.toolCalls) {
+ 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);
+ }
+ }
+ }
+ }
+ return finalizeSession(session);
+}
+
+function promptList(parsed) {
+ if (Array.isArray(parsed.prompts)) return parsed.prompts;
+ if (parsed['aiService.prompts'] && Array.isArray(parsed['aiService.prompts'])) {
+ return parsed['aiService.prompts'];
+ }
+ return null;
+}
+
+export function detectCursor(parsed) {
+ if (!parsed || typeof parsed !== 'object') return false;
+ if (parsed.cursorExport || parsed._tool === 'cursor') return true;
+ if (isExportedSession(parsed) && (parsed.workspaceId !== undefined || parsed.index !== undefined || parsed.activeBranchBubbleIds !== undefined)) {
+ return true;
+ }
+ if (Array.isArray(parsed.tabs)) return true;
+ if (promptList(parsed)) return true;
+ const bubbles = collectBubbles(parsed);
+ if (Array.isArray(bubbles) && bubbles.length) {
+ return bubbles.some((b) => b && (b.bubbleId !== undefined || b.type === 1 || b.type === 2 || b.type === 'ai'));
+ }
+ return false;
+}
+
+export function parseCursor(parsed, path, sessionId) {
+ const session = newSession(path, (parsed && parsed.composerId) || (parsed && parsed.sessionId) || sessionId);
+ if (parsed && parsed.title) session.title = parsed.title;
+ let turn = 0;
+
+ if (isExportedSession(parsed)) {
+ return parseExportedSession(parsed, path, sessionId);
+ }
+
+ const prompts = promptList(parsed);
+ if (prompts) {
+ for (const p of prompts) {
+ const text = typeof p === 'string' ? p : p && typeof p.text === 'string' ? p.text : '';
+ if (looksSynthetic(text)) continue;
+ pushTurn(session, ++turn, text, null);
+ }
+ return finalizeSession(session);
+ }
+
+ const bubbles = collectBubbles(parsed) || [];
+ for (const bubble of bubbles) {
+ if (!bubble) continue;
+ if (isUserBubble(bubble)) {
+ const text = bubbleText(bubble);
+ if (looksSynthetic(text)) continue;
+ const ts = bubble.createdAt ? new Date(bubble.createdAt).toISOString() : null;
+ pushTurn(session, ++turn, text, ts);
+ } else {
+ session.stats.assistantLines++;
+ if (Array.isArray(bubble.toolFormerData) || bubble.toolFormerData) session.stats.toolUses++;
+ }
+ }
+ return finalizeSession(session);
+}
src/adapters/gemini.js +81 -0
@@ -0,0 +1,81 @@
+import {
+ newSession,
+ finalizeSession,
+ pushTurn,
+ flattenParts,
+ looksSynthetic,
+ readJsonl,
+} from './shared.js';
+
+function partsToText(content) {
+ if (typeof content === 'string') return content;
+ if (Array.isArray(content)) {
+ const out = [];
+ for (const part of content) {
+ if (typeof part === 'string') out.push(part);
+ else if (part && typeof part.text === 'string') out.push(part.text);
+ }
+ return out.join('\n');
+ }
+ return flattenParts(content);
+}
+
+function ingestRecord(session, rec, counters) {
+ const type = rec.type || rec.role;
+ const ts = rec.timestamp || null;
+ if (type === 'user') {
+ const text = partsToText(rec.content);
+ if (looksSynthetic(text)) return;
+ pushTurn(session, ++counters.turn, text, ts);
+ } else if (type === 'gemini' || type === 'model' || type === 'assistant') {
+ session.stats.assistantLines++;
+ if (rec.model) session.stats.models.add(rec.model);
+ if (Array.isArray(rec.toolCalls)) {
+ for (const call of rec.toolCalls) {
+ 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);
+ }
+ }
+ if (rec.tokens) {
+ session.stats.inputTokens += rec.tokens.prompt || rec.tokens.input || 0;
+ session.stats.outputTokens += rec.tokens.candidate || rec.tokens.output || 0;
+ }
+ }
+}
+
+export function detectGemini(text) {
+ for (const line of text.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.charCodeAt(0) !== 123) continue;
+ try {
+ const rec = JSON.parse(trimmed);
+ if ((rec.type === 'user' || rec.type === 'gemini') && 'content' in rec) return true;
+ return false;
+ } catch {
+ return false;
+ }
+ }
+ return false;
+}
+
+export function detectGeminiJson(parsed) {
+ if (!parsed || typeof parsed !== 'object') return false;
+ if (!Array.isArray(parsed.messages)) return false;
+ return parsed.messages.some((m) => m && (m.type === 'gemini' || m.type === 'user') && 'content' in m);
+}
+
+export function parseGemini(text, path, sessionId) {
+ const session = newSession(path, sessionId);
+ const counters = { turn: 0 };
+ for (const rec of readJsonl(text)) ingestRecord(session, rec, counters);
+ return finalizeSession(session);
+}
+
+export function parseGeminiJson(parsed, path, sessionId) {
+ const session = newSession(path, parsed.sessionId || sessionId);
+ if (parsed.model) session.stats.models.add(parsed.model);
+ const counters = { turn: 0 };
+ for (const rec of parsed.messages) ingestRecord(session, rec, counters);
+ return finalizeSession(session);
+}
src/adapters/grok.js +42 -0
@@ -0,0 +1,42 @@
+import { newSession, finalizeSession, pushTurn, flattenParts, looksSynthetic } from './shared.js';
+
+function messageText(content) {
+ if (typeof content === 'string') return content;
+ return flattenParts(content);
+}
+
+function grokMessages(parsed) {
+ if (Array.isArray(parsed)) return parsed;
+ if (parsed && Array.isArray(parsed.conversation)) return parsed.conversation;
+ if (parsed && Array.isArray(parsed.messages)) return parsed.messages;
+ return null;
+}
+
+export function detectGrok(parsed) {
+ if (!parsed || typeof parsed !== 'object') return false;
+ const messages = grokMessages(parsed);
+ if (!Array.isArray(messages) || !messages.length) return false;
+ return messages.every((m) => m && typeof m === 'object' && 'role' in m && 'content' in m);
+}
+
+export function parseGrok(parsed, path, sessionId) {
+ const messages = grokMessages(parsed) || [];
+ const session = newSession(path, (parsed && parsed.sessionId) || sessionId);
+ if (parsed && parsed.model) session.stats.models.add(parsed.model);
+ let turn = 0;
+ for (const msg of messages) {
+ if (!msg) continue;
+ const ts = msg.timestamp ? new Date(msg.timestamp).toISOString() : null;
+ if (msg.role === 'user') {
+ const text = messageText(msg.content);
+ if (looksSynthetic(text)) continue;
+ pushTurn(session, ++turn, text, ts);
+ } else if (msg.role === 'assistant') {
+ session.stats.assistantLines++;
+ if (Array.isArray(msg.tool_calls)) session.stats.toolUses += msg.tool_calls.length;
+ } else if (msg.role === 'tool') {
+ session.stats.toolUses++;
+ }
+ }
+ return finalizeSession(session);
+}
src/adapters/index.js +58 -0
@@ -0,0 +1,58 @@
+import { basename } from 'node:path';
+import { detectCodex, parseCodex } from './codex.js';
+import { detectGemini, detectGeminiJson, parseGemini, parseGeminiJson } from './gemini.js';
+import { detectChatGPT, parseChatGPT } from './chatgpt.js';
+import { detectCopilot, parseCopilot } from './copilot.js';
+import { detectGrok, parseGrok } from './grok.js';
+import { detectCursor, parseCursor } from './cursor.js';
+
+export const TOOLS = ['claude', 'codex', 'chatgpt', 'gemini', 'copilot', 'grok', 'cursor', 'transcript'];
+
+function tryParseJson(text) {
+ const head = text.trimStart()[0];
+ if (head !== '{' && head !== '[') return null;
+ try {
+ return JSON.parse(text);
+ } catch {
+ return null;
+ }
+}
+
+export function adaptFrom(tool, text, path) {
+ const id = basename(path).replace(/\.(jsonl?|txt|md)$/i, '');
+ const json = tryParseJson(text);
+ switch (tool) {
+ case 'codex':
+ return [parseCodex(text, path, id)];
+ case 'gemini':
+ return json ? [parseGeminiJson(json, path, id)] : [parseGemini(text, path, id)];
+ case 'chatgpt':
+ return parseChatGPT(json, path);
+ case 'copilot':
+ return [parseCopilot(json, path, id)];
+ case 'grok':
+ return [parseGrok(json, path, id)];
+ case 'cursor':
+ return [parseCursor(json, path, id)];
+ default:
+ throw new Error(`unknown --from tool "${tool}" (expected one of: ${TOOLS.join(', ')})`);
+ }
+}
+
+export function autoAdapt(text, path) {
+ const id = basename(path).replace(/\.(jsonl?|txt|md)$/i, '');
+ const json = tryParseJson(text);
+
+ if (json !== null) {
+ if (detectChatGPT(json)) return { tool: 'chatgpt', sessions: parseChatGPT(json, path) };
+ if (detectCopilot(json)) return { tool: 'copilot', sessions: [parseCopilot(json, path, id)] };
+ if (detectGeminiJson(json)) return { tool: 'gemini', sessions: [parseGeminiJson(json, path, id)] };
+ if (detectCursor(json)) return { tool: 'cursor', sessions: [parseCursor(json, path, id)] };
+ if (detectGrok(json)) return { tool: 'grok', sessions: [parseGrok(json, path, id)] };
+ return null;
+ }
+
+ if (detectCodex(text)) return { tool: 'codex', sessions: [parseCodex(text, path, id)] };
+ if (detectGemini(text)) return { tool: 'gemini', sessions: [parseGemini(text, path, id)] };
+ return null;
+}
src/adapters/shared.js +111 -0
@@ -0,0 +1,111 @@
+export function emptyStats() {
+ return {
+ userLines: 0,
+ assistantLines: 0,
+ toolUses: 0,
+ models: new Set(),
+ filesTouched: new Set(),
+ inputTokens: 0,
+ outputTokens: 0,
+ interruptions: 0,
+ };
+}
+
+export function newSession(path, sessionId) {
+ return {
+ sessionId: sessionId || null,
+ path,
+ title: null,
+ customTitle: null,
+ version: null,
+ cwd: null,
+ gitBranch: null,
+ firstTs: null,
+ lastTs: null,
+ prompts: [],
+ index: new Map(),
+ leafUuid: null,
+ activeLeafUuid: null,
+ stats: emptyStats(),
+ isContinuation: false,
+ };
+}
+
+export function finalizeSession(session) {
+ session.stats.models = [...session.stats.models];
+ session.stats.filesTouched = [...session.stats.filesTouched];
+ if (session.customTitle) session.title = session.customTitle;
+ return session;
+}
+
+export function noteTimestamp(session, ts) {
+ if (!ts) return;
+ if (!session.firstTs) session.firstTs = ts;
+ session.lastTs = ts;
+}
+
+export function pushTurn(session, idx, text, ts, { hasImage = false, hadToolResultContext = false } = {}) {
+ const trimmed = (text || '').trim();
+ if (!trimmed && !hasImage) return null;
+ const uuid = `${session.sessionId || 'turn'}-u${idx}`;
+ const parentUuid = session._lastUserUuid || null;
+ session.index.set(uuid, { parentUuid, type: 'user', ts: ts || null });
+ session.leafUuid = uuid;
+ session._lastUserUuid = uuid;
+ session.stats.userLines++;
+ session.prompts.push({
+ uuid,
+ parentUuid,
+ ts: ts || null,
+ text: trimmed || '[image-only prompt: screenshot/annotated feedback]',
+ hasImage,
+ hadToolResultContext,
+ afterInterruption: false,
+ });
+ noteTimestamp(session, ts);
+ return uuid;
+}
+
+export function flattenParts(parts) {
+ if (typeof parts === 'string') return parts;
+ if (!Array.isArray(parts)) {
+ if (parts && typeof parts === 'object' && typeof parts.text === 'string') return parts.text;
+ return '';
+ }
+ const out = [];
+ for (const part of parts) {
+ if (typeof part === 'string') out.push(part);
+ else if (part && typeof part === 'object' && typeof part.text === 'string') out.push(part.text);
+ }
+ return out.join('\n');
+}
+
+export function looksSynthetic(text) {
+ const t = (text || '').trimStart();
+ if (!t) return true;
+ return (
+ t.startsWith('<environment_context>') ||
+ t.startsWith('<permissions instructions>') ||
+ t.startsWith('<collaboration_mode>') ||
+ t.startsWith('<user_instructions>') ||
+ t.startsWith('<system-reminder>')
+ );
+}
+
+export function readJson(text) {
+ return JSON.parse(text);
+}
+
+export function readJsonl(text) {
+ const records = [];
+ for (const line of text.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.charCodeAt(0) !== 123) continue;
+ try {
+ records.push(JSON.parse(trimmed));
+ } catch {
+ continue;
+ }
+ }
+ return records;
+}
src/cli.js +47 -6
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { discoverSessions } from './discover.js';
import { parseSessionFile, parsePlainTranscript } from './parse.js';
+import { adaptFrom, autoAdapt, TOOLS } from './adapters/index.js';
import { classifyPrompts } from './extract.js';
import { buildTree } from './tree.js';
import { scanText, resolveFindings, applyDecisions, shadowScan } from './redact.js';
@@ -25,7 +26,8 @@ const HELP = `TreeTrace - turn AI coding sessions into regression-ready prompt l
Usage:
treetrace auto-discover Claude Code sessions for this directory
- treetrace --file <path>... parse specific transcript files (.jsonl or plain text)
+ treetrace --file <path>... parse specific session/transcript files
+ treetrace --from <tool> --file <path> import another tool's export
treetrace --stdin read a pasted transcript from stdin
treetrace --report write all artifacts and print the human report
treetrace --handoff print an agent-ready handoff brief to stdout
@@ -35,6 +37,8 @@ Usage:
treetrace --memory write and print compact agent memory
Options:
+ --from <tool> input format for --file: claude, codex, chatgpt, gemini,
+ copilot, grok, cursor, transcript (default: auto-detect)
--dir <path> project directory to trace (default: cwd)
--out <file> markdown output path (default: PROMPT_TREE.md)
--report-file <file> human report output path (default: TREETRACE_REPORT.md)
@@ -65,11 +69,7 @@ export async function main(argv) {
sessions = [parsePlainTranscript(text)];
} else if (opts.files.length) {
for (const file of opts.files) {
- if (file.endsWith('.jsonl')) {
- sessions.push(await parseSessionFile(file, { sessionId: basename(file, '.jsonl') }));
- } else {
- sessions.push(parsePlainTranscript(readFileSync(file, 'utf8'), basename(file)));
- }
+ sessions.push(...(await ingestFile(file, opts.from, log)));
}
} else {
const found = discoverSessions(projectDir);
@@ -202,6 +202,40 @@ export async function main(argv) {
if (asked) log(c.dim(` ${plural(asked, 'redaction decision')} saved to .treetrace/redactions.json`));
}
+async function ingestFile(file, from, log) {
+ if (from && from !== 'claude' && from !== 'transcript') {
+ const text = readFileSync(file, 'utf8');
+ return adaptFrom(from, text, file);
+ }
+ if (from === 'claude') {
+ return [await parseSessionFile(file, { sessionId: basename(file, '.jsonl') })];
+ }
+ if (from === 'transcript') {
+ return [parsePlainTranscript(readFileSync(file, 'utf8'), basename(file))];
+ }
+
+ if (file.endsWith('.jsonl')) {
+ const text = readFileSync(file, 'utf8');
+ const adapted = autoAdapt(text, file);
+ if (adapted && adapted.sessions.some((s) => s.prompts.length)) {
+ log(c.dim(` detected ${adapted.tool} format in ${basename(file)}`));
+ return adapted.sessions;
+ }
+ return [await parseSessionFile(file, { sessionId: basename(file, '.jsonl') })];
+ }
+
+ if (file.endsWith('.json')) {
+ const text = readFileSync(file, 'utf8');
+ const adapted = autoAdapt(text, file);
+ if (adapted && adapted.sessions.some((s) => s.prompts.length)) {
+ log(c.dim(` detected ${adapted.tool} format in ${basename(file)}`));
+ return adapted.sessions;
+ }
+ }
+
+ return [parsePlainTranscript(readFileSync(file, 'utf8'), basename(file))];
+}
+
function analysisArtifacts(ttDir, tree, renderOpts) {
return {
failures: {
@@ -327,6 +361,7 @@ function parseArgs(argv) {
quiet: false,
help: false,
version: false,
+ from: null,
dir: null,
out: null,
reportFile: null,
@@ -352,6 +387,12 @@ function parseArgs(argv) {
case '--quiet': opts.quiet = true; break;
case '--help': case '-h': opts.help = true; break;
case '--version': case '-v': opts.version = true; break;
+ case '--from':
+ opts.from = argv[++i];
+ if (!TOOLS.includes(opts.from)) {
+ throw new Error(`unknown --from value "${opts.from}" (expected one of: ${TOOLS.join(', ')})`);
+ }
+ break;
case '--dir': opts.dir = argv[++i]; break;
case '--out': opts.out = argv[++i]; break;
case '--report-file': opts.reportFile = argv[++i]; break;
test/adapters.test.js +137 -0
@@ -0,0 +1,137 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+import { adaptFrom, autoAdapt, TOOLS } from '../src/adapters/index.js';
+import { classifyPrompts } from '../src/extract.js';
+import { buildTree } from '../src/tree.js';
+
+const DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'adapters');
+const fx = (name) => join(DIR, name);
+const read = (name) => readFileSync(fx(name), 'utf8');
+
+function pipeline(sessions) {
+ const nodes = classifyPrompts(sessions);
+ return { nodes, tree: buildTree(sessions, nodes) };
+}
+
+test('codex: parses real rollout JSONL into human prompts', () => {
+ const sessions = adaptFrom('codex', read('codex-session.jsonl'), fx('codex-session.jsonl'));
+ assert.equal(sessions.length, 1);
+ const s = sessions[0];
+ assert.equal(s.prompts.length, 3);
+ assert.ok(s.prompts.every((p) => !p.text.startsWith('<environment_context>')));
+ assert.ok(s.prompts[0].text.includes('/version subcommand'));
+ assert.equal(s.stats.assistantLines, 3);
+ assert.ok(s.stats.toolUses >= 1);
+ assert.equal(s.stats.inputTokens, 5200);
+ const { tree } = pipeline(sessions);
+ assert.equal(tree.roots.length, 1);
+ assert.equal(tree.stats.promptCount, 3);
+});
+
+test('codex: auto-detected from JSONL shape', () => {
+ const detected = autoAdapt(read('codex-session.jsonl'), fx('codex-session.jsonl'));
+ assert.ok(detected);
+ assert.equal(detected.tool, 'codex');
+ assert.equal(detected.sessions[0].prompts.length, 3);
+});
+
+test('chatgpt: parses export mapping, walks user turns, detects model', () => {
+ const sessions = adaptFrom('chatgpt', read('chatgpt-conversations.json'), fx('chatgpt-conversations.json'));
+ assert.equal(sessions.length, 1);
+ const s = sessions[0];
+ assert.equal(s.prompts.length, 1);
+ assert.equal(s.title, 'Debounce a search input in React');
+ assert.ok(s.stats.models.includes('gpt-4o'));
+ assert.ok(s.stats.assistantLines >= 1);
+ assert.ok(s.stats.toolUses >= 1);
+});
+
+test('chatgpt: auto-detected from mapping shape', () => {
+ const detected = autoAdapt(read('chatgpt-conversations.json'), fx('chatgpt-conversations.json'));
+ assert.ok(detected);
+ assert.equal(detected.tool, 'chatgpt');
+});
+
+test('gemini: parses ChatRecordingService session, tools and tokens', () => {
+ const sessions = adaptFrom('gemini', read('gemini-session.json'), fx('gemini-session.json'));
+ const s = sessions[0];
+ assert.equal(s.prompts.length, 3);
+ assert.ok(s.prompts[0].text.includes('health-check'));
+ assert.equal(s.stats.assistantLines, 3);
+ assert.ok(s.stats.toolUses >= 1);
+ assert.ok(s.stats.filesTouched.includes('src/server/health.ts'));
+ assert.ok(s.stats.models.includes('gemini-3-flash-preview'));
+});
+
+test('gemini: auto-detected from session JSON', () => {
+ const detected = autoAdapt(read('gemini-session.json'), fx('gemini-session.json'));
+ assert.ok(detected);
+ assert.equal(detected.tool, 'gemini');
+ assert.equal(detected.sessions[0].prompts.length, 3);
+});
+
+test('copilot: parses requests[] into prompts, counts tool invocations', () => {
+ const sessions = adaptFrom('copilot', read('copilot-chatsession.json'), fx('copilot-chatsession.json'));
+ const s = sessions[0];
+ assert.equal(s.prompts.length, 5);
+ assert.ok(s.prompts[0].text.toLowerCase().includes('html'));
+ assert.equal(s.stats.assistantLines, 5);
+});
+
+test('copilot: auto-detected from requesterUsername/requests', () => {
+ const detected = autoAdapt(read('copilot-chatsession.json'), fx('copilot-chatsession.json'));
+ assert.ok(detected);
+ assert.equal(detected.tool, 'copilot');
+});
+
+test('cursor: parses exported session messages, model and files', () => {
+ const sessions = adaptFrom('cursor', read('cursor-export.json'), fx('cursor-export.json'));
+ const s = sessions[0];
+ assert.equal(s.prompts.length, 3);
+ assert.equal(s.title, 'Add pagination to the users table');
+ assert.ok(s.stats.models.includes('claude-3.5-sonnet'));
+ assert.ok(s.stats.toolUses >= 1);
+ assert.ok(s.stats.filesTouched.some((f) => f.endsWith('Users.tsx')));
+});
+
+test('cursor: auto-detected from exported session shape', () => {
+ const detected = autoAdapt(read('cursor-export.json'), fx('cursor-export.json'));
+ assert.ok(detected);
+ assert.equal(detected.tool, 'cursor');
+});
+
+test('grok: parses conversation[] role/content into prompts', () => {
+ const sessions = adaptFrom('grok', read('grok-session.json'), fx('grok-session.json'));
+ const s = sessions[0];
+ assert.equal(s.prompts.length, 3);
+ assert.ok(s.prompts[0].text.includes('Fibonacci'));
+ assert.ok(s.stats.models.includes('grok-4'));
+ assert.equal(s.stats.assistantLines, 2);
+});
+
+test('grok: auto-detected from conversation messages', () => {
+ const detected = autoAdapt(read('grok-session.json'), fx('grok-session.json'));
+ assert.ok(detected);
+ assert.equal(detected.tool, 'grok');
+});
+
+test('adapter output flows through the full classify/tree pipeline', () => {
+ for (const name of ['codex-session.jsonl', 'gemini-session.json', 'cursor-export.json', 'grok-session.json']) {
+ const text = read(name);
+ const detected = autoAdapt(text, fx(name));
+ assert.ok(detected, `no detection for ${name}`);
+ const { tree } = pipeline(detected.sessions);
+ assert.ok(tree.nodes.length >= 1, `no nodes for ${name}`);
+ assert.equal(tree.nodes[0].kind, 'root', `first node not root for ${name}`);
+ assert.ok(tree.roots.length >= 1);
+ }
+});
+
+test('adaptFrom rejects an unknown tool name', () => {
+ assert.throws(() => adaptFrom('notatool', '{}', 'x.json'), /unknown/);
+ assert.ok(TOOLS.includes('codex') && TOOLS.includes('cursor'));
+});
test/fixtures/adapters/PROVENANCE.md +67 -0
@@ -0,0 +1,67 @@
+# Adapter fixture provenance
+
+Each fixture below reproduces the real on-disk or export schema of the tool it
+represents. Where the structure came from a real session or a real published
+sample, the message text has been replaced with neutral placeholder content so
+no private conversation is republished. The field shapes, key names, value
+types, and nesting are kept exactly as the real format.
+
+## codex-session.jsonl
+- Format: Codex CLI rollout JSONL (`~/.codex/sessions/.../rollout-*.jsonl`).
+- Source: a real Codex CLI session captured locally (cli_version 0.139.0).
+- Scrubbing: message text, cwd, instructions, and turn context replaced with
+ neutral placeholders. Event/record schema (`session_meta`, `turn_context`,
+ `response_item` messages, `function_call`, `token_count`) is unchanged.
+- Status: VERIFIED against the original real session.
+
+## gemini-session.json
+- Format: gemini-cli ChatRecordingService session JSON.
+- Source: google-gemini/gemini-cli, memory-tests/large-chat-session.json
+ (https://github.com/google-gemini/gemini-cli), Apache-2.0.
+- Scrubbing: a short slice of the real file with message `content`, `thoughts`,
+ and `toolCalls` text replaced with neutral placeholders. The `sessionId`,
+ `projectHash`, `messages[].type`, `content`, `tokens`, `model`, and
+ `toolCalls` shapes are unchanged.
+- Status: VERIFIED. The full real file parses through the adapter (51 user
+ prompts, 1334 model turns, model gemini-3-flash-preview).
+
+## chatgpt-conversations.json
+- Format: ChatGPT/OpenAI account export `conversations.json` (array of
+ conversations, each with a `mapping` of node id to message node).
+- Source: sanand0/openai-conversations, samples/seoul-weather-early-october.json
+ (https://github.com/sanand0/openai-conversations), MIT.
+- Scrubbing: real export structure kept (`mapping`, `author.role`,
+ `content.content_type`, `content.parts`, `parent`, `children`,
+ `create_time`); all message text and tool output replaced with placeholders;
+ heavy per-node metadata trimmed to `model_slug` only.
+- Status: VERIFIED. The original sample parses through the adapter.
+
+## copilot-chatsession.json
+- Format: VS Code GitHub Copilot Chat session JSON (version 3, `requests[]`).
+- Source: Timcooking/VSCode-Copilot-Chat-Viewer, demo-chat.json
+ (https://github.com/Timcooking/VSCode-Copilot-Chat-Viewer), MIT.
+- Scrubbing: real `requesterUsername`, `responderUsername`, `requests[].message`
+ (`text` + `parts`), and `response[].value` shapes kept; text replaced with
+ neutral placeholders.
+- Status: VERIFIED against the published demo session.
+
+## cursor-export.json
+- Format: Cursor exported-chat JSON, matching the cursor-history exporter's
+ single-session JSON (`id`, `title`, `messages[].role/content/model/toolCalls`).
+- Source: schema from S2thend/cursor-history src/core/types.ts and
+ src/cli/formatters/json.ts (https://github.com/S2thend/cursor-history), MIT.
+ Cursor stores chat in state.vscdb (SQLite); the adapter ingests the exported
+ JSON because TreeTrace ships with zero runtime dependencies and cannot open
+ SQLite. This fixture mirrors that exporter's output.
+- Status: VERIFIED against the exporter's documented schema (no SQLite needed).
+
+## grok-session.json
+- Format: Grok CLI exported conversation JSON
+ (`timestamp`, `model`, `messageCount`, `conversation[].role/content`), the
+ xAI OpenAI-compatible message shape.
+- Source: schema from lalomorales22/grok-4-cli lib/export.js `exportToJSON`
+ (https://github.com/lalomorales22/grok-4-cli). The primary superagent-ai
+ grok-cli stores history in SQLite (no JSON on disk), so this adapter targets
+ the exported-JSON shape.
+- Status: EXPERIMENTAL. Built to the exporter's documented JSON shape; not yet
+ validated against a captured real Grok session on this machine.
test/fixtures/adapters/chatgpt-conversations.json +359 -0
@@ -0,0 +1,359 @@
+[
+ {
+ "title": "Debounce a search input in React",
+ "create_time": 1727699286.485543,
+ "update_time": 1727699296.187922,
+ "mapping": {
+ "350877d5-b411-41fa-b87b-0f7680d3f061": {
+ "id": "350877d5-b411-41fa-b87b-0f7680d3f061",
+ "message": {
+ "id": "350877d5-b411-41fa-b87b-0f7680d3f061",
+ "author": {
+ "role": "system",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ ""
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 0.0,
+ "metadata": {},
+ "recipient": "all",
+ "channel": null
+ },
+ "parent": "bbb23b75-51a7-4302-9331-efc1d6ebfc4d",
+ "children": [
+ "091b5281-b7e3-4141-bb44-f351aa6c14c3"
+ ]
+ },
+ "bbb23b75-51a7-4302-9331-efc1d6ebfc4d": {
+ "id": "bbb23b75-51a7-4302-9331-efc1d6ebfc4d",
+ "message": null,
+ "parent": null,
+ "children": [
+ "350877d5-b411-41fa-b87b-0f7680d3f061"
+ ]
+ },
+ "091b5281-b7e3-4141-bb44-f351aa6c14c3": {
+ "id": "091b5281-b7e3-4141-bb44-f351aa6c14c3",
+ "message": {
+ "id": "091b5281-b7e3-4141-bb44-f351aa6c14c3",
+ "author": {
+ "role": "system",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ ""
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1.0,
+ "metadata": {},
+ "recipient": "all",
+ "channel": null
+ },
+ "parent": "350877d5-b411-41fa-b87b-0f7680d3f061",
+ "children": [
+ "bbb2131f-0bfa-4467-9782-b2e4b7bbcf57"
+ ]
+ },
+ "bbb2131f-0bfa-4467-9782-b2e4b7bbcf57": {
+ "id": "bbb2131f-0bfa-4467-9782-b2e4b7bbcf57",
+ "message": {
+ "id": "bbb2131f-0bfa-4467-9782-b2e4b7bbcf57",
+ "author": {
+ "role": "user",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1727699286.491499,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "What is a good way to debounce a search input in React?"
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1.0,
+ "metadata": {},
+ "recipient": "all",
+ "channel": null
+ },
+ "parent": "091b5281-b7e3-4141-bb44-f351aa6c14c3",
+ "children": [
+ "1433a3b0-30ad-4905-8214-97e7301aba3f"
+ ]
+ },
+ "1433a3b0-30ad-4905-8214-97e7301aba3f": {
+ "id": "1433a3b0-30ad-4905-8214-97e7301aba3f",
+ "message": {
+ "id": "1433a3b0-30ad-4905-8214-97e7301aba3f",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1727699293.376943,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "Use a useEffect with a setTimeout cleared on each keystroke, or a small useDebounce hook."
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1.0,
+ "metadata": {
+ "model_slug": "gpt-4o"
+ },
+ "recipient": "all",
+ "channel": null
+ },
+ "parent": "bbb2131f-0bfa-4467-9782-b2e4b7bbcf57",
+ "children": [
+ "f7af31ac-d221-4500-93cb-39a0858bc434"
+ ]
+ },
+ "f7af31ac-d221-4500-93cb-39a0858bc434": {
+ "id": "f7af31ac-d221-4500-93cb-39a0858bc434",
+ "message": {
+ "id": "f7af31ac-d221-4500-93cb-39a0858bc434",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1727699293.377045,
+ "content": {
+ "content_type": "code",
+ "language": "unknown",
+ "response_format_name": null,
+ "text": "search(\"average temperature in Seoul early October\")"
+ },
+ "status": "finished_successfully",
+ "end_turn": false,
+ "weight": 1.0,
+ "metadata": {
+ "model_slug": "gpt-4o"
+ },
+ "recipient": "browser",
+ "channel": null
+ },
+ "parent": "1433a3b0-30ad-4905-8214-97e7301aba3f",
+ "children": [
+ "2c30b440-db17-4b17-ad71-82d08d61d675"
+ ]
+ },
+ "2c30b440-db17-4b17-ad71-82d08d61d675": {
+ "id": "2c30b440-db17-4b17-ad71-82d08d61d675",
+ "message": {
+ "id": "2c30b440-db17-4b17-ad71-82d08d61d675",
+ "author": {
+ "role": "tool",
+ "name": "browser",
+ "metadata": {}
+ },
+ "create_time": 1727699293.37713,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "redacted tool output"
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 0.0,
+ "metadata": {
+ "model_slug": "gpt-4o"
+ },
+ "recipient": "all",
+ "channel": null
+ },
+ "parent": "f7af31ac-d221-4500-93cb-39a0858bc434",
+ "children": [
+ "4503a2a3-a0d4-485b-be1b-5ad93cd8d836"
+ ]
+ },
+ "4503a2a3-a0d4-485b-be1b-5ad93cd8d836": {
+ "id": "4503a2a3-a0d4-485b-be1b-5ad93cd8d836",
+ "message": {
+ "id": "4503a2a3-a0d4-485b-be1b-5ad93cd8d836",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1727699293.377201,
+ "content": {
+ "content_type": "code",
+ "language": "unknown",
+ "response_format_name": null,
+ "text": "mclick([0, 3, 2, 9, 1])"
+ },
+ "status": "finished_successfully",
+ "end_turn": false,
+ "weight": 1.0,
+ "metadata": {
+ "model_slug": "gpt-4o"
+ },
+ "recipient": "browser",
+ "channel": null
+ },
+ "parent": "2c30b440-db17-4b17-ad71-82d08d61d675",
+ "children": [
+ "38983cbf-6a71-4d1b-959b-aadd6a97230b"
+ ]
+ },
+ "38983cbf-6a71-4d1b-959b-aadd6a97230b": {
+ "id": "38983cbf-6a71-4d1b-959b-aadd6a97230b",
+ "message": {
+ "id": "38983cbf-6a71-4d1b-959b-aadd6a97230b",
+ "author": {
+ "role": "tool",
+ "name": "browser",
+ "metadata": {}
+ },
+ "create_time": 1727699293.377272,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "redacted tool output"
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 0.0,
+ "metadata": {
+ "model_slug": "gpt-4o"
+ },
+ "recipient": "all",
+ "channel": null
+ },
+ "parent": "4503a2a3-a0d4-485b-be1b-5ad93cd8d836",
+ "children": [
+ "24ab741e-ccaf-46d9-8049-9d7031de88a3"
+ ]
+ },
+ "24ab741e-ccaf-46d9-8049-9d7031de88a3": {
+ "id": "24ab741e-ccaf-46d9-8049-9d7031de88a3",
+ "message": {
+ "id": "24ab741e-ccaf-46d9-8049-9d7031de88a3",
+ "author": {
+ "role": "tool",
+ "name": "browser",
+ "metadata": {}
+ },
+ "create_time": 1727699293.37734,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "redacted tool output"
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 0.0,
+ "metadata": {
+ "model_slug": "gpt-4o"
+ },
+ "recipient": "all",
+ "channel": null
+ },
+ "parent": "38983cbf-6a71-4d1b-959b-aadd6a97230b",
+ "children": [
+ "a4f5ed82-25cb-40b2-92a1-689c9f9887ec"
+ ]
+ },
+ "a4f5ed82-25cb-40b2-92a1-689c9f9887ec": {
+ "id": "a4f5ed82-25cb-40b2-92a1-689c9f9887ec",
+ "message": {
+ "id": "a4f5ed82-25cb-40b2-92a1-689c9f9887ec",
+ "author": {
+ "role": "tool",
+ "name": "browser",
+ "metadata": {}
+ },
+ "create_time": 1727699293.377407,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "redacted tool output"
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 0.0,
+ "metadata": {
+ "model_slug": "gpt-4o"
+ },
+ "recipient": "all",
+ "channel": null
+ },
+ "parent": "24ab741e-ccaf-46d9-8049-9d7031de88a3",
+ "children": [
+ "e58a766b-0b78-49ff-bfaf-fee6be2689ba"
+ ]
+ },
+ "e58a766b-0b78-49ff-bfaf-fee6be2689ba": {
+ "id": "e58a766b-0b78-49ff-bfaf-fee6be2689ba",
+ "message": {
+ "id": "e58a766b-0b78-49ff-bfaf-fee6be2689ba",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1727699293.377481,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "Use a useEffect with a setTimeout cleared on each keystroke, or a small useDebounce hook."
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 1.0,
+ "metadata": {
+ "model_slug": "gpt-4o"
+ },
+ "recipient": "all",
+ "channel": null
+ },
+ "parent": "a4f5ed82-25cb-40b2-92a1-689c9f9887ec",
+ "children": []
+ }
+ },
+ "moderation_results": [],
+ "current_node": "e58a766b-0b78-49ff-bfaf-fee6be2689ba",
+ "plugin_ids": null,
+ "conversation_id": "anon-convo-chatgpt-demo",
+ "conversation_template_id": null,
+ "gizmo_id": null,
+ "gizmo_type": null,
+ "is_archived": false,
+ "is_starred": null,
+ "safe_urls": [
+ "https://weather-and-climate.com/Seoul-October-averages",
+ "https://wanderlog.com/weather/9/10/seoul-weather-in-october",
+ "https://www.weather-atlas.com/en/south-korea/seoul-weather-october"
+ ],
+ "default_model_slug": "gpt-4o",
+ "conversation_origin": null,
+ "voice": null,
+ "async_status": null,
+ "disabled_tool_ids": [],
+ "id": "66fa9956-4144-800c-b052-6f0187d888d4"
+ }
+]
test/fixtures/adapters/codex-session.jsonl +11 -0
@@ -0,0 +1,11 @@
+{"timestamp": "2026-06-12T01:58:43.049Z", "type": "session_meta", "payload": {"id": "anon-codex-demo", "timestamp": "2026-06-12T01:57:43.912Z", "cwd": "/home/dev/project", "originator": "codex-tui", "cli_version": "0.139.0", "source": "cli", "thread_source": "user", "model_provider": "openai", "base_instructions": {"text": "redacted"}}}
+{"timestamp": "2026-06-12T01:58:43.191Z", "type": "turn_context", "payload": {"turn_id": "anon-turn", "cwd": "/home/dev/project", "workspace_roots": ["/home/dev/project"], "model": "gpt-5.5", "approval_policy": "never", "sandbox_policy": {"type": "danger-full-access"}, "collaboration_mode": {"mode": "default"}}}
+{"timestamp": "2026-06-12T01:58:00.000Z", "type": "response_item", "payload": {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "<environment_context>\n <cwd>/home/dev/project</cwd>\n</environment_context>"}]}}
+{"timestamp": "2026-06-12T01:58:00.000Z", "type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Add a /version subcommand to the CLI that prints the package version."}]}}
+{"timestamp": "2026-06-12T01:58:00.000Z", "type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "I'll add the subcommand and wire it to package.json."}]}}
+{"timestamp": "2026-06-12T01:58:00.000Z", "type": "response_item", "payload": {"type": "function_call", "name": "exec_command", "arguments": "{\"cmd\": \"apply_patch\", \"workdir\": \"/home/dev/project\", \"path\": \"src/cli.js\"}", "call_id": "call_RnlGcJdacQlKT6Twaj6q1w7g"}}
+{"timestamp": "2026-06-12T01:58:00.000Z", "type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Also make `--version` an alias for it."}]}}
+{"timestamp": "2026-06-12T01:58:00.000Z", "type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Added the alias."}]}}
+{"timestamp": "2026-06-12T01:58:00.000Z", "type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Write a test that runs the binary with --version and checks the output."}]}}
+{"timestamp": "2026-06-12T01:58:00.000Z", "type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Done."}]}}
+{"timestamp": "2026-06-12T01:59:00.000Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 5200, "cached_input_tokens": 1000, "output_tokens": 640, "reasoning_output_tokens": 40, "total_tokens": 5840}}}}
test/fixtures/adapters/copilot-chatsession.json +250 -0
@@ -0,0 +1,250 @@
+{
+ "version": 3,
+ "requesterUsername": "user",
+ "requesterAvatarIconUri": {
+ "$mid": 1,
+ "path": "/u/12345678",
+ "scheme": "https",
+ "authority": "avatars.githubusercontent.com",
+ "query": "v=4"
+ },
+ "responderUsername": "GitHub Copilot",
+ "responderAvatarIconUri": {
+ "id": "copilot"
+ },
+ "initialLocation": "panel",
+ "requests": [
+ {
+ "requestId": "request_demo_001",
+ "message": {
+ "text": "Create a simple HTML page with a heading and a paragraph.",
+ "parts": [
+ {
+ "range": {
+ "start": 0,
+ "endExclusive": 32
+ },
+ "editorRange": {
+ "startLineNumber": 1,
+ "startColumn": 1,
+ "endLineNumber": 1,
+ "endColumn": 33
+ },
+ "text": "Create a simple HTML page with a heading and a paragraph.",
+ "kind": "text"
+ }
+ ]
+ },
+ "variableData": {
+ "variables": []
+ },
+ "response": [
+ {
+ "value": "Here is the updated page with your requested change.",
+ "supportThemeIcons": false,
+ "supportHtml": false,
+ "baseUri": {
+ "$mid": 1,
+ "path": "/C:/Users/TestUser/Documents/",
+ "scheme": "file"
+ }
+ },
+ {
+ "kind": "prepareToolInvocation",
+ "toolName": "copilot_createFile"
+ },
+ {
+ "kind": "toolInvocationSerialized",
+ "invocationMessage": {
+ "value": "\u6b63\u5728\u521b\u5efa HTML \u6587\u4ef6",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ },
+ "pastTenseMessage": {
+ "value": "\u5df2\u521b\u5efa HTML \u6587\u4ef6",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ },
+ "isConfirmed": true,
+ "isComplete": true,
+ "toolCallId": "demo_tool_001",
+ "toolId": "copilot_createFile"
+ }
+ ]
+ },
+ {
+ "requestId": "request_demo_002",
+ "message": {
+ "text": "Can you add some CSS styling to this page?",
+ "parts": [
+ {
+ "range": {
+ "start": 0,
+ "endExclusive": 19
+ },
+ "editorRange": {
+ "startLineNumber": 1,
+ "startColumn": 1,
+ "endLineNumber": 1,
+ "endColumn": 20
+ },
+ "text": "Can you add some CSS styling to this page?",
+ "kind": "text"
+ }
+ ]
+ },
+ "variableData": {
+ "variables": []
+ },
+ "response": [
+ {
+ "value": "Here is the updated page with your requested change.",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ },
+ {
+ "value": "Here is the updated page with your requested change.",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ }
+ ]
+ },
+ {
+ "requestId": "request_demo_003",
+ "message": {
+ "text": "Add some JavaScript interactivity.",
+ "parts": [
+ {
+ "range": {
+ "start": 0,
+ "endExclusive": 18
+ },
+ "editorRange": {
+ "startLineNumber": 1,
+ "startColumn": 1,
+ "endLineNumber": 1,
+ "endColumn": 19
+ },
+ "text": "Add some JavaScript interactivity.",
+ "kind": "text"
+ }
+ ]
+ },
+ "variableData": {
+ "variables": []
+ },
+ "response": [
+ {
+ "value": "Here is the updated page with your requested change.",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ },
+ {
+ "kind": "prepareToolInvocation",
+ "toolName": "copilot_createFile"
+ },
+ {
+ "kind": "toolInvocationSerialized",
+ "invocationMessage": {
+ "value": "\u6b63\u5728\u521b\u5efa JavaScript \u6587\u4ef6",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ },
+ "pastTenseMessage": {
+ "value": "\u5df2\u521b\u5efa JavaScript \u6587\u4ef6\uff0c\u5305\u542b\u4ea4\u4e92\u529f\u80fd",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ },
+ "isConfirmed": true,
+ "isComplete": true,
+ "toolCallId": "demo_tool_002",
+ "toolId": "copilot_createFile"
+ },
+ {
+ "value": "Here is the updated page with your requested change.",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ }
+ ]
+ },
+ {
+ "requestId": "request_demo_004",
+ "message": {
+ "text": "How can I make the page responsive?",
+ "parts": [
+ {
+ "range": {
+ "start": 0,
+ "endExclusive": 12
+ },
+ "editorRange": {
+ "startLineNumber": 1,
+ "startColumn": 1,
+ "endLineNumber": 1,
+ "endColumn": 13
+ },
+ "text": "How can I make the page responsive?",
+ "kind": "text"
+ }
+ ]
+ },
+ "variableData": {
+ "variables": []
+ },
+ "response": [
+ {
+ "value": "Here is the updated page with your requested change.",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ },
+ {
+ "value": "Here is the updated page with your requested change.",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ }
+ ]
+ },
+ {
+ "requestId": "request_demo_005",
+ "message": {
+ "text": "Optimize the page for performance.",
+ "parts": [
+ {
+ "range": {
+ "start": 0,
+ "endExclusive": 9
+ },
+ "editorRange": {
+ "startLineNumber": 1,
+ "startColumn": 1,
+ "endLineNumber": 1,
+ "endColumn": 10
+ },
+ "text": "Optimize the page for performance.",
+ "kind": "text"
+ }
+ ]
+ },
+ "variableData": {
+ "variables": []
+ },
+ "response": [
+ {
+ "value": "Here is the updated page with your requested change.",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ },
+ {
+ "value": "Here is the updated page with your requested change.",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ },
+ {
+ "value": "Here is the updated page with your requested change.",
+ "supportThemeIcons": false,
+ "supportHtml": false
+ }
+ ]
+ }
+ ]
+}
test/fixtures/adapters/cursor-export.json +62 -0
@@ -0,0 +1,62 @@
+{
+ "index": 1,
+ "id": "sess-7f3a2b10",
+ "title": "Add pagination to the users table",
+ "createdAt": "2026-05-04T14:02:11.000Z",
+ "lastUpdatedAt": "2026-05-04T14:19:48.000Z",
+ "messageCount": 6,
+ "workspaceId": "ws-9c21",
+ "workspacePath": "/home/dev/acme-dashboard",
+ "source": "global",
+ "activeBranchBubbleIds": ["b1", "b2", "b3", "b4", "b5", "b6"],
+ "messages": [
+ {
+ "id": "b1",
+ "role": "user",
+ "content": "Add pagination to the users table in src/pages/Users.tsx, 25 rows per page.",
+ "timestamp": "2026-05-04T14:02:11.000Z",
+ "codeBlocks": []
+ },
+ {
+ "id": "b2",
+ "role": "assistant",
+ "content": "I'll add a paginated table with a page size of 25 and wire up the controls.",
+ "timestamp": "2026-05-04T14:02:40.000Z",
+ "codeBlocks": [],
+ "model": "claude-3.5-sonnet",
+ "toolCalls": [
+ { "name": "edit_file", "filePath": "/home/dev/acme-dashboard/src/pages/Users.tsx", "args": { "path": "src/pages/Users.tsx" } }
+ ]
+ },
+ {
+ "id": "b3",
+ "role": "user",
+ "content": "The page buttons overflow on mobile. Make them wrap.",
+ "timestamp": "2026-05-04T14:09:02.000Z",
+ "codeBlocks": []
+ },
+ {
+ "id": "b4",
+ "role": "assistant",
+ "content": "Wrapped the pagination controls with a flex container that wraps below 480px.",
+ "timestamp": "2026-05-04T14:09:31.000Z",
+ "codeBlocks": [],
+ "model": "claude-3.5-sonnet"
+ },
+ {
+ "id": "b5",
+ "role": "user",
+ "content": "Now also persist the current page in the URL query string.",
+ "timestamp": "2026-05-04T14:16:20.000Z",
+ "codeBlocks": []
+ },
+ {
+ "id": "b6",
+ "role": "assistant",
+ "content": "Done. The page index is now synced to the ?page= query parameter.",
+ "timestamp": "2026-05-04T14:16:55.000Z",
+ "codeBlocks": [],
+ "model": "claude-3.5-sonnet"
+ }
+ ]
+}
test/fixtures/adapters/gemini-session.json +145 -0
@@ -0,0 +1,145 @@
+{
+ "sessionId": "anon-session-gemini-demo",
+ "projectHash": "anon-project-hash",
+ "startTime": "2026-04-01T22:01:58.817Z",
+ "lastUpdated": "2026-04-06T20:33:07.995Z",
+ "messages": [
+ {
+ "id": "anon-user-0",
+ "timestamp": "2026-04-01T22:02:01.193Z",
+ "type": "user",
+ "content": [
+ {
+ "text": "Add a health-check endpoint at GET /healthz that returns 200 and the build version."
+ }
+ ],
+ "displayContent": [
+ {
+ "text": "**Objective:** Implement the core data models, loading logic, and registry for the \"Agent Teams\" feature in Gemini CLI.\n\n**Context:**\n- Agent Teams are collections of sub-agents paired with orchestration instructions (`TEAM.md`).\n- A team is defined by a directory in `.gemini/teams/<team-name>/` containing a `TEAM.md` (metadata + instructions) and an `agents/` sub-directory.\n- You are on branch `feature/agent-teams`.\n\n**Instructions:**\nFollow the detailed specification in **`plans/TASK-01.md`** and **`plans/agent-teams.md`** strictly.\n\n**Key Implementation Points:**\n1. **Types:** Update `packages/core/src/agents/types.ts` to include the `TeamDefinition` interface.\n2. **Loader:** Create `packages/core/src/agents/teamLoader.ts`. Reuse the existing `parseAgentMarkdown` and `loadAgentsFromDirectory` logic from `agentLoader.ts` to ensure consistency.\n3. **Registry:** Create `packages/core/src/agents/teamRegistry.ts`. It must manage the lifecycle of team discovery and track the `activeTeam`.\n4. **Integration:** Ensure that agents belonging to a team are also registered in the global `AgentRegistry` so they are available as `SubagentTool`s.\n\n**Engineering Standards:**\n- Maintain the existing coding style and license headers (Apache-2.0).\n- Prioritize type safety and use `zod` for validation where applicable.\n- **Testing is mandatory:** Create unit tests in `packages/core/src/agents/teamLoader.test.ts` and `packages/core/src/agents/teamRegistry.test.ts`.\n\n**Verification:**\nRun `npm test -w @google/gemini-cli-core` to verify the new services.\n"
+ }
+ ]
+ },
+ {
+ "id": "anon-gemini-0",
+ "timestamp": "2026-04-01T22:02:04.331Z",
+ "type": "gemini",
+ "content": [
+ {
+ "text": "Added the handler and wired the route."
+ }
+ ],
+ "thoughts": [
+ {
+ "subject": "plan",
+ "description": "redacted"
+ }
+ ],
+ "tokens": {
+ "input": 18753,
+ "output": 190,
+ "cached": 0,
+ "thoughts": 133,
+ "tool": 0,
+ "total": 19076
+ },
+ "model": "gemini-3-flash-preview",
+ "toolCalls": [
+ {
+ "id": "nn28ue0q",
+ "name": "update_topic",
+ "args": {
+ "file_path": "src/server/health.ts"
+ },
+ "result": "redacted",
+ "status": "success",
+ "timestamp": "2026-04-01T22:02:04.398Z",
+ "resultDisplay": "redacted",
+ "description": "redacted",
+ "displayName": "Update Topic Context",
+ "renderOutputAsMarkdown": true
+ }
+ ]
+ },
+ {
+ "id": "anon-user-1",
+ "timestamp": "2026-04-01T22:02:01.193Z",
+ "type": "user",
+ "content": [
+ {
+ "text": "The /healthz handler should also report uptime in seconds."
+ }
+ ],
+ "displayContent": [
+ {
+ "text": "**Objective:** Implement the core data models, loading logic, and registry for the \"Agent Teams\" feature in Gemini CLI.\n\n**Context:**\n- Agent Teams are collections of sub-agents paired with orchestration instructions (`TEAM.md`).\n- A team is defined by a directory in `.gemini/teams/<team-name>/` containing a `TEAM.md` (metadata + instructions) and an `agents/` sub-directory.\n- You are on branch `feature/agent-teams`.\n\n**Instructions:**\nFollow the detailed specification in **`plans/TASK-01.md`** and **`plans/agent-teams.md`** strictly.\n\n**Key Implementation Points:**\n1. **Types:** Update `packages/core/src/agents/types.ts` to include the `TeamDefinition` interface.\n2. **Loader:** Create `packages/core/src/agents/teamLoader.ts`. Reuse the existing `parseAgentMarkdown` and `loadAgentsFromDirectory` logic from `agentLoader.ts` to ensure consistency.\n3. **Registry:** Create `packages/core/src/agents/teamRegistry.ts`. It must manage the lifecycle of team discovery and track the `activeTeam`.\n4. **Integration:** Ensure that agents belonging to a team are also registered in the global `AgentRegistry` so they are available as `SubagentTool`s.\n\n**Engineering Standards:**\n- Maintain the existing coding style and license headers (Apache-2.0).\n- Prioritize type safety and use `zod` for validation where applicable.\n- **Testing is mandatory:** Create unit tests in `packages/core/src/agents/teamLoader.test.ts` and `packages/core/src/agents/teamRegistry.test.ts`.\n\n**Verification:**\nRun `npm test -w @google/gemini-cli-core` to verify the new services.\n"
+ }
+ ]
+ },
+ {
+ "id": "anon-gemini-1",
+ "timestamp": "2026-04-01T22:02:04.331Z",
+ "type": "gemini",
+ "content": [
+ {
+ "text": "Included process uptime in the JSON payload."
+ }
+ ],
+ "thoughts": [
+ {
+ "subject": "plan",
+ "description": "redacted"
+ }
+ ],
+ "tokens": {
+ "input": 18753,
+ "output": 190,
+ "cached": 0,
+ "thoughts": 133,
+ "tool": 0,
+ "total": 19076
+ },
+ "model": "gemini-3-flash-preview"
+ },
+ {
+ "id": "anon-user-2",
+ "timestamp": "2026-04-01T22:02:01.193Z",
+ "type": "user",
+ "content": [
+ {
+ "text": "Write a unit test for the health-check endpoint."
+ }
+ ],
+ "displayContent": [
+ {
+ "text": "**Objective:** Implement the core data models, loading logic, and registry for the \"Agent Teams\" feature in Gemini CLI.\n\n**Context:**\n- Agent Teams are collections of sub-agents paired with orchestration instructions (`TEAM.md`).\n- A team is defined by a directory in `.gemini/teams/<team-name>/` containing a `TEAM.md` (metadata + instructions) and an `agents/` sub-directory.\n- You are on branch `feature/agent-teams`.\n\n**Instructions:**\nFollow the detailed specification in **`plans/TASK-01.md`** and **`plans/agent-teams.md`** strictly.\n\n**Key Implementation Points:**\n1. **Types:** Update `packages/core/src/agents/types.ts` to include the `TeamDefinition` interface.\n2. **Loader:** Create `packages/core/src/agents/teamLoader.ts`. Reuse the existing `parseAgentMarkdown` and `loadAgentsFromDirectory` logic from `agentLoader.ts` to ensure consistency.\n3. **Registry:** Create `packages/core/src/agents/teamRegistry.ts`. It must manage the lifecycle of team discovery and track the `activeTeam`.\n4. **Integration:** Ensure that agents belonging to a team are also registered in the global `AgentRegistry` so they are available as `SubagentTool`s.\n\n**Engineering Standards:**\n- Maintain the existing coding style and license headers (Apache-2.0).\n- Prioritize type safety and use `zod` for validation where applicable.\n- **Testing is mandatory:** Create unit tests in `packages/core/src/agents/teamLoader.test.ts` and `packages/core/src/agents/teamRegistry.test.ts`.\n\n**Verification:**\nRun `npm test -w @google/gemini-cli-core` to verify the new services.\n"
+ }
+ ]
+ },
+ {
+ "id": "anon-gemini-2",
+ "timestamp": "2026-04-01T22:02:04.331Z",
+ "type": "gemini",
+ "content": [
+ {
+ "text": "Added a test asserting status 200 and the version field."
+ }
+ ],
+ "thoughts": [
+ {
+ "subject": "plan",
+ "description": "redacted"
+ }
+ ],
+ "tokens": {
+ "input": 18753,
+ "output": 190,
+ "cached": 0,
+ "thoughts": 133,
+ "tool": 0,
+ "total": 19076
+ },
+ "model": "gemini-3-flash-preview"
+ }
+ ],
+ "kind": "main"
+}
test/fixtures/adapters/grok-session.json +13 -0
@@ -0,0 +1,13 @@
+{
+ "timestamp": "2026-05-09T18:30:00.000Z",
+ "model": "grok-4",
+ "messageCount": 6,
+ "conversation": [
+ { "role": "system", "content": "You are a helpful coding assistant." },
+ { "role": "user", "content": "Write a Python function that returns the nth Fibonacci number iteratively." },
+ { "role": "assistant", "content": "Here is an iterative implementation that runs in O(n) time and O(1) space." },
+ { "role": "user", "content": "Add memoization and handle negative inputs by raising ValueError." },
+ { "role": "assistant", "content": "Updated the function to cache results and validate the input." },
+ { "role": "user", "content": "Now write a quick pytest covering n=0, n=1, n=10 and a negative case." }
+ ]
+}