diff --git a/cspell.json b/cspell.json index accddd6d..4df53044 100644 --- a/cspell.json +++ b/cspell.json @@ -4,6 +4,8 @@ "minWordLength": 5, "words": [ "aicoach", + "varint", + "swazz", "acked", "affordances", "akiaiosfodnn", diff --git a/src/core/analyzer-consumption.ts b/src/core/analyzer-consumption.ts index 171d2633..1793574b 100644 --- a/src/core/analyzer-consumption.ts +++ b/src/core/analyzer-consumption.ts @@ -114,7 +114,7 @@ function attributeSessionLevel( const byModel = new Map(); for (const r of session.requests) { if (delegatedRequests?.has(r)) continue; - const m = normalizeModel(r.modelId || 'untracked'); + const m = normalizeModel(r.modelId || 'untracked', true); if (!byModel.has(m)) byModel.set(m, []); byModel.get(m)!.push(r); } @@ -219,7 +219,7 @@ export class ConsumptionAnalyzer extends AnalyzerBase { const defaultMultipliers: Record = {}; for (const r of reqs) { - const model = normalizeModel(r.modelId || 'untracked'); + const model = normalizeModel(r.modelId || 'untracked', true); const mult = modelMultiplier(model); defaultMultipliers[model] = mult; modelTotals.set(model, (modelTotals.get(model) || 0) + 1); @@ -289,7 +289,7 @@ export class ConsumptionAnalyzer extends AnalyzerBase { const dailyReqs = new Map(); for (const r of reqs) { const d = new Date(r.timestamp!).getDate(); - const mult = modelMultiplier(normalizeModel(r.modelId || 'untracked')); + const mult = modelMultiplier(normalizeModel(r.modelId || 'untracked', true)); dailyReqs.set(d, (dailyReqs.get(d) || 0) + mult); } @@ -426,7 +426,7 @@ export class ConsumptionAnalyzer extends AnalyzerBase { request: SessionRequest, billing: RequestBilling | undefined, ): void { - const model = normalizeModel(request.modelId || 'untracked'); + const model = normalizeModel(request.modelId || 'untracked', true); const resolvedBilling: RequestBilling = billing ?? { uncachedInput: 0, totalInput: 0, @@ -754,7 +754,7 @@ export class ConsumptionAnalyzer extends AnalyzerBase { countedCount++; const tokens = (requestBilling?.totalInput ?? 0) + (requestBilling?.output ?? 0); dailyTokens.set(dayIdx + 1, (dailyTokens.get(dayIdx + 1) || 0) + tokens); - const model = normalizeModel(request.modelId ?? 'unknown'); + const model = normalizeModel(request.modelId ?? 'unknown', true); if (!dailyTokensByModel.has(model)) dailyTokensByModel.set(model, new Map()); const modelDay = dailyTokensByModel.get(model)!; modelDay.set(dayIdx + 1, (modelDay.get(dayIdx + 1) || 0) + tokens); diff --git a/src/core/analyzer-production.ts b/src/core/analyzer-production.ts index 5357ed75..2028f1d2 100644 --- a/src/core/analyzer-production.ts +++ b/src/core/analyzer-production.ts @@ -27,7 +27,7 @@ export class ProductionAnalyzer extends AnalyzerBase { const day = toDateStr(request.timestamp!); const session = this.requestSessionMap.get(request); const workspaceName = session?.workspaceName || ''; - const model = normalizeModel(request.modelId || 'unknown'); + const model = normalizeModel(request.modelId || 'unknown', true); const harness = session?.harness || 'unknown'; for (const block of request.aiCode) { totalAiLoc += block.loc; @@ -46,7 +46,7 @@ export class ProductionAnalyzer extends AnalyzerBase { const day = request.timestamp ? toDateStr(request.timestamp) : null; const session = this.requestSessionMap.get(request); const workspaceName = session?.workspaceName || ''; - const model = normalizeModel(request.modelId || 'unknown'); + const model = normalizeModel(request.modelId || 'unknown', true); const harness = session?.harness || 'unknown'; for (const [file, loc] of editLocs) { totalAiLoc += loc; diff --git a/src/core/constants.ts b/src/core/constants.ts index c5dd8935..3f16552d 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -19,7 +19,8 @@ export const MODEL_MULTIPLIERS: Record = { 'claude-opus-4.7': 7.5, 'claude-haiku-4.5': 0.33, 'gemini-2.0-flash': 0.25, 'gemini-2.5-pro': 1, 'gemini-3-flash': 0.33, - 'gemini-3-pro': 1, 'gemini-3.1-pro': 1, + 'gemini-3-pro': 1, 'gemini-3.1-pro': 1, 'gemini-3.5-flash': 0.33, 'gemini-3.1-flash': 0.33, + 'gpt-oss-120b': 0.5, 'grok-code-fast-1': 0.25, 'raptor-mini': 0, 'goldeneye': 1, 'copilot-internal': 0, 'auto': 1, 'custom-model': 1, }; @@ -59,6 +60,9 @@ export const MODEL_TOKEN_RATES: Record = { 'gemini-3-flash': { input: 0.50, cached: 0.05, output: 3.00 }, 'gemini-3-pro': { input: 2.00, cached: 0.20, output: 12.00 }, 'gemini-3.1-pro': { input: 2.00, cached: 0.20, output: 12.00 }, + 'gemini-3.5-flash': { input: 0.50, cached: 0.05, output: 3.00 }, + 'gemini-3.1-flash': { input: 0.50, cached: 0.05, output: 3.00 }, + 'gpt-oss-120b': { input: 1.00, cached: 0.10, output: 4.00 }, 'grok-code-fast-1': { input: 0.20, cached: 0.02, output: 1.50 }, 'raptor-mini': { input: 0.25, cached: 0.025, output: 2.00 }, 'goldeneye': { input: 1.25, cached: 0.125, output: 10.00 }, diff --git a/src/core/dsl/interpreter.ts b/src/core/dsl/interpreter.ts index 2858a6ea..aceb6321 100644 --- a/src/core/dsl/interpreter.ts +++ b/src/core/dsl/interpreter.ts @@ -278,14 +278,21 @@ const MODEL_TIERS: Record = { 'o4-mini': 2, 'o3-mini': 1, 'o3': 3, 'o1-mini': 1, 'o1-preview': 2, 'o1': 2, 'gpt-4.1-nano': 0.2, 'gpt-4.1-mini': 0.5, 'gpt-4.1': 1, 'gpt-4-turbo': 1, 'gpt-4': 1, - 'gemini-3.1-pro': 1, 'gemini-3-pro': 1, 'gemini-3-flash': 0.33, + 'gemini-3.5-flash-low': 0.33, 'gemini-3.5-flash-medium': 0.33, 'gemini-3.5-flash-high': 0.33, + 'gemini-3.5-flash': 0.33, + 'gemini-3.1-flash-image2': 0.33, 'gemini-3.1-flash': 0.33, + 'gemini-3.1-pro-low': 1, 'gemini-3.1-pro-high': 1, 'gemini-3.1-pro': 1, + 'gemini-3-pro': 1, 'gemini-3-flash': 0.33, 'gemini-2.5-pro': 1, 'gemini-2.0-flash': 0.3, + 'claude-sonnet-4.6-thinking': 1, 'claude-opus-4.6-thinking': 3, + 'gpt-oss-120b-medium': 0.5, 'gpt-oss-120b': 0.5, 'grok-code-fast-1': 0.25, }; function modelTierLookup(raw: string): number { const id = raw.replace(/^(openai\/|anthropic\/|google\/)/, '').replace(/-\d{4}-\d{2}-\d{2}$/, '').toLowerCase(); - for (const [k, v] of Object.entries(MODEL_TIERS)) { + const sortedTiers = Object.entries(MODEL_TIERS).sort((a, b) => b[0].length - a[0].length); + for (const [k, v] of sortedTiers) { if (id.includes(k)) return v; } return 0; diff --git a/src/core/helpers.test.ts b/src/core/helpers.test.ts index 2f8bf3e2..36cd869c 100644 --- a/src/core/helpers.test.ts +++ b/src/core/helpers.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { describe, it, expect } from 'vitest'; -import { fileUriToPath, toDateStr, startOfDay, endOfDay, isoWeek, normalizeModel, modelMultiplier, classifyWorkType } from './helpers'; +import { fileUriToPath, toDateStr, startOfDay, endOfDay, isoWeek, normalizeModel, modelMultiplier, classifyWorkType, tokenCostInCredits } from './helpers'; describe('fileUriToPath', () => { it('converts a Windows file URI', () => { @@ -150,6 +150,30 @@ describe('modelMultiplier', () => { it('returns 1 for unknown models', () => { expect(modelMultiplier('totally-unknown-model')).toBe(1); }); + + it('correctly maps Antigravity model variants via prefix matching', () => { + expect(modelMultiplier('gemini-3.5-flash-low')).toBe(0.33); + expect(modelMultiplier('gemini-3.5-flash-medium')).toBe(0.33); + expect(modelMultiplier('gemini-3.1-pro-high')).toBe(1); + expect(modelMultiplier('claude-sonnet-4.6-thinking')).toBe(1); + expect(modelMultiplier('claude-opus-4.6-thinking')).toBe(3); + expect(modelMultiplier('gpt-oss-120b-medium')).toBe(0.5); + }); +}); + +describe('tokenCostInCredits', () => { + it('calculates cost using prefix match rates', () => { + // gemini-3.5-flash-low should use gemini-3.5-flash rates (input: 0.50, output: 3.00) + // 1M input tokens * $0.50 + 1M output tokens * $3.00 = $3.50 * 100 = 350 credits + const cost = tokenCostInCredits('gemini-3.5-flash-low', 1_000_000, 1_000_000); + expect(cost).toBe(350); + }); + + it('falls back to modelMultiplier when no prefix rate matches', () => { + // unknown-model-here should fallback to modelMultiplier (1) = 1 credit + const cost = tokenCostInCredits('unknown-model-here', 1_000, 1_000); + expect(cost).toBe(1); + }); }); describe('classifyWorkType', () => { diff --git a/src/core/helpers.ts b/src/core/helpers.ts index 62918dd6..74e4099a 100644 --- a/src/core/helpers.ts +++ b/src/core/helpers.ts @@ -111,12 +111,12 @@ export function fillMonthRange(months: string[]): string[] { * while the effort value is captured separately via extractReasoningEffortFromModelId. */ const EFFORT_SUFFIX_RE = /-(xhigh|extra-high|max|high|medium|med|low|minimal)$/; -export function normalizeModel(modelId: string): string { +export function normalizeModel(modelId: string, keepSuffix = false): string { let m = modelId.trim(); for (const prefix of ['copilot/', 'github.copilot-chat/', 'github/']) { if (m.startsWith(prefix)) { m = m.slice(prefix.length); break; } } - m = m.replace(/-thought$/, '').replace(/-preview$/, '').replace(/-latest$/, ''); + m = m.replace(/-(thought|thinking)$/, '').replace(/-preview$/, '').replace(/-latest$/, ''); // Claude's API returns hyphenated version numbers (claude-opus-4-6) where // our rate tables use dots (claude-opus-4.6). Also strip -YYYYMMDD date // suffixes that older API responses append (claude-haiku-4-5-20251001). @@ -128,7 +128,7 @@ export function normalizeModel(modelId: string): string { } // Strip effort suffixes ONLY for known reasoning-capable families (avoid // false positives on models like gpt-5.4-mini or gemini-3-flash). - if (EFFORT_BEARING_RE.test(m) && !NON_EFFORT_SUFFIXES_RE.test(m)) { + if (!keepSuffix && EFFORT_BEARING_RE.test(m) && !NON_EFFORT_SUFFIXES_RE.test(m)) { m = m.replace(EFFORT_SUFFIX_RE, ''); } return m.trim(); @@ -137,7 +137,8 @@ export function normalizeModel(modelId: string): string { export function modelMultiplier(modelId: string): number { const key = normalizeModel(modelId); if (MODEL_MULTIPLIERS[key] !== undefined) return MODEL_MULTIPLIERS[key]; - for (const [k, v] of Object.entries(MODEL_MULTIPLIERS)) { + const sortedEntries = Object.entries(MODEL_MULTIPLIERS).sort((a, b) => b[0].length - a[0].length); + for (const [k, v] of sortedEntries) { if (key.startsWith(k)) return v; } return 1; @@ -263,7 +264,17 @@ export function tokenCostInCredits( cacheReadTokens: number = 0, cacheWriteTokens: number = 0, ): number { - const rates = MODEL_TOKEN_RATES[normalizeModel(model)]; + let rates = MODEL_TOKEN_RATES[normalizeModel(model)]; + if (!rates) { + const norm = normalizeModel(model); + const sortedRates = Object.entries(MODEL_TOKEN_RATES).sort((a, b) => b[0].length - a[0].length); + for (const [k, v] of sortedRates) { + if (norm.startsWith(k)) { + rates = v; + break; + } + } + } if (!rates) { // Fallback: use model multiplier as rough proxy (1 PRU ≈ 1 credit) return modelMultiplier(model); diff --git a/src/core/parser-antigravity.test.ts b/src/core/parser-antigravity.test.ts new file mode 100644 index 00000000..6850a204 --- /dev/null +++ b/src/core/parser-antigravity.test.ts @@ -0,0 +1,229 @@ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { findAntigravityDirs, decodeProtobuf, parseAntigravitySessions, parseAntigravitySessionsAsync, extractAntigravityImages } from './parser-antigravity'; + +vi.mock('child_process', async () => { + const original = await vi.importActual('child_process'); + return { + ...original, + execFileSync: vi.fn(), + execFile: vi.fn(), + }; +}); + +vi.mock('fs', async () => { + const original = await vi.importActual('fs'); + return { + ...original, + existsSync: vi.fn(), + readdirSync: vi.fn(), + statSync: vi.fn(), + readFileSync: vi.fn(), + }; +}); + +describe('Antigravity Discovery & Decoder', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should find directories', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + const dirs = findAntigravityDirs(); + expect(dirs.length).toBe(3); + }); + + it('should decode simple protobuf', () => { + // Proto message: field 1 = varint 15, field 2 = string "test" + const buf = Buffer.from([0x08, 0x0f, 0x12, 0x04, 0x74, 0x65, 0x73, 0x74]); + const res = decodeProtobuf(buf); + expect(res[1]).toBe(15); + expect(res[2]).toBe('test'); + }); + + it('should parse database sessions successfully', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync as unknown as () => string[]).mockReturnValue(['session-1.db']); + vi.mocked(fs.statSync as unknown as () => Partial).mockReturnValue({ birthtimeMs: 10000, mtimeMs: 10000 }); + + // Mock sqlite3 responses + vi.mocked(execFileSync).mockImplementation((cmd, args) => { + if (cmd === 'sqlite3') { + const arr = args as string[]; + const sql = arr?.[arr.length - 1] || ''; + if (sql.includes('trajectory_metadata_blob') && sql.includes('steps')) { + const metaHex = '3a1c66696c653a2f2f2f55736572732f616c65782f7372632f7377617a7a1206088ef4d7d006'; + const step0Hex = '9a010e120c68656c6c6f2070726f6d7074'; + const step1Hex = 'a201100a0e68656c6c6f20726573706f6e7365'; + const step2Hex = '2a2e222c120a77726974655f66696c651a1e7b2254617267657446696c65223a222f706174682f746f2f66696c65227d'; + return JSON.stringify([{ hex_data: metaHex }]) + '\n' + JSON.stringify([ + { idx: 0, step_type: 14, payload_hex: step0Hex }, + { idx: 1, step_type: 15, payload_hex: step1Hex }, + { idx: 2, step_type: 21, payload_hex: step2Hex }, + ]); + } + } + return '3.41.0'; + }); + + const fakeHome = os.homedir(); + const sessions = parseAntigravitySessions(path.join(fakeHome, 'fake-dir')); + expect(sessions.length).toBe(1); + const s = sessions[0]; + expect(s.sessionId).toBe('session-1'); + expect(s.workspaceName).toBe('swazz'); + expect(s.requests.length).toBe(1); + expect(s.requests[0].messageText).toBe('hello prompt'); + expect(s.requests[0].responseText).toBe('hello response'); + expect(s.requests[0].toolsUsed).toContain('write_file'); + expect(s.requests[0].editedFiles).toContain('/path/to/file'); + }); + + it('should parse database sessions asynchronously successfully', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync as unknown as () => string[]).mockReturnValue(['session-1.db']); + vi.mocked(fs.statSync as unknown as () => Partial).mockReturnValue({ birthtimeMs: 10000, mtimeMs: 10000 }); + + // Mock sqlite3 responses + vi.mocked(execFileSync).mockReturnValue('3.41.0'); + // Using vi.mocked(execFile) is not needed because execFile is mock-injected inside vi.mock('child_process') + const { execFile: mockedExecFile } = await import('child_process'); + vi.mocked(mockedExecFile).mockImplementation((_cmd, args, _opts, callback) => { + const cb = callback as (err: Error | null, stdout: string, stderr: string) => void; + const arr = args as string[]; + const sql = arr?.[arr.length - 1] || ''; + if (sql.includes('trajectory_metadata_blob') && sql.includes('steps')) { + const metaHex = '3a1c66696c653a2f2f2f55736572732f616c65782f7372632f7377617a7a1206088ef4d7d006'; + const step0Hex = '9a010e120c68656c6c6f2070726f6d7074'; + const step1Hex = 'a201100a0e68656c6c6f20726573706f6e7365'; + const step2Hex = '2a2e222c120a77726974655f66696c651a1e7b2254617267657446696c65223a222f706174682f746f2f66696c65227d'; + cb( + null, + JSON.stringify([{ hex_data: metaHex }]) + '\n' + JSON.stringify([ + { idx: 0, step_type: 14, payload_hex: step0Hex }, + { idx: 1, step_type: 15, payload_hex: step1Hex }, + { idx: 2, step_type: 21, payload_hex: step2Hex }, + ]), + '' + ); + } else { + cb(null, '3.41.0', ''); + } + return {} as unknown as import('child_process').ChildProcess; + }); + + const fakeHome = os.homedir(); + const sessions = await parseAntigravitySessionsAsync(path.join(fakeHome, 'fake-dir')); + expect(sessions.length).toBe(1); + const s = sessions[0]; + expect(s.sessionId).toBe('session-1'); + expect(s.workspaceName).toBe('swazz'); + expect(s.requests.length).toBe(1); + expect(s.requests[0].messageText).toBe('hello prompt'); + expect(s.requests[0].responseText).toBe('hello response'); + expect(s.requests[0].toolsUsed).toContain('write_file'); + expect(s.requests[0].editedFiles).toContain('/path/to/file'); + expect(s.requests[0].modelId).toBe('gemini-3.5-flash'); + }); + + it('should parse model, skills and image fields from steps', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync as unknown as () => string[]).mockReturnValue(['session-2.db']); + vi.mocked(fs.statSync as unknown as () => Partial).mockReturnValue({ birthtimeMs: 10000, mtimeMs: 10000 }); + + vi.mocked(execFileSync).mockImplementation((cmd, args) => { + if (cmd === 'sqlite3') { + const arr = args as string[]; + const sql = arr?.[arr.length - 1] || ''; + if (sql.includes('trajectory_metadata_blob') && sql.includes('steps')) { + const metaHex = '3a1c66696c653a2f2f2f55736572732f616c65782f7372632f7377617a7a1206088ef4d7d006'; + + const prompt = 'Use skill /Users/alex/.gemini/config/skills/brainstorming/SKILL.md to brainstorm'; + const promptBuf = Buffer.from(prompt, 'utf-8'); + const p2Buf = Buffer.concat([ + Buffer.from([0x12, promptBuf.length]), + promptBuf, + ]); + const modelBuf = Buffer.from('model: claude-3-5-sonnet', 'utf-8'); + const payload0 = Buffer.concat([ + Buffer.from([0x9a, 0x01, p2Buf.length]), + p2Buf, + modelBuf, + ]); + + const step0Hex = payload0.toString('hex'); + + const respBuf = Buffer.from('done brainstorming', 'utf-8'); + const p1Buf = Buffer.concat([ + Buffer.from([0x0a, respBuf.length]), + respBuf, + ]); + const payload1 = Buffer.concat([ + Buffer.from([0xa2, 0x01, p1Buf.length]), + p1Buf, + ]); + const step1Hex = payload1.toString('hex'); + + return JSON.stringify([{ hex_data: metaHex }]) + '\n' + JSON.stringify([ + { idx: 0, step_type: 14, payload_hex: step0Hex }, + { idx: 1, step_type: 15, payload_hex: step1Hex }, + ]); + } + } + return '3.41.0'; + }); + + const fakeHome = os.homedir(); + const sessions = parseAntigravitySessions(path.join(fakeHome, 'fake-dir')); + expect(sessions.length).toBe(1); + const s = sessions[0]; + expect(s.requests.length).toBe(1); + const req = s.requests[0]; + expect(req.modelId).toBe('claude-3-5-sonnet'); + expect(req.skillsUsed).toContain('brainstorming'); + }); + + it('should extract images from sqlite steps successfully', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (typeof p === 'string' && p.endsWith('image.png')) return true; + return false; + }); + vi.mocked(fs.readFileSync as unknown as () => Buffer).mockReturnValue(Buffer.from('fake-image-bytes')); + + vi.mocked(execFileSync).mockImplementation((cmd, args) => { + if (cmd === 'sqlite3') { + const arr = args as string[]; + const sql = arr?.[arr.length - 1] || ''; + if (sql.includes('SELECT hex(step_payload)')) { + const attBuf = Buffer.concat([ + Buffer.from([0x0a, 9]), + Buffer.from('image.png', 'utf-8'), + Buffer.from([0x22, 9]), + Buffer.from('image/png', 'utf-8'), + ]); + + const p5Buf = Buffer.concat([ + Buffer.from([0x12, attBuf.length]), + attBuf, + ]); + + const rootBuf = Buffer.concat([ + Buffer.from([0x2a, p5Buf.length]), + p5Buf, + ]); + + return JSON.stringify([{ h: rootBuf.toString('hex') }]); + } + } + return '3.41.0'; + }); + + const testDbPath = path.join(os.tmpdir(), 'session.db'); + const images = extractAntigravityImages(testDbPath, 'session-1-123'); + expect(images.length).toBe(1); + expect(images[0]).toBe('data:image/png;base64,ZmFrZS1pbWFnZS1ieXRlcw=='); + }); +}); diff --git a/src/core/parser-antigravity.ts b/src/core/parser-antigravity.ts new file mode 100644 index 00000000..39c2d7f6 --- /dev/null +++ b/src/core/parser-antigravity.ts @@ -0,0 +1,628 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as path from 'path'; +import { execFileSync, execFile } from 'child_process'; +import * as os from 'os'; +import { Session, SessionRequest } from './types'; +import { assertTrustedPath, createRequest, createSession, extractCodeBlocks, textForCodeScan, extractSkillPathsFromText, extractSkillNameFromPath } from './parser-shared'; + +const SQLITE_QUERY_OPTS = { timeout: 5000, killSignal: 'SIGKILL', maxBuffer: 50 * 1024 * 1024, cwd: os.tmpdir() } as const; + +export function findAntigravityDirs(): string[] { + const home = process.env.HOME || process.env.USERPROFILE || ''; + if (!home) return []; + const dirs: string[] = []; + const paths = [ + path.join(home, '.gemini', 'antigravity', 'conversations'), + path.join(home, '.gemini', 'antigravity-cli', 'conversations'), + path.join(home, '.gemini', 'antigravity-ide', 'conversations'), + ]; + for (const p of paths) { + if (fs.existsSync(p)) dirs.push(p); + } + return dirs; +} + +function readVarint(buf: Buffer, offset: { val: number }): number { + let result = 0; + let shift = 0; + while (true) { + if (offset.val >= buf.length) throw new Error('Varint out of bounds'); + const b = buf[offset.val++]; + result |= (b & 0x7f) << shift; + if (!(b & 0x80)) break; + shift += 7; + } + return result; +} + +function isFieldAllowed(path: number[]): boolean { + if (path.length === 1) { + return path[0] === 1 || path[0] === 2 || path[0] === 5 || path[0] === 7 || path[0] === 19 || path[0] === 20 || path[0] === 24 || path[0] === 114; + } + if (path.length === 2) { + const p0 = path[0], p1 = path[1]; + if (p0 === 2) return p1 === 1; + if (p0 === 5) return p1 === 1 || p1 === 2 || p1 === 4 || p1 === 9; + if (p0 === 19) return p1 === 2; + if (p0 === 20) return p1 === 1; + if (p0 === 24) return p1 === 3; + if (p0 === 114) return p1 === 1; + return false; + } + if (path.length === 3) { + const p0 = path[0], p1 = path[1], p2 = path[2]; + if (p0 === 5 && p1 === 1) return p2 === 1; + if (p0 === 5 && p1 === 2) return p2 === 1 || p2 === 4; + if (p0 === 5 && p1 === 4) return p2 === 2 || p2 === 3 || p2 === 9; + if (p0 === 5 && p1 === 9) return p2 === 1 || p2 === 2; + if (p0 === 24 && p1 === 3) return p2 === 1 || p2 === 5; + return false; + } + return false; +} + +function isNestedMessage(path: number[]): boolean { + if (path.length === 1) { + const p0 = path[0]; + return p0 === 2 || p0 === 5 || p0 === 19 || p0 === 20 || p0 === 24 || p0 === 114; + } + if (path.length === 2) { + const p0 = path[0], p1 = path[1]; + if (p0 === 5) return p1 === 1 || p1 === 2 || p1 === 4 || p1 === 9; + if (p0 === 24) return p1 === 3; + } + return false; +} + +export function decodeProtobuf(buf: Buffer, path: number[] = []): Record { + const result: Record = {}; + const offset = { val: 0 }; + while (offset.val < buf.length) { + try { + const key = readVarint(buf, offset); + const fieldNum = key >> 3; + const wireType = key & 0x07; + + const currentPath = [...path, fieldNum]; + const shouldDecode = isFieldAllowed(currentPath); + + if (wireType === 0) { + const val = readVarint(buf, offset); + if (shouldDecode) result[fieldNum] = val; + } else if (wireType === 1) { + if (offset.val + 8 > buf.length) break; + if (shouldDecode) result[fieldNum] = buf.subarray(offset.val, offset.val + 8); + offset.val += 8; + } else if (wireType === 2) { + const len = readVarint(buf, offset); + if (offset.val + len > buf.length) break; + if (shouldDecode) { + const val = buf.subarray(offset.val, offset.val + len); + let isPrintable = true; + const checkLen = Math.min(val.length, 100); + for (let i = 0; i < checkLen; i++) { + const code = val[i]; + if (code < 32 && code !== 9 && code !== 10 && code !== 13) { + isPrintable = false; + break; + } + } + + let decoded: Record | null = null; + if (isNestedMessage(currentPath) && currentPath.length < 4 && val.length <= 16384) { + try { + const rec = decodeProtobuf(val, currentPath); + if (Object.keys(rec).length > 0) { + decoded = rec; + } + } catch { + // Ignore + } + } + + if (decoded !== null) { + result[fieldNum] = decoded; + } else if (isPrintable && val.length > 0) { + result[fieldNum] = val.toString('utf-8'); + } else { + result[fieldNum] = val; + } + } + offset.val += len; + } else if (wireType === 5) { + if (offset.val + 4 > buf.length) break; + if (shouldDecode) result[fieldNum] = buf.subarray(offset.val, offset.val + 4); + offset.val += 4; + } else { + break; + } + } catch { + break; + } + } + return result; +} + +function getRecord(obj: unknown): Record | undefined { + if (obj && typeof obj === 'object' && !Buffer.isBuffer(obj)) { + return obj as Record; + } + return undefined; +} + +function sqliteQuery(dbPath: string, sql: string): string { + assertTrustedPath(dbPath); + try { + return execFileSync('sqlite3', ['-cmd', 'PRAGMA busy_timeout=1000', '-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); + } catch { + return ''; + } +} + +interface StepRow { + idx: number; + step_type: number; + payload_hex: string; +} + +interface MetadataResult { + workspaceRootPath?: string; + workspaceName: string; + creationDate: number; +} + +function decodeMetadataRows(metaRows: { hex_data?: string }[], birthtimeMs: number): MetadataResult { + let workspaceRootPath: string | undefined; + let workspaceName = 'Antigravity Workspace'; + let creationDate = birthtimeMs; + + if (metaRows[0] && metaRows[0].hex_data) { + const metaBuf = Buffer.from(metaRows[0].hex_data, 'hex'); + const metaObj = decodeProtobuf(metaBuf); + const p7 = metaObj[7]; + const p1 = metaObj[1]; + if (typeof p7 === 'string') { + workspaceRootPath = p7.replace(/^file:\/\//, ''); + } else if (typeof p1 === 'string') { + const m = p1.match(/file:\/\/[^\s"]+/); + if (m) workspaceRootPath = m[0].replace(/^file:\/\//, ''); + } + if (workspaceRootPath) { + workspaceName = path.basename(workspaceRootPath); + } + const p2 = getRecord(metaObj[2]); + if (p2 && typeof p2[1] === 'number') { + creationDate = p2[1] * 1000; + } + } + return { workspaceRootPath, workspaceName, creationDate }; +} + +function parseSqliteMultiResult(stdout: string): string[] { + const firstClose = stdout.indexOf(']'); + if (firstClose === -1) return []; + const firstOpen = stdout.indexOf('['); + if (firstOpen === -1 || firstOpen > firstClose) return []; + const firstArray = stdout.slice(firstOpen, firstClose + 1); + + const secondPart = stdout.slice(firstClose + 1); + const secondOpen = secondPart.indexOf('['); + if (secondOpen === -1) return [firstArray]; + const secondClose = secondPart.lastIndexOf(']'); + if (secondClose === -1 || secondClose < secondOpen) return [firstArray]; + const secondArray = secondPart.slice(secondOpen, secondClose + 1); + + return [firstArray, secondArray]; +} + +function processStepRow( + row: StepRow, + sessionId: string, + creationDate: number, + state: { currentReq: SessionRequest | null; requests: SessionRequest[]; lastMessageDate: number; modelId?: string }, +): void { + if (!row.payload_hex) return; + + if (!state.modelId) { + const bufStr = Buffer.from(row.payload_hex, 'hex').toString('utf-8'); + const m = bufStr.match(/(gemini-[a-zA-Z0-9.-]+|claude-[a-zA-Z0-9.-]+|gpt-[a-zA-Z0-9.-]+|o[13]-[a-zA-Z0-9.-]+)/i); + if (m) state.modelId = m[1]; + } + + const payloadBuf = Buffer.from(row.payload_hex, 'hex'); + const payloadObj = decodeProtobuf(payloadBuf); + + let stepTime = creationDate; + const p5 = getRecord(payloadObj[5]); + if (p5) { + const p5_1 = getRecord(p5[1]); + if (p5_1 && typeof p5_1[1] === 'number') { + stepTime = p5_1[1] * 1000; + if (stepTime > state.lastMessageDate) state.lastMessageDate = stepTime; + } + } + + if (row.step_type === 14) { + const p19 = getRecord(payloadObj[19]); + const promptText = (p19 && typeof p19[2] === 'string') ? p19[2] : ''; + let imageCount = 0; + const p5 = getRecord(payloadObj[5]); + if (p5 && p5[2]) { + const attachments = Array.isArray(p5[2]) ? p5[2] : [p5[2]]; + for (const att of attachments) { + const attRec = getRecord(att); + if (attRec && typeof attRec[1] === 'string') { + const fn = attRec[1].toLowerCase(); + if (fn.endsWith('.png') || fn.endsWith('.jpg') || fn.endsWith('.jpeg') || fn.endsWith('.webp')) { + imageCount++; + } + } + } + } + if (state.currentReq) { finalizeRequest(state.currentReq); state.requests.push(state.currentReq); } + state.currentReq = createRequest({ + requestId: `${sessionId}-${row.idx}`, + timestamp: stepTime, + messageText: promptText, + responseText: '', + agentName: 'Antigravity', + agentMode: 'agent', + toolsUsed: [], + editedFiles: [], + referencedFiles: [], + promptTokens: 0, + completionTokens: 0, + variableKinds: imageCount > 0 ? { image: imageCount } : {}, + }); + } else if (state.currentReq) { + handleAssistantOrToolStep(row, payloadObj, state.currentReq); + } +} + +function handleAssistantOrToolStep(row: StepRow, payloadObj: Record, currentReq: SessionRequest): void { + if (row.step_type === 15 || row.step_type === 101) { + let resp = ''; + const p114 = getRecord(payloadObj[114]); + const p20 = getRecord(payloadObj[20]); + if (row.step_type === 101 && p114 && typeof p114[1] === 'string') { + resp = p114[1]; + } else if (p20 && typeof p20[1] === 'string') { + resp = p20[1]; + } + if (resp) { + currentReq.responseText = currentReq.responseText ? `${currentReq.responseText}\n${resp}` : resp; + } + + const p5 = getRecord(payloadObj[5]); + const tokens = p5 ? getRecord(p5[9]) : undefined; + if (tokens) { + if (typeof tokens[1] === 'number') currentReq.promptTokens = (currentReq.promptTokens || 0) + tokens[1]; + if (typeof tokens[2] === 'number') currentReq.completionTokens = (currentReq.completionTokens || 0) + tokens[2]; + } + } else if (row.step_type === 21) { + const p5 = getRecord(payloadObj[5]); + const p5_4 = p5 ? getRecord(p5[4]) : undefined; + if (p5_4) { + const toolName = (typeof p5_4[9] === 'string' ? p5_4[9] : (typeof p5_4[2] === 'string' ? p5_4[2] : '')); + const toolArgsStr = typeof p5_4[3] === 'string' ? p5_4[3] : ''; + if (toolName) { + currentReq.toolsUsed?.push(toolName); + if (toolArgsStr) { + parseToolArgsAndTrackFiles(toolName, toolArgsStr, currentReq); + } + } + } + } else if (row.step_type === 17) { + const p24 = getRecord(payloadObj[24]); + const p24_3 = p24 ? getRecord(p24[3]) : undefined; + const errText = p24_3 ? (typeof p24_3[5] === 'string' ? p24_3[5] : (typeof p24_3[1] === 'string' ? p24_3[1] : '')) : ''; + if (errText) { + currentReq.responseText = currentReq.responseText ? `${currentReq.responseText}\nError: ${errText}` : `Error: ${errText}`; + } + } +} + +function parseToolArgsAndTrackFiles(toolName: string, toolArgsStr: string, currentReq: SessionRequest): void { + try { + const args = JSON.parse(toolArgsStr) as Record; + const filePath = typeof args.AbsolutePath === 'string' ? args.AbsolutePath : + typeof args.TargetFile === 'string' ? args.TargetFile : + typeof args.file_path === 'string' ? args.file_path : + typeof args.path === 'string' ? args.path : ''; + if (filePath) { + if (['write_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName)) { + currentReq.editedFiles?.push(filePath); + } else { + currentReq.referencedFiles?.push(filePath); + } + } + } catch { + // Ignore JSON parse error + } +} + +export function parseAntigravitySessions(conversationsDir: string): Session[] { + const sessions: Session[] = []; + if (!fs.existsSync(conversationsDir)) return sessions; + + let files: string[]; + try { + files = fs.readdirSync(conversationsDir).filter(f => f.endsWith('.db')); + } catch { + return sessions; + } + + try { + execFileSync('sqlite3', ['--version'], { timeout: 3000, cwd: os.tmpdir() }); + } catch { + return sessions; + } + + for (const file of files) { + const dbPath = path.join(conversationsDir, file); + const sessionId = path.basename(file, '.db'); + + let birthtimeMs = Date.now(); + try { + const stat = fs.statSync(dbPath); + birthtimeMs = stat.birthtimeMs || stat.mtimeMs; + } catch { + // Keep default + } + + const sql = "SELECT hex(data) as hex_data FROM trajectory_metadata_blob WHERE id = 'main'; SELECT idx, step_type, hex(step_payload) as payload_hex FROM steps ORDER BY idx;"; + const raw = sqliteQuery(dbPath, sql); + const arrays = parseSqliteMultiResult(raw); + if (arrays.length < 2) continue; + + let meta: MetadataResult; + let stepRows: StepRow[]; + try { + const metaRows = JSON.parse(arrays[0]) as { hex_data?: string }[]; + meta = decodeMetadataRows(metaRows, birthtimeMs); + stepRows = JSON.parse(arrays[1]) as StepRow[]; + } catch { + continue; + } + + if (stepRows.length === 0) continue; + + const state = { + currentReq: null as SessionRequest | null, + requests: [] as SessionRequest[], + lastMessageDate: meta.creationDate, + modelId: undefined as string | undefined, + }; + + for (const row of stepRows) { + processStepRow(row, sessionId, meta.creationDate, state); + } + + if (state.currentReq) { finalizeRequest(state.currentReq); state.requests.push(state.currentReq); } + if (state.requests.length === 0) continue; + + const finalModelId = state.modelId || 'gemini-3.5-flash'; + for (const r of state.requests) { + r.modelId = finalModelId; + } + + const session = createSession({ + sessionId, + workspaceId: `antigravity-${sessionId}`, + workspaceName: meta.workspaceName, + location: 'terminal', + harness: 'Antigravity', + creationDate: meta.creationDate, + lastMessageDate: state.lastMessageDate, + requests: state.requests, + workspaceRootPath: meta.workspaceRootPath, + }); + sessions.push(session); + } + + return sessions; +} + +function sqliteExecAsync(dbPath: string, args: string[]): Promise { + assertTrustedPath(dbPath); + const newArgs = [...args]; + const idx = newArgs.indexOf('-json'); + if (idx !== -1) { + newArgs.splice(idx, 0, '-cmd', 'PRAGMA busy_timeout=1000'); + } + return new Promise(resolve => { + execFile('sqlite3', newArgs, { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }, (err, stdout) => { + if (err) { + resolve(''); + } else { + resolve(stdout); + } + }); + }); +} + +async function sqliteQueryAsync(dbPath: string, sql: string): Promise { + return sqliteExecAsync(dbPath, ['-json', dbPath, sql]); +} + + +export async function parseAntigravitySessionsAsync( + conversationsDir: string, + onDetail?: (detail: string) => void, +): Promise { + const sessions: Session[] = []; + if (!fs.existsSync(conversationsDir)) return sessions; + + let files: string[]; + try { + files = fs.readdirSync(conversationsDir).filter(f => f.endsWith('.db')); + } catch { + return sessions; + } + + try { + execFileSync('sqlite3', ['--version'], { timeout: 3000, cwd: os.tmpdir() }); + } catch { + return sessions; + } + + let fileIndex = 0; + for (const file of files) { + fileIndex++; + if (onDetail) { + onDetail(`[${fileIndex}/${files.length}] ${file}`); + } + + const dbPath = path.join(conversationsDir, file); + const sessionId = path.basename(file, '.db'); + + let birthtimeMs = Date.now(); + try { + const stat = fs.statSync(dbPath); + birthtimeMs = stat.birthtimeMs || stat.mtimeMs; + } catch { + // Keep default + } + + const start = Date.now(); + + const sql = "SELECT hex(data) as hex_data FROM trajectory_metadata_blob WHERE id = 'main'; SELECT idx, step_type, hex(step_payload) as payload_hex FROM steps ORDER BY idx;"; + const raw = await sqliteQueryAsync(dbPath, sql); + const arrays = parseSqliteMultiResult(raw); + if (arrays.length < 2) continue; + + let meta: MetadataResult; + let stepRows: StepRow[]; + try { + const metaRows = JSON.parse(arrays[0]) as { hex_data?: string }[]; + meta = decodeMetadataRows(metaRows, birthtimeMs); + stepRows = JSON.parse(arrays[1]) as StepRow[]; + } catch { + continue; + } + + if (stepRows.length === 0) continue; + + const state = { + currentReq: null as SessionRequest | null, + requests: [] as SessionRequest[], + lastMessageDate: meta.creationDate, + modelId: undefined as string | undefined, + }; + + for (const row of stepRows) { + processStepRow(row, sessionId, meta.creationDate, state); + } + + if (state.currentReq) { finalizeRequest(state.currentReq); state.requests.push(state.currentReq); } + if (state.requests.length === 0) continue; + + const finalModelId = state.modelId || 'gemini-3.5-flash'; + for (const r of state.requests) { + r.modelId = finalModelId; + } + + const session = createSession({ + sessionId, + workspaceId: `antigravity-${sessionId}`, + workspaceName: meta.workspaceName, + location: 'terminal', + harness: 'Antigravity', + creationDate: meta.creationDate, + lastMessageDate: state.lastMessageDate, + requests: state.requests, + workspaceRootPath: meta.workspaceRootPath, + }); + sessions.push(session); + + const duration = Date.now() - start; + if (duration > 150) { + console.log(`[Antigravity] WARNING: parsing ${file} took ${duration}ms (steps: ${stepRows.length})`); + } + + // Yield to event loop to keep the process responsive + await new Promise(r => setTimeout(r, 0)); + } + + return sessions; +} + +export function extractAntigravityImages(filePath: string, requestId: string): string[] { + try { + const idxStr = requestId.split('-').pop(); + if (!idxStr) return []; + const idx = parseInt(idxStr, 10); + if (Number.isNaN(idx)) return []; + + const sql = `SELECT hex(step_payload) as h FROM steps WHERE idx = ${idx};`; + const out = sqliteQuery(filePath, sql); + if (!out) return []; + + let hex = ''; + try { + const arr = JSON.parse(out) as { h?: string }[]; + if (arr && arr.length > 0 && typeof arr[0].h === 'string') hex = arr[0].h; + } catch { + return []; + } + + if (!hex) return []; + + const buf = Buffer.from(hex, 'hex'); + const payloadObj = decodeProtobuf(buf, []); + + const p5 = getRecord(payloadObj[5]); + if (!p5) return []; + + const attachments = p5[2]; + if (!attachments) return []; + + const arr = Array.isArray(attachments) ? attachments : [attachments]; + const uris: string[] = []; + const artifactsDir = path.join(path.dirname(filePath), 'artifacts'); + + for (const att of arr) { + const attRec = getRecord(att); + if (!attRec) continue; + + const filename = typeof attRec[1] === 'string' ? attRec[1] : null; + if (!filename) continue; + + let mime = 'image/png'; + if (typeof attRec[4] === 'string') mime = attRec[4]; + else if (filename.endsWith('.jpeg') || filename.endsWith('.jpg')) mime = 'image/jpeg'; + else if (filename.endsWith('.webp')) mime = 'image/webp'; + + const imgPath = path.join(artifactsDir, filename); + if (fs.existsSync(imgPath)) { + const fileBuf = fs.readFileSync(imgPath); + uris.push(`data:${mime};base64,${fileBuf.toString('base64')}`); + if (uris.length >= 4) break; + } + } + return uris; + } catch { + return []; + } +} + +function finalizeRequest(req: SessionRequest): void { + req.messageLength = req.messageText.length; + req.responseLength = req.responseText.length; + req.userCode = extractCodeBlocks(textForCodeScan(req.messageText)); + req.aiCode = extractCodeBlocks(textForCodeScan(req.responseText)); + + const skillNames = new Set(); + const allText = req.messageText + '\n' + req.responseText; + const paths = extractSkillPathsFromText(allText); + for (const p of paths) { + const name = extractSkillNameFromPath(p); + if (name) skillNames.add(name); + } + req.skillsUsed = [...skillNames]; +} diff --git a/src/core/parser-claude.ts b/src/core/parser-claude.ts index 5e12059c..4673cbb3 100644 --- a/src/core/parser-claude.ts +++ b/src/core/parser-claude.ts @@ -338,6 +338,8 @@ function encodeComponentForMatch(name: string): string { * @param encoded The encoded project directory name. * @param _projectsDir The `.claude/projects` directory containing this project. */ +const resolvedDirCache = new Map(); + function projectNameFromEncoded(encoded: string, _projectsDir: string): string { const segments = encoded.split('-'); let root: string; @@ -362,16 +364,19 @@ function projectNameFromEncoded(encoded: string, _projectsDir: string): string { let offset = 0; while (offset < remaining.length) { - let dirEntries: { name: string; encoded: string }[]; - try { - dirEntries = fs.readdirSync(resolved, { withFileTypes: true }) - .filter(e => e.isDirectory() || e.isSymbolicLink()) - .map(e => ({ name: e.name, encoded: encodeComponentForMatch(e.name) })) - // Longest encoded form first — greedy match avoids splitting names - // that contain hyphens (e.g. `AI-Engineering-Coach`). - .sort((a, b) => b.encoded.length - a.encoded.length); - } catch { - break; + let dirEntries = resolvedDirCache.get(resolved); + if (!dirEntries) { + try { + dirEntries = fs.readdirSync(resolved, { withFileTypes: true }) + .filter(e => e.isDirectory() || e.isSymbolicLink()) + .map(e => ({ name: e.name, encoded: encodeComponentForMatch(e.name) })) + // Longest encoded form first — greedy match avoids splitting names + // that contain hyphens (e.g. `AI-Engineering-Coach`). + .sort((a, b) => b.encoded.length - a.encoded.length); + resolvedDirCache.set(resolved, dirEntries); + } catch { + break; + } } const rest = remaining.slice(offset); @@ -518,6 +523,7 @@ function parseClaudeProjectSessions( } export function parseClaudeSessions(projectsDir: string): { sessions: Session[]; workspaceId: string; workspaceName: string }[] { + resolvedDirCache.clear(); const results: { sessions: Session[]; workspaceId: string; workspaceName: string }[] = []; let projectDirs: fs.Dirent[]; @@ -539,6 +545,7 @@ export async function parseClaudeSessionsAsync( projectsDir: string, onProject?: (idx: number, total: number, name: string) => void, ): Promise<{ sessions: Session[]; workspaceId: string; workspaceName: string }[]> { + resolvedDirCache.clear(); const results: { sessions: Session[]; workspaceId: string; workspaceName: string }[] = []; let projectDirs: string[]; diff --git a/src/core/parser-harnesses.ts b/src/core/parser-harnesses.ts index 76b33e67..d0c68050 100644 --- a/src/core/parser-harnesses.ts +++ b/src/core/parser-harnesses.ts @@ -10,6 +10,7 @@ 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 { findAntigravityDirs, parseAntigravitySessions, parseAntigravitySessionsAsync } from './parser-antigravity'; type WorkspaceMap = Map; @@ -69,6 +70,22 @@ const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [ } }, }, + { + name: 'Antigravity', + collectSync(ctx) { + for (const agDir of findAntigravityDirs()) { + for (const session of parseAntigravitySessions(agDir)) addSession(ctx.workspaces, ctx.sessions, session, agDir); + } + }, + async collectAsync(ctx, reportDetail) { + for (const agDir of findAntigravityDirs()) { + const sessions = await parseAntigravitySessionsAsync(agDir, reportDetail); + for (const session of sessions) { + addSession(ctx.workspaces, ctx.sessions, session, agDir); + } + } + }, + }, ]; export interface ExternalHarnessProgressHandlers { @@ -88,7 +105,7 @@ 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 || findAntigravityDirs().length > 0; } export function collectExternalHarnessesSync(workspaces: WorkspaceMap, sessions: Session[]): void { @@ -106,6 +123,7 @@ export const EXTERNAL_HARNESS_SET = new Set([ 'Claude', 'Codex', 'OpenCode', + 'Antigravity', ]); export async function collectExternalHarnessesAsync( diff --git a/src/core/parser-main.test.ts b/src/core/parser-main.test.ts index f0320f39..fa804207 100644 --- a/src/core/parser-main.test.ts +++ b/src/core/parser-main.test.ts @@ -24,7 +24,13 @@ vi.mock('./parser-xcode', () => ({ vi.mock('./parser-harnesses', () => ({ collectExternalHarnessesSync: vi.fn(), collectExternalHarnessesAsync: vi.fn(() => Promise.resolve()), - EXTERNAL_HARNESS_SET: new Set(['Claude', 'Codex', 'OpenCode']), + EXTERNAL_HARNESS_SET: new Set(['Claude', 'Codex', 'OpenCode', 'Antigravity']), +})); + +vi.mock('./parser-antigravity', () => ({ + findAntigravityDirs: vi.fn(() => []), + parseAntigravitySessions: vi.fn(() => []), + parseAntigravitySessionsAsync: vi.fn(() => Promise.resolve([])), })); vi.mock('./cache', () => ({ diff --git a/src/core/parser-shared.ts b/src/core/parser-shared.ts index c50d14e6..209b7ff9 100644 --- a/src/core/parser-shared.ts +++ b/src/core/parser-shared.ts @@ -266,7 +266,7 @@ function compactTextForStorage(text: string, maxChars: number): string { return text.slice(0, headChars) + marker + text.slice(text.length - tailChars); } -function textForCodeScan(text: string): string { +export function textForCodeScan(text: string): string { if (text.length <= MAX_CODE_SCAN_CHARS) return text; return text.slice(0, MAX_CODE_SCAN_CHARS); } diff --git a/src/core/parser-vscode-files.ts b/src/core/parser-vscode-files.ts index 98b9dcbf..596f06b3 100644 --- a/src/core/parser-vscode-files.ts +++ b/src/core/parser-vscode-files.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import { StringDecoder } from 'string_decoder'; import { assertTrustedPath, prefetchCache, readFileSafe, recordFailedFile, recordSkippedLines } from './parser-shared'; import { fileUriToPath } from './helpers'; +import { extractAntigravityImages } from './parser-antigravity'; import { debugCore, warnCore } from './log'; export function readFile(fpath: string): string { @@ -451,6 +452,11 @@ function extractImagesFromVariables(variables: RawImageVariable[]): string[] { export function extractSessionImages(filePath: string, requestId: string): string[] { try { assertTrustedPath(filePath); + + if (filePath.endsWith('.db') && filePath.includes('antigravity')) { + return extractAntigravityImages(filePath, requestId); + } + // Bounded read (50 MB cap) so a huge session file can't OOM the extension // host when the image gallery requests it on the main thread. const raw = readFileSafe(filePath); diff --git a/src/core/parser.ts b/src/core/parser.ts index 8a37c238..5e59b7db 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -15,6 +15,7 @@ import { findVsCodeDirs, scanVsCodeDirs, processWorkspaceEntry, processWorkspace import { computeSessionTotals, createRunningTotals, type SessionTotals } from './session-totals'; import { findXcodeDirs, parseXcodeDatabases, parseXcodeDatabasesAsync } from './parser-xcode'; import { collectExternalHarnessesAsync, collectExternalHarnessesSync, EXTERNAL_HARNESS_SET } from './parser-harnesses'; +import { findAntigravityDirs } from './parser-antigravity'; import { warnCore } from './log'; export type { ParseResult }; @@ -95,15 +96,20 @@ function pct(phase: number, intraPhase: number): number { } export function findLogsDirs(): string[] { - return [...findVsCodeDirs(), ...findXcodeDirs()]; + return [...findVsCodeDirs(), ...findXcodeDirs(), ...findAntigravityDirs()]; } function partitionDirs(logsDirs: string[]): { vsCodeDirs: string[]; xcodeDirs: string[] } { const vsCodeDirs: string[] = []; const xcodeDirs: string[] = []; for (const d of logsDirs) { - if (d.includes(path.join('.config', 'github-copilot', 'xcode'))) xcodeDirs.push(d); - else vsCodeDirs.push(d); + if (d.includes(path.join('.config', 'github-copilot', 'xcode'))) { + xcodeDirs.push(d); + } else if (d.includes('.gemini') || d.includes('antigravity')) { + // Skip Antigravity dirs here because they are parsed as external harnesses + } else { + vsCodeDirs.push(d); + } } return { vsCodeDirs, xcodeDirs }; } diff --git a/src/webview/page-config.ts b/src/webview/page-config.ts index 30917970..a555e196 100644 --- a/src/webview/page-config.ts +++ b/src/webview/page-config.ts @@ -13,7 +13,7 @@ import { renderContextManagement } from './page-context-mgmt'; import { llmAvailable, LLM_UNAVAILABLE_NOTE } from './capabilities'; /* Harness colors */ -const HC: Record = { 'Local Agent': '#007acc', 'Local Agent (Insiders)': '#24bfa5', 'Xcode': '#147efb', 'Claude Code': '#d97706', 'GitHub Copilot CLI': '#8b5cf6', 'GitHub Copilot App': '#a371f7', 'Codex CLI': '#ec4899', 'OpenCode': '#10b981' }; +const HC: Record = { 'Local Agent': '#007acc', 'Local Agent (Insiders)': '#24bfa5', 'Xcode': '#147efb', 'Claude Code': '#d97706', 'GitHub Copilot CLI': '#8b5cf6', 'GitHub Copilot App': '#a371f7', 'Codex CLI': '#ec4899', 'OpenCode': '#10b981', 'Antigravity': '#bc8cff' }; function hc(h: string): string { return HC[h] || COLORS.muted; } /** Active treemap chart reference + workspace data for review highlighting */ diff --git a/src/webview/shared.ts b/src/webview/shared.ts index f1d5de83..6c2d805f 100644 --- a/src/webview/shared.ts +++ b/src/webview/shared.ts @@ -342,15 +342,18 @@ export const COLORS = { export const PALETTE = [COLORS.blue, COLORS.green, COLORS.purple, COLORS.yellow, COLORS.red, COLORS.cyan, COLORS.orange, COLORS.pink]; export const HARNESS_COLORS: Record = { - 'Local Agent': '#007ACC', - 'Local Agent (Insiders)': '#24bfa5', - 'Xcode': '#147EFB', - 'GitHub Copilot CLI': '#6e40c9', - 'GitHub Copilot App': '#8957e5', - 'Claude': '#d97706', - - 'Codex': '#10b981', - 'OpenCode': '#8b5cf6', + 'VS Code': COLORS.blue, + 'Windsurf': COLORS.cyan, + 'Cursor': COLORS.purple, + 'Cline': COLORS.orange, + 'Roo Code': COLORS.orange, + 'GitHub Copilot CLI': COLORS.purple, + 'GitHub Copilot App': COLORS.purple, + 'Xcode': COLORS.blue, + 'Codex': COLORS.green, + 'Aider': COLORS.red, + 'Copilot for Xcode': COLORS.blue, + 'Antigravity': COLORS.purple, }; export function harnessColor(name: string, idx: number): string {