diff --git a/cspell.json b/cspell.json index accddd6..6285a61 100644 --- a/cspell.json +++ b/cspell.json @@ -3,14 +3,17 @@ "language": "en", "minWordLength": 5, "words": [ - "aicoach", "acked", "affordances", + "aicoach", "akiaiosfodnn", "allpending", + "Antigravity", + "antigravity", "antipatterns", "aymen", "backpressure", + "badlogic", "baprs", "behaviour", "bglpat", @@ -58,9 +61,10 @@ "knip", "leitner", "lookback", - "metas", "metachars", "metaprogramming", + "metas", + "multiedit", "onedrive", "opencode", "optim", @@ -73,13 +77,13 @@ "pycache", "pyproject", "pytest", - "renderable", "recompiles", "reindexing", + "renderable", "reparsed", "reqs", - "sanjay", "sandboxed", + "sanjay", "sdlc", "segoe", "sidechain", @@ -91,19 +95,21 @@ "toolsmith", "treemap", "tseslint", - "undercount", "unacked", + "undercount", "unflushed", "unparseable", "upskilling", + "varint", + "varints", "visualbasic", "vitest", "vnode", "vnodes", "vsce", "vscode", - "workhours", "wordlist", + "workhours", "xhigh" ], "flagWords": [], diff --git a/package.json b/package.json index 4376809..258d62d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ai-engineer-coach", "displayName": "AI Engineer Coach", - "description": "Analyze your AI coding assistant usage across VS Code, Xcode, Claude, Codex, and OpenCode. Read-only, zero telemetry.", + "description": "Analyze your AI coding assistant usage across VS Code, Xcode, Claude, Codex, OpenCode, Pi, and Antigravity. Read-only, zero telemetry.", "version": "0.1.0", "publisher": "ai-engineer-coach", "author": { diff --git a/src/core/parser-antigravity.test.ts b/src/core/parser-antigravity.test.ts new file mode 100644 index 0000000..bd0e84d --- /dev/null +++ b/src/core/parser-antigravity.test.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Tests for the Antigravity parser — builds a synthetic conversation db with + * hand-encoded protobuf step payloads matching the reverse-engineered layout. */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, it, expect } from 'vitest'; +import { parseAntigravitySessions } from './parser-antigravity'; + +type NodeSqliteModule = typeof import('node:sqlite'); + +function loadNodeSqliteForTest(): NodeSqliteModule | null { + interface SqliteCapable { getBuiltinModule?: (id: string) => unknown } + return ((process as SqliteCapable).getBuiltinModule?.('node:sqlite') as NodeSqliteModule | undefined) ?? null; +} + +/* --- minimal protobuf wire-format writer (mirrors the parser's reader) --- */ + +function varint(value: number): Buffer { + const bytes: number[] = []; + let v = value; + do { + let byte = v & 0x7f; + v >>>= 7; + if (v > 0) byte |= 0x80; + bytes.push(byte); + } while (v > 0); + return Buffer.from(bytes); +} + +function intField(fieldNo: number, value: number): Buffer { + return Buffer.concat([varint((fieldNo << 3) | 0), varint(value)]); +} + +function bytesField(fieldNo: number, value: Buffer | string): Buffer { + const payload = typeof value === 'string' ? Buffer.from(value, 'utf8') : value; + return Buffer.concat([varint((fieldNo << 3) | 2), varint(payload.length), payload]); +} + +function timestampMessage(seconds: number): Buffer { + return intField(1, seconds); +} + +/** field 5 metadata: {1: timestamp, 4?: tool call, 9?: usage} */ +function metaMessage(seconds: number, extra: Buffer[] = []): Buffer { + return Buffer.concat([bytesField(1, timestampMessage(seconds)), ...extra]); +} + +function userStepPayload(seconds: number, text: string): Buffer { + return Buffer.concat([ + intField(1, 14), + bytesField(5, metaMessage(seconds)), + bytesField(19, bytesField(2, text)), + ]); +} + +function generationStepPayload(seconds: number, text: string, inputTokens: number, outputTokens: number, tool?: { name: string; argsJson: string }): Buffer { + const usage = Buffer.concat([intField(2, inputTokens), intField(3, outputTokens)]); + const generationParts = [bytesField(1, text)]; + if (tool) { + generationParts.push(bytesField(7, Buffer.concat([bytesField(2, tool.name), bytesField(3, tool.argsJson)]))); + } + return Buffer.concat([ + intField(1, 15), + bytesField(5, metaMessage(seconds, [bytesField(9, usage)])), + bytesField(20, Buffer.concat(generationParts)), + ]); +} + +function withConversation(run: (conversationsDir: string) => void): void { + const sqlite = loadNodeSqliteForTest(); + if (!sqlite) return; // node:sqlite unavailable on this runtime — nothing to test + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-parser-test-')); + const conversationsDir = path.join(root, 'conversations'); + const cacheDir = path.join(root, 'cache'); + fs.mkdirSync(conversationsDir, { recursive: true }); + fs.mkdirSync(cacheDir, { recursive: true }); + + fs.writeFileSync( + path.join(cacheDir, 'last_conversations.json'), + JSON.stringify({ '/home/me/proj': 'conv-1' }), + 'utf-8', + ); + + const db = new sqlite.DatabaseSync(path.join(conversationsDir, 'conv-1.db')); + db.exec('CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER, step_payload BLOB)'); + const insert = db.prepare('INSERT INTO steps VALUES (?,?,?)'); + insert.run(0, 14, userStepPayload(1_700_000_000, 'refactor this')); + insert.run(1, 15, generationStepPayload(1_700_000_005, 'On it.', 1000, 50, { + name: 'view_file', + argsJson: JSON.stringify({ AbsolutePath: '/home/me/proj/a.ts' }), + })); + insert.run(2, 15, generationStepPayload(1_700_000_010, 'Done.', 1200, 80)); + db.close(); + + try { + run(conversationsDir); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +describe('parseAntigravitySessions', () => { + it('parses conversations from protobuf step payloads', () => { + withConversation(conversationsDir => { + const sessions = parseAntigravitySessions(conversationsDir); + expect(sessions).toHaveLength(1); + expect(sessions[0].harness).toBe('Antigravity'); + expect(sessions[0].workspaceName).toBe('proj'); + expect(sessions[0].workspaceRootPath).toBe('/home/me/proj'); + + const requests = sessions[0].requests; + expect(requests).toHaveLength(1); + expect(requests[0].messageText).toBe('refactor this'); + expect(requests[0].responseText).toContain('On it.'); + expect(requests[0].responseText).toContain('Done.'); + expect(requests[0].toolsUsed).toEqual(['view_file']); + expect(requests[0].referencedFiles).toEqual(['/home/me/proj/a.ts']); + // Summed across the two generation steps + expect(requests[0].promptTokens).toBe(2200); + expect(requests[0].completionTokens).toBe(130); + expect(requests[0].timestamp).toBe(1_700_000_000 * 1000); + // 10s between the user step and the last generation step + expect(requests[0].totalElapsed).toBe(10_000); + }); + }); + + it('skips databases without user messages', () => { + const sqlite = loadNodeSqliteForTest(); + if (!sqlite) return; + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-empty-test-')); + const conversationsDir = path.join(root, 'conversations'); + fs.mkdirSync(conversationsDir, { recursive: true }); + + const db = new sqlite.DatabaseSync(path.join(conversationsDir, 'conv-2.db')); + db.exec('CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER, step_payload BLOB)'); + db.prepare('INSERT INTO steps VALUES (?,?,?)').run(0, 15, generationStepPayload(1_700_000_000, 'hello', 10, 5)); + db.close(); + + try { + expect(parseAntigravitySessions(conversationsDir)).toHaveLength(0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/core/parser-antigravity.ts b/src/core/parser-antigravity.ts new file mode 100644 index 0000000..a805162 --- /dev/null +++ b/src/core/parser-antigravity.ts @@ -0,0 +1,360 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Antigravity (Google) agent conversation parser + * + * Data layout: + * ~/.gemini/antigravity-cli/conversations/.db -- one SQLite db per conversation + * ~/.gemini/antigravity-cli/cache/last_conversations.json -- {cwd: conversation-uuid} map + * + * Each conversation db has a `steps` table (idx, step_type, step_payload) + * where step_payload is an undocumented protobuf blob. The field numbers used + * below were reverse-engineered from real conversations and are read with a + * minimal protobuf wire-format decoder; every access is best-effort, so a + * schema drift degrades to missing data instead of a crash: + * + * field 5 step metadata + * 5.1 google.protobuf.Timestamp {1: seconds, 2: nanos} + * 5.4 tool call {1: id, 2: tool name, 3: arguments JSON} + * 5.9 generation usage {2: input tokens, 3: output tokens} + * field 19 user message {2: prompt text} (step_type 14) + * field 20 model generation {1: response text, 3: thinking, + * 7: tool call {2: name, 3: arguments JSON}} (step_type 15) + * + * Requires the node:sqlite builtin (Node >= 22.5); the source is skipped + * gracefully when it is unavailable. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { Session, SessionRequest } from './types'; +import { assertTrustedPath, createRequest, createSession, detectDevcontainerFromRequests } from './parser-shared'; + +const STEP_TYPE_USER_MESSAGE = 14; + +const AG_WRITE_TOOLS = new Set(['write_to_file', 'replace_file_content', 'edit_file', 'multi_edit', 'write_file']); +const AG_READ_TOOLS = new Set(['view_file', 'view_code_item', 'find_by_name', 'grep_search', 'list_dir', 'read_file']); + +type NodeSqlite = typeof import('node:sqlite'); + +function loadNodeSqlite(): NodeSqlite | null { + try { + const getBuiltinModule = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule; + return (getBuiltinModule?.call(process, 'node:sqlite') as NodeSqlite | undefined) ?? null; + } catch { + return null; + } +} + +export function findAntigravityDirs(): string[] { + const home = process.env.HOME || process.env.USERPROFILE || ''; + const conversationsDir = path.join(home, '.gemini', 'antigravity-cli', 'conversations'); + return fs.existsSync(conversationsDir) ? [conversationsDir] : []; +} + +/* ------------------------------- protobuf ------------------------------- */ + +type ProtoValue = bigint | Buffer; +type ProtoFields = Map; + +function readVarint(buf: Buffer, pos: number): [bigint, number] { + let result = 0n; + let shift = 0n; + let p = pos; + + while (p < buf.length) { + const byte = buf[p++]; + result |= BigInt(byte & 0x7f) << shift; + if ((byte & 0x80) === 0) return [result, p]; + shift += 7n; + } + + return [result, p]; +} + +/** Decodes one message level of protobuf wire format; returns null on malformed input. */ +function decodeProto(buf: Buffer): ProtoFields | null { + const fields: ProtoFields = new Map(); + let pos = 0; + + while (pos < buf.length) { + let tag: bigint; + [tag, pos] = readVarint(buf, pos); + + const fieldNo = Number(tag >> 3n); + const wireType = Number(tag & 7n); + if (fieldNo === 0) return null; + + let value: ProtoValue; + + if (wireType === 0) { + [value, pos] = readVarint(buf, pos); + } else if (wireType === 1) { + if (pos + 8 > buf.length) return null; + value = buf.subarray(pos, pos + 8); + pos += 8; + } else if (wireType === 5) { + if (pos + 4 > buf.length) return null; + value = buf.subarray(pos, pos + 4); + pos += 4; + } else if (wireType === 2) { + let length: bigint; + [length, pos] = readVarint(buf, pos); + const end = pos + Number(length); + if (end > buf.length) return null; + value = buf.subarray(pos, end); + pos = end; + } else { + return null; + } + + const existing = fields.get(fieldNo); + if (existing) existing.push(value); + else fields.set(fieldNo, [value]); + } + + return fields; +} + +function subMessage(fields: ProtoFields | null, fieldNo: number): ProtoFields | null { + const value = fields?.get(fieldNo)?.[0]; + return Buffer.isBuffer(value) ? decodeProto(value) : null; +} + +function intField(fields: ProtoFields | null, fieldNo: number): number | null { + const value = fields?.get(fieldNo)?.[0]; + return typeof value === 'bigint' ? Number(value) : null; +} + +function stringField(fields: ProtoFields | null, fieldNo: number): string | null { + const value = fields?.get(fieldNo)?.[0]; + if (!Buffer.isBuffer(value)) return null; + + const text = value.toString('utf8'); + // Reject buffers that are clearly nested messages rather than text. + // eslint-disable-next-line no-control-regex + return /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(text) ? null : text; +} + +/* ------------------------------ step reading ----------------------------- */ + +interface AgStep { + stepType: number; + timestampMs: number | null; + userText: string | null; + responseText: string | null; + toolName: string | null; + toolArgsJson: string | null; + inputTokens: number | null; + outputTokens: number | null; +} + +function timestampMsOf(meta: ProtoFields | null): number | null { + const ts = subMessage(meta, 1); + const seconds = intField(ts, 1); + return seconds ? seconds * 1000 : null; +} + +function readStep(stepType: number, payload: Buffer): AgStep | null { + const root = decodeProto(payload); + if (!root) return null; + + const meta = subMessage(root, 5); + const metaTool = subMessage(meta, 4); + const usage = subMessage(meta, 9); + const userMessage = subMessage(root, 19); + const generation = subMessage(root, 20); + const generationTool = subMessage(generation, 7); + + return { + stepType, + timestampMs: timestampMsOf(meta), + userText: stringField(userMessage, 2), + responseText: stringField(generation, 1), + toolName: stringField(metaTool, 2) ?? stringField(generationTool, 2), + toolArgsJson: stringField(metaTool, 3) ?? stringField(generationTool, 3), + inputTokens: intField(usage, 2), + outputTokens: intField(usage, 3), + }; +} + +/* ---------------------------- session assembly --------------------------- */ + +interface AgTurnData { + textParts: string[]; + toolsUsed: string[]; + editedFiles: string[]; + referencedFiles: string[]; + inputTokens: number; + outputTokens: number; + sawUsage: boolean; + lastTs: number | null; +} + +function filePathFromArgs(argsJson: string | null): string | null { + if (!argsJson) return null; + try { + const args = JSON.parse(argsJson) as Record; + const candidate = args['AbsolutePath'] ?? args['TargetFile'] ?? args['SearchDirectory'] ?? args['path']; + return typeof candidate === 'string' ? candidate : null; + } catch { + return null; + } +} + +function applyAgStep(step: AgStep, turn: AgTurnData): void { + if (step.timestampMs && (!turn.lastTs || step.timestampMs > turn.lastTs)) turn.lastTs = step.timestampMs; + if (step.responseText) turn.textParts.push(step.responseText); + + if (step.inputTokens !== null || step.outputTokens !== null) { + turn.sawUsage = true; + turn.inputTokens += step.inputTokens ?? 0; + turn.outputTokens += step.outputTokens ?? 0; + } + + if (!step.toolName) return; + turn.toolsUsed.push(step.toolName); + + const filePath = filePathFromArgs(step.toolArgsJson); + if (!filePath) return; + + const tool = step.toolName.toLowerCase(); + if (AG_WRITE_TOOLS.has(tool)) turn.editedFiles.push(filePath); + else if (AG_READ_TOOLS.has(tool)) turn.referencedFiles.push(filePath); +} + +function buildAgRequest(userStep: AgStep, turn: AgTurnData): SessionRequest { + const userTs = userStep.timestampMs; + + return createRequest({ + timestamp: userTs, + messageText: userStep.userText ?? '', + responseText: turn.textParts.join('\n'), + agentName: 'Antigravity', + toolsUsed: turn.toolsUsed, + editedFiles: [...new Set(turn.editedFiles)], + referencedFiles: [...new Set(turn.referencedFiles)], + totalElapsed: userTs && turn.lastTs && turn.lastTs > userTs ? turn.lastTs - userTs : null, + promptTokens: turn.sawUsage ? turn.inputTokens : null, + completionTokens: turn.sawUsage ? turn.outputTokens : null, + }); +} + +function buildAgRequests(steps: AgStep[]): SessionRequest[] { + const requests: SessionRequest[] = []; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + if (step.stepType !== STEP_TYPE_USER_MESSAGE || !step.userText) continue; + + const turn: AgTurnData = { + textParts: [], + toolsUsed: [], + editedFiles: [], + referencedFiles: [], + inputTokens: 0, + outputTokens: 0, + sawUsage: false, + lastTs: step.timestampMs, + }; + + let j = i + 1; + for (; j < steps.length; j++) { + if (steps[j].stepType === STEP_TYPE_USER_MESSAGE && steps[j].userText) break; + applyAgStep(steps[j], turn); + } + + requests.push(buildAgRequest(step, turn)); + i = j - 1; + } + + return requests; +} + +/** Reads {cwd: conversation-id} from cache/last_conversations.json and inverts it. */ +function readConversationCwdMap(conversationsDir: string): Map { + const result = new Map(); + const cachePath = path.join(path.dirname(conversationsDir), 'cache', 'last_conversations.json'); + + try { + assertTrustedPath(cachePath); + const raw = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as Record; + for (const [cwd, conversationId] of Object.entries(raw)) { + if (typeof conversationId === 'string') result.set(conversationId, cwd); + } + } catch { + /* no cwd mapping available */ + } + + return result; +} + +function parseAgConversation(sqlite: NodeSqlite, dbPath: string, conversationId: string, cwd: string | undefined): Session | null { + let db: InstanceType | null = null; + const steps: AgStep[] = []; + + try { + assertTrustedPath(dbPath); + db = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + const rows = db.prepare( + 'SELECT step_type, step_payload FROM steps WHERE step_payload IS NOT NULL ORDER BY idx', + ).all() as unknown as { step_type: number; step_payload: Uint8Array }[]; + + for (const row of rows) { + const step = readStep(row.step_type, Buffer.from(row.step_payload)); + if (step) steps.push(step); + } + } catch { + return null; + } finally { + db?.close(); + } + + const requests = buildAgRequests(steps); + if (requests.length === 0) return null; + + const workspaceName = cwd + ? (cwd.replaceAll('\\', '/').replace(/\/+$/, '').split('/').pop() || 'unknown') + : 'Antigravity'; + + return createSession({ + sessionId: `antigravity-${conversationId}`, + workspaceId: `antigravity-${cwd ?? 'unknown'}`, + workspaceName, + location: 'terminal', + harness: 'Antigravity', + requests, + hasDevcontainer: detectDevcontainerFromRequests(requests, cwd), + workspaceRootPath: cwd, + }); +} + +export function parseAntigravitySessions(conversationsDir: string): Session[] { + const sqlite = loadNodeSqlite(); + if (!sqlite) return []; + + const sessions: Session[] = []; + const cwdByConversation = readConversationCwdMap(conversationsDir); + + let files: string[]; + try { + files = fs.readdirSync(conversationsDir).filter(name => name.endsWith('.db')); + } catch { + return sessions; + } + + for (const file of files) { + const conversationId = file.slice(0, -3); + const session = parseAgConversation( + sqlite, + path.join(conversationsDir, file), + conversationId, + cwdByConversation.get(conversationId), + ); + if (session) sessions.push(session); + } + + return sessions; +} diff --git a/src/core/parser-harnesses.ts b/src/core/parser-harnesses.ts index 76b33e6..09d3534 100644 --- a/src/core/parser-harnesses.ts +++ b/src/core/parser-harnesses.ts @@ -10,6 +10,8 @@ import { Workspace, Session } from './types'; import { findClaudeDirs, parseClaudeSessions, parseClaudeSessionsAsync } from './parser-claude'; import { findCodexDirs, parseCodexSessions } from './parser-codex'; import { findOpenCodeDirs, parseOpenCodeSessions } from './parser-opencode'; +import { findPiDirs, parsePiSessions } from './parser-pi'; +import { findAntigravityDirs, parseAntigravitySessions } from './parser-antigravity'; type WorkspaceMap = Map; @@ -69,6 +71,22 @@ const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [ } }, }, + { + name: 'Pi', + collectSync(ctx) { + for (const piDir of findPiDirs()) { + for (const session of parsePiSessions(piDir)) addSession(ctx.workspaces, ctx.sessions, session, piDir); + } + }, + }, + { + name: 'Antigravity', + collectSync(ctx) { + for (const agDir of findAntigravityDirs()) { + for (const session of parseAntigravitySessions(agDir)) addSession(ctx.workspaces, ctx.sessions, session, agDir); + } + }, + }, ]; export interface ExternalHarnessProgressHandlers { @@ -88,7 +106,13 @@ export function hasExternalHarnessSources(): boolean { // string and probe relative paths (e.g. `.claude/projects`) under the current // working directory, which could report false positives. Bail out instead. if (!process.env.HOME && !process.env.USERPROFILE) return false; - return findClaudeDirs().length > 0 || findCodexDirs().length > 0 || findOpenCodeDirs().length > 0; + return ( + findClaudeDirs().length > 0 || + findCodexDirs().length > 0 || + findOpenCodeDirs().length > 0 || + findPiDirs().length > 0 || + findAntigravityDirs().length > 0 + ); } export function collectExternalHarnessesSync(workspaces: WorkspaceMap, sessions: Session[]): void { @@ -106,6 +130,8 @@ export const EXTERNAL_HARNESS_SET = new Set([ 'Claude', 'Codex', 'OpenCode', + 'Pi', + 'Antigravity', ]); export async function collectExternalHarnessesAsync( diff --git a/src/core/parser-pi.test.ts b/src/core/parser-pi.test.ts new file mode 100644 index 0000000..54118f2 --- /dev/null +++ b/src/core/parser-pi.test.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Tests for the Pi parser — verifies JSONL sessions parse into requests with + * summed token usage across the assistant steps of a turn. */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, it, expect } from 'vitest'; +import { parsePiSessions } from './parser-pi'; + +function withPiSession(entries: object[], run: (sessionsDir: string) => void): void { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-parser-test-')); + const projectDir = path.join(root, '--home-me-proj--'); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync( + path.join(projectDir, '2026-01-01T00-00-00-000Z_abc.jsonl'), + entries.map(e => JSON.stringify(e)).join('\n'), + 'utf-8', + ); + try { + run(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +describe('parsePiSessions', () => { + it('parses a session and sums usage across the assistant steps of a turn', () => { + withPiSession( + [ + { type: 'session', id: 'sess-1', timestamp: '2026-01-01T00:00:00.000Z', cwd: '/home/me/proj' }, + { type: 'message', message: { role: 'user', content: [{ type: 'text', text: 'do it' }], timestamp: 1767225600000 } }, + { + type: 'message', + message: { + role: 'assistant', + model: 'claude-sonnet-4', + usage: { input: 100, output: 10, cacheRead: 50, cacheWrite: 5 }, + content: [ + { type: 'text', text: 'working' }, + { type: 'toolCall', name: 'edit', arguments: { path: '/home/me/proj/a.ts' } }, + ], + timestamp: 1767225601000, + }, + }, + { type: 'message', message: { role: 'toolResult', content: [{ type: 'text', text: 'ok' }], timestamp: 1767225602000 } }, + { + type: 'message', + message: { + role: 'assistant', + model: 'claude-sonnet-4', + usage: { input: 200, output: 20 }, + content: [{ type: 'text', text: 'done' }, { type: 'toolCall', name: 'read', arguments: { path: '/home/me/proj/b.ts' } }], + timestamp: 1767225603000, + }, + }, + ], + sessionsDir => { + const sessions = parsePiSessions(sessionsDir); + expect(sessions).toHaveLength(1); + expect(sessions[0].harness).toBe('Pi'); + expect(sessions[0].workspaceName).toBe('proj'); + expect(sessions[0].workspaceRootPath).toBe('/home/me/proj'); + + const requests = sessions[0].requests; + expect(requests).toHaveLength(1); + expect(requests[0].messageText).toBe('do it'); + expect(requests[0].responseText).toContain('working'); + expect(requests[0].responseText).toContain('done'); + expect(requests[0].modelId).toBe('claude-sonnet-4'); + // 100 + 50 + 5 (turn 1, incl. cache) + 200 (turn 2) + expect(requests[0].promptTokens).toBe(355); + expect(requests[0].completionTokens).toBe(30); + expect(requests[0].cacheReadTokens).toBe(50); + expect(requests[0].cacheWriteTokens).toBe(5); + expect(requests[0].toolsUsed).toEqual(['edit', 'read']); + expect(requests[0].editedFiles).toEqual(['/home/me/proj/a.ts']); + expect(requests[0].referencedFiles).toEqual(['/home/me/proj/b.ts']); + }, + ); + }); + + it('skips sessions without any user request', () => { + withPiSession( + [ + { type: 'session', id: 'sess-2', timestamp: '2026-01-01T00:00:00.000Z', cwd: '/home/me/proj' }, + { type: 'model_change', provider: 'anthropic', modelId: 'claude-sonnet-4' }, + ], + sessionsDir => { + expect(parsePiSessions(sessionsDir)).toHaveLength(0); + }, + ); + }); + + it('keeps requests without usage data as null tokens', () => { + withPiSession( + [ + { type: 'session', id: 'sess-3', timestamp: '2026-01-01T00:00:00.000Z', cwd: '/home/me/proj' }, + { type: 'message', message: { role: 'user', content: [{ type: 'text', text: 'hi' }], timestamp: 1767225600000 } }, + ], + sessionsDir => { + const sessions = parsePiSessions(sessionsDir); + expect(sessions).toHaveLength(1); + expect(sessions[0].requests[0].promptTokens).toBeNull(); + expect(sessions[0].requests[0].completionTokens).toBeNull(); + }, + ); + }); +}); diff --git a/src/core/parser-pi.ts b/src/core/parser-pi.ts new file mode 100644 index 0000000..be25629 --- /dev/null +++ b/src/core/parser-pi.ts @@ -0,0 +1,268 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Pi (badlogic/pi-mono) session parser + * + * Data layout: + * ~/.pi/agent/sessions//_.jsonl + * + * Each JSONL file is one session. Entry types (one JSON object per line): + * {type:"session", id, timestamp, cwd} -- header (first line) + * {type:"model_change", provider, modelId} -- model switches + * {type:"message", message:{role:"user"|"assistant"|"toolResult", ...}} + * + * Assistant messages carry `model`, `provider`, `usage {input, output, + * cacheRead, cacheWrite}`, and a content array of {type:"text"|"thinking"| + * "toolCall"} items; toolCall items have `name` and `arguments` (with `path` + * for file tools). A single user turn is usually followed by several + * assistant/toolResult steps, so token counts are summed per turn (same + * convention as the Claude Code parser). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { Session, SessionRequest } from './types'; +import { assertTrustedPath, createRequest, createSession, detectDevcontainerFromRequests } from './parser-shared'; +import { extractReasoningEffortFromModelId } from './helpers'; + +interface PiContentItem { + type: string; + text?: string; + thinking?: string; + name?: string; + arguments?: Record; +} + +interface PiMessage { + role: string; + content?: PiContentItem[] | string; + model?: string; + usage?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number }; + timestamp?: number; +} + +interface PiEntry { + type: string; + id?: string; + timestamp?: string; + cwd?: string; + message?: PiMessage; +} + +const PI_WRITE_TOOLS = new Set(['write', 'edit', 'multi_edit', 'multiedit']); +const PI_READ_TOOLS = new Set(['read', 'glob', 'grep', 'ls', 'find', 'view_file']); + +export function findPiDirs(): string[] { + const home = process.env.HOME || process.env.USERPROFILE || ''; + const sessionsDir = path.join(home, '.pi', 'agent', 'sessions'); + return fs.existsSync(sessionsDir) ? [sessionsDir] : []; +} + +function readPiEntries(filePath: string): PiEntry[] { + const entries: PiEntry[] = []; + try { + assertTrustedPath(filePath); + for (const line of fs.readFileSync(filePath, 'utf-8').split('\n')) { + if (!line.trim()) continue; + try { + entries.push(JSON.parse(line) as PiEntry); + } catch { + /* skip malformed lines */ + } + } + } catch { + /* skip unreadable files */ + } + return entries; +} + +function contentItems(message: PiMessage): PiContentItem[] { + return Array.isArray(message.content) ? message.content : []; +} + +function messageTextOf(message: PiMessage): string { + if (typeof message.content === 'string') return message.content; + return contentItems(message) + .filter(item => item.type === 'text' && item.text) + .map(item => item.text!) + .join('\n'); +} + +interface PiTurnData { + responseText: string; + toolsUsed: string[]; + editedFiles: string[]; + referencedFiles: string[]; + modelId: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + sawUsage: boolean; + lastTs: number | null; +} + +function filePathFromToolArgs(args: Record): string | null { + const candidate = args['path'] ?? args['filePath'] ?? args['file_path']; + return typeof candidate === 'string' ? candidate : null; +} + +function applyPiToolCall(item: PiContentItem, turn: PiTurnData): void { + if (!item.name) return; + turn.toolsUsed.push(item.name); + + const filePath = filePathFromToolArgs(item.arguments ?? {}); + if (!filePath) return; + + const tool = item.name.toLowerCase(); + if (PI_WRITE_TOOLS.has(tool)) turn.editedFiles.push(filePath); + else if (PI_READ_TOOLS.has(tool)) turn.referencedFiles.push(filePath); +} + +function applyAssistantMessage(message: PiMessage, entryTs: number | null, turn: PiTurnData, textParts: string[]): void { + if (message.model) turn.modelId = message.model; + if (entryTs && (!turn.lastTs || entryTs > turn.lastTs)) turn.lastTs = entryTs; + + const usage = message.usage; + if (usage) { + turn.sawUsage = true; + const cacheRead = usage.cacheRead ?? 0; + const cacheWrite = usage.cacheWrite ?? 0; + // Prompt tokens include cached context so context-window analysis sees the + // full window; cached portions are also tracked separately. + turn.inputTokens += (usage.input ?? 0) + cacheRead + cacheWrite; + turn.outputTokens += usage.output ?? 0; + turn.cacheReadTokens += cacheRead; + turn.cacheWriteTokens += cacheWrite; + } + + for (const item of contentItems(message)) { + if (item.type === 'text' && item.text) textParts.push(item.text); + else if (item.type === 'toolCall') applyPiToolCall(item, turn); + } +} + +function entryTimestamp(entry: PiEntry): number | null { + if (entry.message?.timestamp) return entry.message.timestamp; + if (entry.timestamp) { + const parsed = Date.parse(entry.timestamp); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +/** Collects every assistant step following `startIndex` until the next user message. */ +function collectPiTurn(entries: PiEntry[], startIndex: number, userTs: number | null): { turn: PiTurnData; nextIndex: number } { + const turn: PiTurnData = { + responseText: '', + toolsUsed: [], + editedFiles: [], + referencedFiles: [], + modelId: '', + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + sawUsage: false, + lastTs: userTs, + }; + + const textParts: string[] = []; + let i = startIndex; + + for (; i < entries.length; i++) { + const entry = entries[i]; + if (entry.type !== 'message' || !entry.message) continue; + if (entry.message.role === 'user') break; + if (entry.message.role === 'assistant') { + applyAssistantMessage(entry.message, entryTimestamp(entry), turn, textParts); + } + } + + turn.responseText = textParts.join('\n'); + return { turn, nextIndex: i }; +} + +function buildPiRequest(userEntry: PiEntry, turn: PiTurnData, userTs: number | null): SessionRequest { + return createRequest({ + requestId: userEntry.id ?? '', + timestamp: userTs, + messageText: userEntry.message ? messageTextOf(userEntry.message) : '', + responseText: turn.responseText, + agentName: 'Pi', + modelId: turn.modelId, + toolsUsed: turn.toolsUsed, + editedFiles: [...new Set(turn.editedFiles)], + referencedFiles: [...new Set(turn.referencedFiles)], + totalElapsed: userTs && turn.lastTs && turn.lastTs > userTs ? turn.lastTs - userTs : null, + promptTokens: turn.sawUsage ? turn.inputTokens : null, + completionTokens: turn.sawUsage ? turn.outputTokens : null, + cacheReadTokens: turn.cacheReadTokens > 0 ? turn.cacheReadTokens : null, + cacheWriteTokens: turn.cacheWriteTokens > 0 ? turn.cacheWriteTokens : null, + reasoningEffort: extractReasoningEffortFromModelId(turn.modelId), + }); +} + +function parsePiSessionFile(filePath: string): Session | null { + const entries = readPiEntries(filePath); + const header = entries.find(entry => entry.type === 'session'); + if (!header?.id) return null; + + const requests: SessionRequest[] = []; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.type !== 'message' || entry.message?.role !== 'user') continue; + + const userTs = entryTimestamp(entry); + const { turn, nextIndex } = collectPiTurn(entries, i + 1, userTs); + requests.push(buildPiRequest(entry, turn, userTs)); + i = nextIndex - 1; + } + + if (requests.length === 0) return null; + + const cwd = header.cwd || ''; + const workspaceName = cwd ? (cwd.replaceAll('\\', '/').replace(/\/+$/, '').split('/').pop() || 'unknown') : 'unknown'; + + return createSession({ + sessionId: header.id, + workspaceId: `pi-${cwd || header.id}`, + workspaceName, + location: 'terminal', + harness: 'Pi', + requests, + hasDevcontainer: detectDevcontainerFromRequests(requests, cwd || undefined), + workspaceRootPath: cwd || undefined, + }); +} + +export function parsePiSessions(sessionsDir: string): Session[] { + const sessions: Session[] = []; + + let projectDirs: fs.Dirent[]; + try { + projectDirs = fs.readdirSync(sessionsDir, { withFileTypes: true }).filter(e => e.isDirectory()); + } catch { + return sessions; + } + + for (const projectDir of projectDirs) { + const dirPath = path.join(sessionsDir, projectDir.name); + let files: string[]; + try { + files = fs.readdirSync(dirPath).filter(name => name.endsWith('.jsonl')); + } catch { + continue; + } + + for (const file of files) { + const session = parsePiSessionFile(path.join(dirPath, file)); + if (session) sessions.push(session); + } + } + + return sessions; +}