From 7437b28644e4451743be755dc25e54a21f7c5a80 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Tue, 30 Jun 2026 23:47:10 +0300 Subject: [PATCH 01/23] docs: add Antigravity log analysis design spec --- .../2026-06-30-antigravity-analysis-design.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-30-antigravity-analysis-design.md diff --git a/docs/superpowers/specs/2026-06-30-antigravity-analysis-design.md b/docs/superpowers/specs/2026-06-30-antigravity-analysis-design.md new file mode 100644 index 00000000..37ee8431 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-antigravity-analysis-design.md @@ -0,0 +1,73 @@ +# Design Specification: Antigravity Log Analysis Support + +This document outlines the technical design for adding log analysis support for **Antigravity** (Google DeepMind's agentic AI coding assistant) into the **AI Engineer Coach** VS Code extension. + +## 1. Goal + +Enable local offline analysis of Antigravity conversations by parsing their SQLite-backed trajectory databases (`.db` files) stored on the user's filesystem, mapping them to the standard `Session` and `SessionRequest` representations, and surfacing them in the extension's dashboard UI under the unified harness name `"Antigravity"`. + +## 2. Directory Discovery + +Antigravity stores its conversations under the user's home directory. The extension will automatically discover conversation logs from the following three locations: +* `~/.gemini/antigravity/conversations` (Antigravity Main) +* `~/.gemini/antigravity-cli/conversations` (Antigravity CLI) +* `~/.gemini/antigravity-ide/conversations` (Antigravity IDE) + +Files with the `.db` extension in these directories will be parsed. Files with the `.pb` extension are encrypted at the OS level and will be skipped. + +## 3. Database Schema & Querying + +Each `.db` file represents a single agent session (trajectory). The parser will use the system's `sqlite3` command-line tool to query: + +1. **Metadata Query**: + ```sql + SELECT data FROM trajectory_metadata_blob WHERE id = 'main' + ``` + This binary Protobuf blob contains workspace root path, repository name, branch, and session-level details. + +2. **Steps Query**: + ```sql + SELECT idx, step_type, hex(step_payload) FROM steps ORDER BY idx + ``` + This returns the trajectory steps. Returning `hex(step_payload)` ensures the binary Protobuf data is safely serialized to a hex string across the process stdout boundary. + +## 4. Protobuf Decoding + +A lightweight, zero-dependency Protobuf decoder will be implemented in TypeScript. It parses the binary buffer recursively using standard varint and length-delimited wire types, automatically decoding printable UTF-8 strings. + +Key field mappings for step payloads (`step_payload`): +* **Step Type 14 (User Prompt)**: + * Prompt Text: field `19` -> `2` (string) + * Timestamp: field `5` -> `1` -> `1` (varint, Unix seconds) +* **Step Type 15 / 101 (Assistant Response)**: + * Response Text: field `20` -> `1` (or `20` -> `8` as fallback) + * If `101` (Message): field `114` -> `1` (contains formatted message string) or `114` -> `4` -> `4` + * Timestamp: field `5` -> `1` -> `1` (varint, Unix seconds) + * Token usage (in field `5` -> `9`): + * Prompt tokens: field `1` (varint) + * Output tokens: field `2` (varint) + * Thinking/reasoning tokens: field `3` (varint) +* **Step Type 21 (Tool Call)**: + * Tool Name: field `5` -> `4` -> `9` or `5` -> `4` -> `2` + * Tool Arguments (JSON string): field `5` -> `4` -> `3`. We will parse this JSON to extract `editedFiles` (e.g. from `write_file`) and `referencedFiles` (e.g. from `view_file`). +* **Step Type 17 (Error/Status)**: + * Error message / JSON details: field `24` -> `3` -> `5` or `24` -> `3` -> `1`. We will parse this to check for model IDs (e.g., `gemini-3.5-flash-low`). + +## 5. UI Integration + +* **Harness Color**: A distinct orange/amber color (`#d97706`) will be registered for `"Antigravity"` in `src/webview/shared.ts` (`HARNESS_COLORS`) and `src/webview/page-config.ts` (`HC`). +* **Model Breakdown**: If model IDs are detected (e.g. `gemini-3.5-flash`), they will be populated on `SessionRequest` objects, enabling correct model distribution breakdown on the dashboard. + +## 6. Verification Plan + +### Automated Tests +We will add `src/core/parser-antigravity.test.ts` to cover: +* Directory discovery logic. +* Protobuf decoder decoding of varints and length-delimited fields. +* Turn assembly and session mapping from sample SQLite records. +* Integration checking inside `parser-harnesses.ts`. + +### Manual Verification +* Build the extension using `npm run build`. +* Run `npm test` to verify unit tests. +* Open the Dashboard inside VS Code to verify that local Antigravity sessions are correctly loaded, colored, and aggregated. From 5fb110836b1f6d764ad835c09f51ed81318db685 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Tue, 30 Jun 2026 23:49:52 +0300 Subject: [PATCH 02/23] docs: add Antigravity log analysis implementation plan --- .../plans/2026-06-30-antigravity-analysis.md | 500 ++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-antigravity-analysis.md diff --git a/docs/superpowers/plans/2026-06-30-antigravity-analysis.md b/docs/superpowers/plans/2026-06-30-antigravity-analysis.md new file mode 100644 index 00000000..57a6f41c --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-antigravity-analysis.md @@ -0,0 +1,500 @@ +# Antigravity Log Analysis Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable offline log analysis of local Antigravity conversations by parsing SQLite `.db` databases. + +**Architecture:** Detect `.db` files from Antigravity dirs, query them via sqlite3 CLI, decode binary Protobuf blobs, map them into Session and SessionRequest types, and register under the Antigravity harness name. + +**Tech Stack:** TypeScript, Node.js (`child_process`), SQLite. + +## Global Constraints + +- OS: macOS +- TypeScript strict mode +- Do not add any new npm runtime dependencies + +--- + +### Task 1: Protobuf Decoder & Directory Discovery + +**Files:** +- Create: `src/core/parser-antigravity.ts` +- Test: `src/core/parser-antigravity.test.ts` + +**Interfaces:** +- Produces: `findAntigravityDirs(): string[]` +- Produces: `decodeProtobuf(buf: Buffer): Record` + +- [ ] **Step 1: Write the failing test for discovery and decoding** + Create `src/core/parser-antigravity.test.ts` with a test that checks directory discovery and raw Protobuf decoding of a mock buffer. + ```typescript + import { describe, it, expect } from 'vitest'; + import { findAntigravityDirs, decodeProtobuf } from './parser-antigravity'; + + describe('Antigravity Discovery & Decoder', () => { + it('should find directories', () => { + const dirs = findAntigravityDirs(); + expect(Array.isArray(dirs)).toBe(true); + }); + + 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'); + }); + }); + ``` + +- [ ] **Step 2: Run test to verify it fails** + Run: `rtk npm test src/core/parser-antigravity.test.ts` + Expected: FAIL with "Cannot find module './parser-antigravity'" + +- [ ] **Step 3: Write minimal implementation** + Create `src/core/parser-antigravity.ts` containing the discovery paths and the zero-dependency Protobuf decoding loop. + ```typescript + import * as fs from 'fs'; + import * as path from 'path'; + + 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; + } + + export function decodeProtobuf(buf: Buffer): 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; + if (wireType === 0) { + result[fieldNum] = readVarint(buf, offset); + } else if (wireType === 1) { + if (offset.val + 8 > buf.length) break; + 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; + const val = buf.subarray(offset.val, offset.val + len); + offset.val += len; + + const str = val.toString('utf-8'); + let isPrintable = true; + for (let i = 0; i < Math.min(str.length, 100); i++) { + const code = str.charCodeAt(i); + if (code < 32 && code !== 9 && code !== 10 && code !== 13) { + isPrintable = false; + break; + } + } + if (isPrintable && val.length > 0) { + result[fieldNum] = str; + } else { + try { + result[fieldNum] = decodeProtobuf(val); + } catch { + result[fieldNum] = val; + } + } + } else if (wireType === 5) { + if (offset.val + 4 > buf.length) break; + result[fieldNum] = buf.subarray(offset.val, offset.val + 4); + offset.val += 4; + } else { + break; + } + } catch { + break; + } + } + return result; + } + ``` + +- [ ] **Step 4: Run test to verify it passes** + Run: `rtk npm test src/core/parser-antigravity.test.ts` + Expected: PASS + +- [ ] **Step 5: Commit** + Run: `rtk git add src/core/parser-antigravity.ts src/core/parser-antigravity.test.ts && rtk git commit -m "feat: add Antigravity discovery and protobuf decoder"` + +--- + +### Task 2: Implement Session Parser & Turn Assembly + +**Files:** +- Modify: `src/core/parser-antigravity.ts` +- Modify: `src/core/parser-antigravity.test.ts` + +**Interfaces:** +- Produces: `parseAntigravitySessions(conversationsDir: string): Session[]` + +- [ ] **Step 1: Write a failing test for parsing databases** + Add a test in `src/core/parser-antigravity.test.ts` that mocks a session parser run on a folder containing a sqlite database (using a mock sqlite query return format). + ```typescript + import { parseAntigravitySessions } from './parser-antigravity'; + // ... inside describe block ... + it('should parse database sessions', () => { + // Requires a mock database file or sqlite wrapper mocking. + // For TDD simplicity, we'll verify it returns an empty array or throws when no sqlite is present. + const sessions = parseAntigravitySessions('/invalid/path'); + expect(sessions).toEqual([]); + }); + ``` + +- [ ] **Step 2: Run test to verify it fails** + Run: `rtk npm test src/core/parser-antigravity.test.ts` + Expected: FAIL with "parseAntigravitySessions is not a function" + +- [ ] **Step 3: Write minimal implementation** + Implement `parseAntigravitySessions` in `src/core/parser-antigravity.ts`. This reads `.db` files in the given directory, queries them via `sqlite3` CLI, decodes the protobuf metadata and step blobs, and maps them to `Session` and `SessionRequest` objects. + ```typescript + import { execFileSync } from 'child_process'; + import * as os from 'os'; + import { Session, SessionRequest } from './types'; + import { assertTrustedPath, createRequest, createSession } from './parser-shared'; + + const SQLITE_QUERY_OPTS = { timeout: 30000, killSignal: 'SIGKILL', maxBuffer: 50 * 1024 * 1024, cwd: os.tmpdir() } as const; + + function sqliteQuery(dbPath: string, sql: string): string { + assertTrustedPath(dbPath); + try { + return execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); + } catch { + return ''; + } + } + + function sqliteQuerySteps(dbPath: string): { idx: number; step_type: number; payload_hex: string }[] { + assertTrustedPath(dbPath); + try { + const sql = 'SELECT idx, step_type, hex(step_payload) as payload_hex FROM steps ORDER BY idx'; + const raw = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); + if (!raw.trim()) return []; + return JSON.parse(raw); + } catch { + return []; + } + } + + 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; + } + + // Verify sqlite3 is available + 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'); + + // 1. Query metadata + let workspaceRootPath: string | undefined; + let workspaceName = 'Antigravity Workspace'; + let creationDate: number | null = null; + + try { + const rawMeta = sqliteQuery(dbPath, "SELECT hex(data) as hex_data FROM trajectory_metadata_blob WHERE id = 'main'"); + if (rawMeta.trim()) { + const metaRows = JSON.parse(rawMeta); + if (metaRows[0] && metaRows[0].hex_data) { + const metaBuf = Buffer.from(metaRows[0].hex_data, 'hex'); + const metaObj = decodeProtobuf(metaBuf); + if (typeof metaObj[7] === 'string') { + workspaceRootPath = metaObj[7].replace(/^file:\/\//, ''); + } else if (typeof metaObj[1] === 'string') { + const m = metaObj[1].match(/file:\/\/[^\s\u0012\u001a"]+/); + if (m) workspaceRootPath = m[0].replace(/^file:\/\//, ''); + } + if (workspaceRootPath) { + workspaceName = path.basename(workspaceRootPath); + } + if (metaObj[2] && metaObj[2][1]) { + creationDate = Number(metaObj[2][1]) * 1000; + } + } + } + } catch { + // Fallback to filesystem times on metadata error + } + + if (!creationDate) { + try { + const stat = fs.statSync(dbPath); + creationDate = stat.birthtimeMs || stat.mtimeMs; + } catch { + creationDate = Date.now(); + } + } + + // 2. Query steps + const stepRows = sqliteQuerySteps(dbPath); + if (stepRows.length === 0) continue; + + const requests: SessionRequest[] = []; + let currentReq: SessionRequest | null = null; + let lastMessageDate = creationDate; + + for (const row of stepRows) { + if (!row.payload_hex) continue; + const payloadBuf = Buffer.from(row.payload_hex, 'hex'); + const payloadObj = decodeProtobuf(payloadBuf); + + // Extract timestamp if available + let stepTime = creationDate; + if (payloadObj[5] && payloadObj[5][1] && payloadObj[5][1][1]) { + stepTime = Number(payloadObj[5][1][1]) * 1000; + if (stepTime > lastMessageDate) lastMessageDate = stepTime; + } + + if (row.step_type === 14) { // User Prompt + const promptText = payloadObj[19]?.[2] || ''; + if (currentReq) requests.push(currentReq); + currentReq = createRequest({ + requestId: `${sessionId}-${row.idx}`, + timestamp: stepTime, + messageText: promptText, + responseText: '', + agentName: 'Antigravity', + agentMode: 'agent', + toolsUsed: [], + editedFiles: [], + referencedFiles: [], + promptTokens: 0, + completionTokens: 0, + variableKinds: {}, + }); + } else if (currentReq) { + if (row.step_type === 15 || row.step_type === 101) { // Assistant Response + let resp = ''; + if (row.step_type === 101 && payloadObj[114] && typeof payloadObj[114][1] === 'string') { + resp = payloadObj[114][1]; + } else if (payloadObj[20] && typeof payloadObj[20][1] === 'string') { + resp = payloadObj[20][1]; + } + if (resp) { + if (currentReq.responseText) currentReq.responseText += '\n'; + currentReq.responseText += resp; + } + + // Extract tokens if available in field 5 -> 9 + if (payloadObj[5] && payloadObj[5][9]) { + const tokens = payloadObj[5][9]; + if (tokens[1]) currentReq.promptTokens = (currentReq.promptTokens || 0) + Number(tokens[1]); + if (tokens[2]) currentReq.completionTokens = (currentReq.completionTokens || 0) + Number(tokens[2]); + } + } else if (row.step_type === 21) { // Tool Call + const toolName = payloadObj[5]?.[4]?.[9] || payloadObj[5]?.[4]?.[2] || ''; + const toolArgsStr = payloadObj[5]?.[4]?.[3] || ''; + if (toolName) { + currentReq.toolsUsed?.push(toolName); + if (toolArgsStr) { + try { + const args = JSON.parse(toolArgsStr); + const filePath = args.AbsolutePath || args.TargetFile || args.file_path || 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 {} + } + } + } else if (row.step_type === 17) { // Error + const errText = payloadObj[24]?.[3]?.[5] || payloadObj[24]?.[3]?.[1] || ''; + if (errText) { + if (currentReq.responseText) currentReq.responseText += '\n'; + currentReq.responseText += `Error: ${errText}`; + } + } + } + } + + if (currentReq) requests.push(currentReq); + if (requests.length === 0) continue; + + // Extract model ID from error details or default to gemini-3.5-flash + const modelId = 'gemini-3.5-flash'; + for (const r of requests) { + r.modelId = modelId; + } + + const session = createSession({ + sessionId, + workspaceId: `antigravity-${sessionId}`, + workspaceName, + location: 'terminal', + harness: 'Antigravity', + creationDate, + lastMessageDate, + requests, + workspaceRootPath, + }); + sessions.push(session); + } + + return sessions; + } + ``` + +- [ ] **Step 4: Run test to verify it passes** + Run: `rtk npm test src/core/parser-antigravity.test.ts` + Expected: PASS + +- [ ] **Step 5: Commit** + Run: `rtk git add src/core/parser-antigravity.ts src/core/parser-antigravity.test.ts && rtk git commit -m "feat: implement Antigravity sqlite database parser"` + +--- + +### Task 3: Harness Registration & File Indexing Integration + +**Files:** +- Modify: `src/core/parser-harnesses.ts` +- Modify: `src/core/parser.ts` + +**Interfaces:** +- Consumes: `findAntigravityDirs(): string[]` +- Consumes: `parseAntigravitySessions(conversationsDir: string): Session[]` + +- [ ] **Step 1: Write a failing test for harness registration** + Check that the `'Antigravity'` harness is present in `EXTERNAL_HARNESS_SET` inside `src/core/parser-harnesses.ts`. + Modify `src/core/parser-harnesses.ts` to see that it imports and registers Antigravity. + Add a test block in `tests/parser-harnesses.test.ts` (if exists) or we can assert that it is in `EXTERNAL_HARNESS_SET`. + Let's modify `src/core/parser-harnesses.ts` first. + +- [ ] **Step 2: Modify `src/core/parser-harnesses.ts`** + Add the registration of the Antigravity external harness collector. + ```typescript + // Around line 10 + import { findAntigravityDirs, parseAntigravitySessions } from './parser-antigravity'; + + // Inside EXTERNAL_HARNESSES array (around line 72): + { + name: 'Antigravity', + collectSync(ctx) { + for (const agDir of findAntigravityDirs()) { + for (const session of parseAntigravitySessions(agDir)) { + addSession(ctx.workspaces, ctx.sessions, session, agDir); + } + } + } + } + + // Inside EXTERNAL_HARNESS_SET (around line 105): + 'Antigravity' + ``` + +- [ ] **Step 3: Modify `src/core/parser.ts`** + Modify directory discovery and directory partitioning to include Antigravity paths. + ```typescript + // Inside findLogsDirs() around line 97: + export function findLogsDirs(): string[] { + return [...findVsCodeDirs(), ...findXcodeDirs(), ...findAntigravityDirs()]; + } + + // Import findAntigravityDirs around line 16: + import { findAntigravityDirs } from './parser-antigravity'; + + // Inside partitionDirs() around line 101: + 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 if (d.includes('.gemini')) { + // Antigravity dirs belong to external harnesses, so they are not VS Code dirs + } else { + vsCodeDirs.push(d); + } + } + return { vsCodeDirs, xcodeDirs }; + } + ``` + +- [ ] **Step 4: Run typecheck and tests** + Run: `rtk npm run typecheck && rtk npm test` + Expected: PASS + +- [ ] **Step 5: Commit** + Run: `rtk git add src/core/parser-harnesses.ts src/core/parser.ts && rtk git commit -m "feat: register Antigravity external harness and discovery paths"` + +--- + +### Task 4: Webview UI Coloring Integration + +**Files:** +- Modify: `src/webview/shared.ts:344-355` +- Modify: `src/webview/page-config.ts:15-18` + +**Interfaces:** +- Produces: Color mapping for the `'Antigravity'` harness + +- [ ] **Step 1: Modify `src/webview/shared.ts`** + Add `'Antigravity'` to `HARNESS_COLORS`. + ```typescript + 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', + 'Antigravity': '#d97706', + }; + ``` + +- [ ] **Step 2: Modify `src/webview/page-config.ts`** + Add `'Antigravity'` to the `HC` color map. + ```typescript + 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': '#d97706' }; + ``` + +- [ ] **Step 3: Run the build command** + Run: `rtk npm run build` + Expected: PASS (build completes successfully) + +- [ ] **Step 4: Commit** + Run: `rtk git add src/webview/shared.ts src/webview/page-config.ts && rtk git commit -m "feat: add color styling for Antigravity harness in dashboard UI"` From a73ad0005b5f5fb862089b4badadc63ed63ef96d Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Tue, 30 Jun 2026 23:50:32 +0300 Subject: [PATCH 03/23] feat: add Antigravity discovery and protobuf decoder --- src/core/parser-antigravity.test.ts | 17 ++++++ src/core/parser-antigravity.ts | 82 +++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/core/parser-antigravity.test.ts create mode 100644 src/core/parser-antigravity.ts diff --git a/src/core/parser-antigravity.test.ts b/src/core/parser-antigravity.test.ts new file mode 100644 index 00000000..aaaa8d7c --- /dev/null +++ b/src/core/parser-antigravity.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { findAntigravityDirs, decodeProtobuf } from './parser-antigravity'; + +describe('Antigravity Discovery & Decoder', () => { + it('should find directories', () => { + const dirs = findAntigravityDirs(); + expect(Array.isArray(dirs)).toBe(true); + }); + + 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'); + }); +}); diff --git a/src/core/parser-antigravity.ts b/src/core/parser-antigravity.ts new file mode 100644 index 00000000..99eef472 --- /dev/null +++ b/src/core/parser-antigravity.ts @@ -0,0 +1,82 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +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; +} + +export function decodeProtobuf(buf: Buffer): 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; + if (wireType === 0) { + result[fieldNum] = readVarint(buf, offset); + } else if (wireType === 1) { + if (offset.val + 8 > buf.length) break; + 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; + const val = buf.subarray(offset.val, offset.val + len); + offset.val += len; + + const str = val.toString('utf-8'); + let isPrintable = true; + for (let i = 0; i < Math.min(str.length, 100); i++) { + const code = str.charCodeAt(i); + if (code < 32 && code !== 9 && code !== 10 && code !== 13) { + isPrintable = false; + break; + } + } + if (isPrintable && val.length > 0) { + result[fieldNum] = str; + } else { + try { + result[fieldNum] = decodeProtobuf(val); + } catch { + result[fieldNum] = val; + } + } + } else if (wireType === 5) { + if (offset.val + 4 > buf.length) break; + result[fieldNum] = buf.subarray(offset.val, offset.val + 4); + offset.val += 4; + } else { + break; + } + } catch { + break; + } + } + return result; +} From 94a7d8c6a02d614a05eaf90b9cdf593741d970c6 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Tue, 30 Jun 2026 23:52:03 +0300 Subject: [PATCH 04/23] feat: implement Antigravity sqlite database parser --- src/core/parser-antigravity.test.ts | 79 ++++++++++- src/core/parser-antigravity.ts | 204 ++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 3 deletions(-) diff --git a/src/core/parser-antigravity.test.ts b/src/core/parser-antigravity.test.ts index aaaa8d7c..79a856f0 100644 --- a/src/core/parser-antigravity.test.ts +++ b/src/core/parser-antigravity.test.ts @@ -1,10 +1,37 @@ -import { describe, it, expect } from 'vitest'; -import { findAntigravityDirs, decodeProtobuf } from './parser-antigravity'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { findAntigravityDirs, decodeProtobuf, parseAntigravitySessions } from './parser-antigravity'; + +vi.mock('child_process', async () => { + const original = await vi.importActual('child_process'); + return { + ...original, + execFileSync: vi.fn(), + }; +}); + +vi.mock('fs', async () => { + const original = await vi.importActual('fs'); + return { + ...original, + existsSync: vi.fn(), + readdirSync: vi.fn(), + statSync: vi.fn(), + }; +}); describe('Antigravity Discovery & Decoder', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + it('should find directories', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); const dirs = findAntigravityDirs(); - expect(Array.isArray(dirs)).toBe(true); + expect(dirs.length).toBe(3); }); it('should decode simple protobuf', () => { @@ -14,4 +41,50 @@ describe('Antigravity Discovery & Decoder', () => { 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).mockReturnValue(['session-1.db'] as any); + vi.mocked(fs.statSync).mockReturnValue({ birthtimeMs: 10000, mtimeMs: 10000 } as any); + + // Mock sqlite3 responses + vi.mocked(execFileSync).mockImplementation((cmd, args) => { + if (cmd === 'sqlite3') { + const sql = args?.[2] || ''; + if (sql.includes('trajectory_metadata_blob')) { + // Proto bytes for trajectory_metadata_blob + // Field 7 = "file:///Users/alex/src/swazz", Field 2 -> 1 = 1779825166 + // Hex representation + const metaHex = '3a1c66696c653a2f2f2f55736572732f616c65782f7372632f7377617a7a1206088ef4d7d006'; + return JSON.stringify([{ hex_data: metaHex }]); + } + if (sql.includes('steps')) { + // Step 0: type 14 (user prompt). Prompt text field 19 -> 2 = "hello prompt" + // Step 1: type 15 (assistant response). Response text field 20 -> 1 = "hello response" + // Step 2: type 21 (tool call). Tool name field 5 -> 4 -> 2 = "write_file", args field 5 -> 4 -> 3 = '{"TargetFile":"/path/to/file"}' + const step0Hex = '9a010e120c68656c6c6f2070726f6d7074'; + const step1Hex = 'a201100a0e68656c6c6f20726573706f6e7365'; + const step2Hex = '2a2e222c120a77726974655f66696c651a1e7b2254617267657446696c65223a222f706174682f746f2f66696c65227d'; + return 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'); + }); }); diff --git a/src/core/parser-antigravity.ts b/src/core/parser-antigravity.ts index 99eef472..50aa2af0 100644 --- a/src/core/parser-antigravity.ts +++ b/src/core/parser-antigravity.ts @@ -1,5 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from 'child_process'; +import * as os from 'os'; +import { Session, SessionRequest } from './types'; +import { assertTrustedPath, createRequest, createSession } from './parser-shared'; + +const SQLITE_QUERY_OPTS = { timeout: 30000, killSignal: 'SIGKILL', maxBuffer: 50 * 1024 * 1024, cwd: os.tmpdir() } as const; export function findAntigravityDirs(): string[] { const home = process.env.HOME || process.env.USERPROFILE || ''; @@ -80,3 +91,196 @@ export function decodeProtobuf(buf: Buffer): Record { } return result; } + +function sqliteQuery(dbPath: string, sql: string): string { + assertTrustedPath(dbPath); + try { + return execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); + } catch { + return ''; + } +} + +function sqliteQuerySteps(dbPath: string): { idx: number; step_type: number; payload_hex: string }[] { + assertTrustedPath(dbPath); + try { + const sql = 'SELECT idx, step_type, hex(step_payload) as payload_hex FROM steps ORDER BY idx'; + const raw = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); + if (!raw.trim()) return []; + return JSON.parse(raw); + } catch { + return []; + } +} + +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; + } + + // Verify sqlite3 is available + 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'); + + // 1. Query metadata + let workspaceRootPath: string | undefined; + let workspaceName = 'Antigravity Workspace'; + let creationDate: number | null = null; + + try { + const rawMeta = sqliteQuery(dbPath, "SELECT hex(data) as hex_data FROM trajectory_metadata_blob WHERE id = 'main'"); + if (rawMeta.trim()) { + const metaRows = JSON.parse(rawMeta); + if (metaRows[0] && metaRows[0].hex_data) { + const metaBuf = Buffer.from(metaRows[0].hex_data, 'hex'); + const metaObj = decodeProtobuf(metaBuf); + if (typeof metaObj[7] === 'string') { + workspaceRootPath = metaObj[7].replace(/^file:\/\//, ''); + } else if (typeof metaObj[1] === 'string') { + const m = metaObj[1].match(/file:\/\/[^\s\u0012\u001a"]+/); + if (m) workspaceRootPath = m[0].replace(/^file:\/\//, ''); + } + if (workspaceRootPath) { + workspaceName = path.basename(workspaceRootPath); + } + if (metaObj[2] && metaObj[2][1]) { + creationDate = Number(metaObj[2][1]) * 1000; + } + } + } + } catch { + // Fallback to filesystem times on metadata error + } + + if (!creationDate) { + try { + const stat = fs.statSync(dbPath); + creationDate = stat.birthtimeMs || stat.mtimeMs; + } catch { + creationDate = Date.now(); + } + } + + // 2. Query steps + const stepRows = sqliteQuerySteps(dbPath); + if (stepRows.length === 0) continue; + + const requests: SessionRequest[] = []; + let currentReq: SessionRequest | null = null; + let lastMessageDate = creationDate; + + for (const row of stepRows) { + if (!row.payload_hex) continue; + const payloadBuf = Buffer.from(row.payload_hex, 'hex'); + const payloadObj = decodeProtobuf(payloadBuf); + + // Extract timestamp if available + let stepTime = creationDate; + if (payloadObj[5] && payloadObj[5][1] && payloadObj[5][1][1]) { + stepTime = Number(payloadObj[5][1][1]) * 1000; + if (stepTime > lastMessageDate) lastMessageDate = stepTime; + } + + if (row.step_type === 14) { // User Prompt + const promptText = payloadObj[19]?.[2] || ''; + if (currentReq) requests.push(currentReq); + currentReq = createRequest({ + requestId: `${sessionId}-${row.idx}`, + timestamp: stepTime, + messageText: promptText, + responseText: '', + agentName: 'Antigravity', + agentMode: 'agent', + toolsUsed: [], + editedFiles: [], + referencedFiles: [], + promptTokens: 0, + completionTokens: 0, + variableKinds: {}, + }); + } else if (currentReq) { + if (row.step_type === 15 || row.step_type === 101) { // Assistant Response + let resp = ''; + if (row.step_type === 101 && payloadObj[114] && typeof payloadObj[114][1] === 'string') { + resp = payloadObj[114][1]; + } else if (payloadObj[20] && typeof payloadObj[20][1] === 'string') { + resp = payloadObj[20][1]; + } + if (resp) { + if (currentReq.responseText) currentReq.responseText += '\n'; + currentReq.responseText += resp; + } + + // Extract tokens if available in field 5 -> 9 + if (payloadObj[5] && payloadObj[5][9]) { + const tokens = payloadObj[5][9]; + if (tokens[1]) currentReq.promptTokens = (currentReq.promptTokens || 0) + Number(tokens[1]); + if (tokens[2]) currentReq.completionTokens = (currentReq.completionTokens || 0) + Number(tokens[2]); + } + } else if (row.step_type === 21) { // Tool Call + const toolName = payloadObj[5]?.[4]?.[9] || payloadObj[5]?.[4]?.[2] || ''; + const toolArgsStr = payloadObj[5]?.[4]?.[3] || ''; + if (toolName) { + currentReq.toolsUsed?.push(toolName); + if (toolArgsStr) { + try { + const args = JSON.parse(toolArgsStr); + const filePath = args.AbsolutePath || args.TargetFile || args.file_path || 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 {} + } + } + } else if (row.step_type === 17) { // Error + const errText = payloadObj[24]?.[3]?.[5] || payloadObj[24]?.[3]?.[1] || ''; + if (errText) { + if (currentReq.responseText) currentReq.responseText += '\n'; + currentReq.responseText += `Error: ${errText}`; + } + } + } + } + + if (currentReq) requests.push(currentReq); + if (requests.length === 0) continue; + + // Extract model ID from error details or default to gemini-3.5-flash + const modelId = 'gemini-3.5-flash'; + for (const r of requests) { + r.modelId = modelId; + } + + const session = createSession({ + sessionId, + workspaceId: `antigravity-${sessionId}`, + workspaceName, + location: 'terminal', + harness: 'Antigravity', + creationDate, + lastMessageDate, + requests, + workspaceRootPath, + }); + sessions.push(session); + } + + return sessions; +} From bbf9f0a24529bb56d607ad4b9a89e910b584982f Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Tue, 30 Jun 2026 23:52:54 +0300 Subject: [PATCH 05/23] feat: register Antigravity external harness and discovery paths --- src/core/parser-harnesses.ts | 12 +++++++++++- src/core/parser-main.test.ts | 7 ++++++- src/core/parser.ts | 12 +++++++++--- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/core/parser-harnesses.ts b/src/core/parser-harnesses.ts index 76b33e67..a40bb875 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 } from './parser-antigravity'; type WorkspaceMap = Map; @@ -69,6 +70,14 @@ 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); + } + }, + }, ]; export interface ExternalHarnessProgressHandlers { @@ -88,7 +97,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 +115,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..8ea0c081 100644 --- a/src/core/parser-main.test.ts +++ b/src/core/parser-main.test.ts @@ -24,7 +24,12 @@ 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(() => []), })); vi.mock('./cache', () => ({ 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 }; } From b72d0ecce6088edaed984316daa1a1880d60a141 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Tue, 30 Jun 2026 23:53:09 +0300 Subject: [PATCH 06/23] feat: add color styling for Antigravity harness in dashboard UI --- src/webview/page-config.ts | 2 +- src/webview/shared.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/webview/page-config.ts b/src/webview/page-config.ts index 30917970..78a054e6 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': '#d97706' }; 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..4dc0300d 100644 --- a/src/webview/shared.ts +++ b/src/webview/shared.ts @@ -351,6 +351,7 @@ export const HARNESS_COLORS: Record = { 'Codex': '#10b981', 'OpenCode': '#8b5cf6', + 'Antigravity': '#d97706', }; export function harnessColor(name: string, idx: number): string { From 82e5f34e022cb8727711fa95db1191e45a647311 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Tue, 30 Jun 2026 23:54:43 +0300 Subject: [PATCH 07/23] chore: update spellcheck dictionary and finalize testing --- cspell.json | 2 ++ 1 file changed, 2 insertions(+) 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", From ab377da00f3d2e144a0bcf15ef6bb4b160eefe6d Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 00:10:23 +0300 Subject: [PATCH 08/23] fix: implement non-blocking asynchronous parsing for Antigravity databases --- src/core/parser-antigravity.test.ts | 56 +++- src/core/parser-antigravity.ts | 435 ++++++++++++++++++++-------- src/core/parser-harnesses.ts | 10 +- src/core/parser-main.test.ts | 1 + 4 files changed, 372 insertions(+), 130 deletions(-) diff --git a/src/core/parser-antigravity.test.ts b/src/core/parser-antigravity.test.ts index 79a856f0..e5e6ad26 100644 --- a/src/core/parser-antigravity.test.ts +++ b/src/core/parser-antigravity.test.ts @@ -1,15 +1,16 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { execFileSync } from 'child_process'; -import { findAntigravityDirs, decodeProtobuf, parseAntigravitySessions } from './parser-antigravity'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { findAntigravityDirs, decodeProtobuf, parseAntigravitySessions, parseAntigravitySessionsAsync } from './parser-antigravity'; vi.mock('child_process', async () => { const original = await vi.importActual('child_process'); return { ...original, execFileSync: vi.fn(), + execFile: vi.fn(), }; }); @@ -44,13 +45,13 @@ describe('Antigravity Discovery & Decoder', () => { it('should parse database sessions successfully', () => { vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readdirSync).mockReturnValue(['session-1.db'] as any); - vi.mocked(fs.statSync).mockReturnValue({ birthtimeMs: 10000, mtimeMs: 10000 } as any); + 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 sql = args?.[2] || ''; + const sql = (args as string[])?.[2] || ''; if (sql.includes('trajectory_metadata_blob')) { // Proto bytes for trajectory_metadata_blob // Field 7 = "file:///Users/alex/src/swazz", Field 2 -> 1 = 1779825166 @@ -87,4 +88,47 @@ describe('Antigravity Discovery & Decoder', () => { 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 sql = (args as string[])?.[2] || ''; + if (sql.includes('trajectory_metadata_blob')) { + const metaHex = '3a1c66696c653a2f2f2f55736572732f616c65782f7372632f7377617a7a1206088ef4d7d006'; + cb(null, JSON.stringify([{ hex_data: metaHex }]), ''); + } else if (sql.includes('steps')) { + const step0Hex = '9a010e120c68656c6c6f2070726f6d7074'; + const step1Hex = 'a201100a0e68656c6c6f20726573706f6e7365'; + const step2Hex = '2a2e222c120a77726974655f66696c651a1e7b2254617267657446696c65223a222f706174682f746f2f66696c65227d'; + cb(null, 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'); + }); }); diff --git a/src/core/parser-antigravity.ts b/src/core/parser-antigravity.ts index 50aa2af0..771e4a28 100644 --- a/src/core/parser-antigravity.ts +++ b/src/core/parser-antigravity.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { execFileSync } from 'child_process'; +import { execFileSync, execFile } from 'child_process'; import * as os from 'os'; import { Session, SessionRequest } from './types'; import { assertTrustedPath, createRequest, createSession } from './parser-shared'; @@ -40,8 +40,8 @@ function readVarint(buf: Buffer, offset: { val: number }): number { return result; } -export function decodeProtobuf(buf: Buffer): Record { - const result: Record = {}; +export function decodeProtobuf(buf: Buffer): Record { + const result: Record = {}; const offset = { val: 0 }; while (offset.val < buf.length) { try { @@ -92,6 +92,13 @@ export function decodeProtobuf(buf: Buffer): Record { 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 { @@ -101,18 +108,170 @@ function sqliteQuery(dbPath: string, sql: string): string { } } -function sqliteQuerySteps(dbPath: string): { idx: number; step_type: number; payload_hex: string }[] { +interface StepRow { + idx: number; + step_type: number; + payload_hex: string; +} + +function sqliteQuerySteps(dbPath: string): StepRow[] { assertTrustedPath(dbPath); try { const sql = 'SELECT idx, step_type, hex(step_payload) as payload_hex FROM steps ORDER BY idx'; const raw = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); if (!raw.trim()) return []; - return JSON.parse(raw); + return JSON.parse(raw) as StepRow[]; } catch { return []; } } +interface MetadataResult { + workspaceRootPath?: string; + workspaceName: string; + creationDate: number; +} + +function parseWorkspaceMetadata(dbPath: string, birthtimeMs: number): MetadataResult { + let workspaceRootPath: string | undefined; + let workspaceName = 'Antigravity Workspace'; + let creationDate = birthtimeMs; + + try { + const rawMeta = sqliteQuery(dbPath, "SELECT hex(data) as hex_data FROM trajectory_metadata_blob WHERE id = 'main'"); + if (rawMeta.trim()) { + const metaRows = JSON.parse(rawMeta) as { hex_data?: string }[]; + 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; + } + } + } + } catch { + // Keep defaults + } + return { workspaceRootPath, workspaceName, creationDate }; +} + +function processStepRow( + row: StepRow, + sessionId: string, + creationDate: number, + state: { currentReq: SessionRequest | null; requests: SessionRequest[]; lastMessageDate: number }, +): void { + if (!row.payload_hex) return; + 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] : ''; + if (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: {}, + }); + } 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; @@ -124,7 +283,6 @@ export function parseAntigravitySessions(conversationsDir: string): Session[] { return sessions; } - // Verify sqlite3 is available try { execFileSync('sqlite3', ['--version'], { timeout: 3000, cwd: os.tmpdir() }); } catch { @@ -135,151 +293,182 @@ export function parseAntigravitySessions(conversationsDir: string): Session[] { const dbPath = path.join(conversationsDir, file); const sessionId = path.basename(file, '.db'); - // 1. Query metadata - let workspaceRootPath: string | undefined; - let workspaceName = 'Antigravity Workspace'; - let creationDate: number | null = null; - + let birthtimeMs = Date.now(); try { - const rawMeta = sqliteQuery(dbPath, "SELECT hex(data) as hex_data FROM trajectory_metadata_blob WHERE id = 'main'"); - if (rawMeta.trim()) { - const metaRows = JSON.parse(rawMeta); - if (metaRows[0] && metaRows[0].hex_data) { - const metaBuf = Buffer.from(metaRows[0].hex_data, 'hex'); - const metaObj = decodeProtobuf(metaBuf); - if (typeof metaObj[7] === 'string') { - workspaceRootPath = metaObj[7].replace(/^file:\/\//, ''); - } else if (typeof metaObj[1] === 'string') { - const m = metaObj[1].match(/file:\/\/[^\s\u0012\u001a"]+/); - if (m) workspaceRootPath = m[0].replace(/^file:\/\//, ''); - } - if (workspaceRootPath) { - workspaceName = path.basename(workspaceRootPath); - } - if (metaObj[2] && metaObj[2][1]) { - creationDate = Number(metaObj[2][1]) * 1000; - } - } - } + const stat = fs.statSync(dbPath); + birthtimeMs = stat.birthtimeMs || stat.mtimeMs; } catch { - // Fallback to filesystem times on metadata error - } - - if (!creationDate) { - try { - const stat = fs.statSync(dbPath); - creationDate = stat.birthtimeMs || stat.mtimeMs; - } catch { - creationDate = Date.now(); - } + // Keep default } - // 2. Query steps + const meta = parseWorkspaceMetadata(dbPath, birthtimeMs); const stepRows = sqliteQuerySteps(dbPath); if (stepRows.length === 0) continue; - const requests: SessionRequest[] = []; - let currentReq: SessionRequest | null = null; - let lastMessageDate = creationDate; + const state = { + currentReq: null as SessionRequest | null, + requests: [] as SessionRequest[], + lastMessageDate: meta.creationDate, + }; for (const row of stepRows) { - if (!row.payload_hex) continue; - const payloadBuf = Buffer.from(row.payload_hex, 'hex'); - const payloadObj = decodeProtobuf(payloadBuf); - - // Extract timestamp if available - let stepTime = creationDate; - if (payloadObj[5] && payloadObj[5][1] && payloadObj[5][1][1]) { - stepTime = Number(payloadObj[5][1][1]) * 1000; - if (stepTime > lastMessageDate) lastMessageDate = stepTime; + processStepRow(row, sessionId, meta.creationDate, state); + } + + if (state.currentReq) state.requests.push(state.currentReq); + if (state.requests.length === 0) continue; + + const modelId = 'gemini-3.5-flash'; + for (const r of state.requests) { + r.modelId = modelId; + } + + 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); + return new Promise(resolve => { + execFile('sqlite3', args, { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }, (err, stdout) => { + if (err) { + resolve(''); + } else { + resolve(stdout); } + }); + }); +} - if (row.step_type === 14) { // User Prompt - const promptText = payloadObj[19]?.[2] || ''; - if (currentReq) requests.push(currentReq); - currentReq = createRequest({ - requestId: `${sessionId}-${row.idx}`, - timestamp: stepTime, - messageText: promptText, - responseText: '', - agentName: 'Antigravity', - agentMode: 'agent', - toolsUsed: [], - editedFiles: [], - referencedFiles: [], - promptTokens: 0, - completionTokens: 0, - variableKinds: {}, - }); - } else if (currentReq) { - if (row.step_type === 15 || row.step_type === 101) { // Assistant Response - let resp = ''; - if (row.step_type === 101 && payloadObj[114] && typeof payloadObj[114][1] === 'string') { - resp = payloadObj[114][1]; - } else if (payloadObj[20] && typeof payloadObj[20][1] === 'string') { - resp = payloadObj[20][1]; - } - if (resp) { - if (currentReq.responseText) currentReq.responseText += '\n'; - currentReq.responseText += resp; - } +async function sqliteQueryAsync(dbPath: string, sql: string): Promise { + return sqliteExecAsync(dbPath, ['-json', dbPath, sql]); +} - // Extract tokens if available in field 5 -> 9 - if (payloadObj[5] && payloadObj[5][9]) { - const tokens = payloadObj[5][9]; - if (tokens[1]) currentReq.promptTokens = (currentReq.promptTokens || 0) + Number(tokens[1]); - if (tokens[2]) currentReq.completionTokens = (currentReq.completionTokens || 0) + Number(tokens[2]); - } - } else if (row.step_type === 21) { // Tool Call - const toolName = payloadObj[5]?.[4]?.[9] || payloadObj[5]?.[4]?.[2] || ''; - const toolArgsStr = payloadObj[5]?.[4]?.[3] || ''; - if (toolName) { - currentReq.toolsUsed?.push(toolName); - if (toolArgsStr) { - try { - const args = JSON.parse(toolArgsStr); - const filePath = args.AbsolutePath || args.TargetFile || args.file_path || 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 {} - } - } - } else if (row.step_type === 17) { // Error - const errText = payloadObj[24]?.[3]?.[5] || payloadObj[24]?.[3]?.[1] || ''; - if (errText) { - if (currentReq.responseText) currentReq.responseText += '\n'; - currentReq.responseText += `Error: ${errText}`; - } +async function sqliteQueryStepsAsync(dbPath: string): Promise { + try { + const sql = 'SELECT idx, step_type, hex(step_payload) as payload_hex FROM steps ORDER BY idx'; + const raw = await sqliteExecAsync(dbPath, ['-json', dbPath, sql]); + if (!raw.trim()) return []; + return JSON.parse(raw) as StepRow[]; + } catch { + return []; + } +} + +async function parseWorkspaceMetadataAsync(dbPath: string, birthtimeMs: number): Promise { + let workspaceRootPath: string | undefined; + let workspaceName = 'Antigravity Workspace'; + let creationDate = birthtimeMs; + + try { + const rawMeta = await sqliteQueryAsync(dbPath, "SELECT hex(data) as hex_data FROM trajectory_metadata_blob WHERE id = 'main'"); + if (rawMeta.trim()) { + const metaRows = JSON.parse(rawMeta) as { hex_data?: string }[]; + 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; } } } + } catch { + // Keep defaults + } + return { workspaceRootPath, workspaceName, creationDate }; +} + +export async function parseAntigravitySessionsAsync(conversationsDir: string): 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; + } + + 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 meta = await parseWorkspaceMetadataAsync(dbPath, birthtimeMs); + const stepRows = await sqliteQueryStepsAsync(dbPath); + if (stepRows.length === 0) continue; + + const state = { + currentReq: null as SessionRequest | null, + requests: [] as SessionRequest[], + lastMessageDate: meta.creationDate, + }; + + for (const row of stepRows) { + processStepRow(row, sessionId, meta.creationDate, state); + } - if (currentReq) requests.push(currentReq); - if (requests.length === 0) continue; + if (state.currentReq) state.requests.push(state.currentReq); + if (state.requests.length === 0) continue; - // Extract model ID from error details or default to gemini-3.5-flash const modelId = 'gemini-3.5-flash'; - for (const r of requests) { + for (const r of state.requests) { r.modelId = modelId; } const session = createSession({ sessionId, workspaceId: `antigravity-${sessionId}`, - workspaceName, + workspaceName: meta.workspaceName, location: 'terminal', harness: 'Antigravity', - creationDate, - lastMessageDate, - requests, - workspaceRootPath, + creationDate: meta.creationDate, + lastMessageDate: state.lastMessageDate, + requests: state.requests, + workspaceRootPath: meta.workspaceRootPath, }); sessions.push(session); + + // Yield to event loop to keep the process responsive + await new Promise(r => setTimeout(r, 0)); } return sessions; diff --git a/src/core/parser-harnesses.ts b/src/core/parser-harnesses.ts index a40bb875..66297aa8 100644 --- a/src/core/parser-harnesses.ts +++ b/src/core/parser-harnesses.ts @@ -10,7 +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 } from './parser-antigravity'; +import { findAntigravityDirs, parseAntigravitySessions, parseAntigravitySessionsAsync } from './parser-antigravity'; type WorkspaceMap = Map; @@ -77,6 +77,14 @@ const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [ 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); + for (const session of sessions) { + addSession(ctx.workspaces, ctx.sessions, session, agDir); + } + } + }, }, ]; diff --git a/src/core/parser-main.test.ts b/src/core/parser-main.test.ts index 8ea0c081..fa804207 100644 --- a/src/core/parser-main.test.ts +++ b/src/core/parser-main.test.ts @@ -30,6 +30,7 @@ vi.mock('./parser-harnesses', () => ({ vi.mock('./parser-antigravity', () => ({ findAntigravityDirs: vi.fn(() => []), parseAntigravitySessions: vi.fn(() => []), + parseAntigravitySessionsAsync: vi.fn(() => Promise.resolve([])), })); vi.mock('./cache', () => ({ From e4d7dc92cf3e07dcd0ebaee074d1cd1cb08f3963 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 00:16:48 +0300 Subject: [PATCH 09/23] perf: cache resolved directory mappings in Claude workspace path parser --- src/core/parser-claude.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) 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[]; From c94880e23fc05dce7292eb8a6e71315c7bba35a3 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 00:27:42 +0300 Subject: [PATCH 10/23] perf: fix exponential recursion in protobuf decoder and add SQLite busy_timeout --- src/core/parser-antigravity.test.ts | 6 ++-- src/core/parser-antigravity.ts | 45 +++++++++++++++++++---------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/core/parser-antigravity.test.ts b/src/core/parser-antigravity.test.ts index e5e6ad26..32095ea3 100644 --- a/src/core/parser-antigravity.test.ts +++ b/src/core/parser-antigravity.test.ts @@ -51,7 +51,8 @@ describe('Antigravity Discovery & Decoder', () => { // Mock sqlite3 responses vi.mocked(execFileSync).mockImplementation((cmd, args) => { if (cmd === 'sqlite3') { - const sql = (args as string[])?.[2] || ''; + const arr = args as string[]; + const sql = arr?.[arr.length - 1] || ''; if (sql.includes('trajectory_metadata_blob')) { // Proto bytes for trajectory_metadata_blob // Field 7 = "file:///Users/alex/src/swazz", Field 2 -> 1 = 1779825166 @@ -100,7 +101,8 @@ describe('Antigravity Discovery & Decoder', () => { 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 sql = (args as string[])?.[2] || ''; + const arr = args as string[]; + const sql = arr?.[arr.length - 1] || ''; if (sql.includes('trajectory_metadata_blob')) { const metaHex = '3a1c66696c653a2f2f2f55736572732f616c65782f7372632f7377617a7a1206088ef4d7d006'; cb(null, JSON.stringify([{ hex_data: metaHex }]), ''); diff --git a/src/core/parser-antigravity.ts b/src/core/parser-antigravity.ts index 771e4a28..4eaf326f 100644 --- a/src/core/parser-antigravity.ts +++ b/src/core/parser-antigravity.ts @@ -10,7 +10,7 @@ import * as os from 'os'; import { Session, SessionRequest } from './types'; import { assertTrustedPath, createRequest, createSession } from './parser-shared'; -const SQLITE_QUERY_OPTS = { timeout: 30000, killSignal: 'SIGKILL', maxBuffer: 50 * 1024 * 1024, cwd: os.tmpdir() } as const; +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 || ''; @@ -40,7 +40,7 @@ function readVarint(buf: Buffer, offset: { val: number }): number { return result; } -export function decodeProtobuf(buf: Buffer): Record { +export function decodeProtobuf(buf: Buffer, depth = 0): Record { const result: Record = {}; const offset = { val: 0 }; while (offset.val < buf.length) { @@ -71,12 +71,14 @@ export function decodeProtobuf(buf: Buffer): Record { } if (isPrintable && val.length > 0) { result[fieldNum] = str; - } else { + } else if (depth < 4 && val.length <= 16384) { try { - result[fieldNum] = decodeProtobuf(val); + result[fieldNum] = decodeProtobuf(val, depth + 1); } catch { result[fieldNum] = val; } + } else { + result[fieldNum] = val; } } else if (wireType === 5) { if (offset.val + 4 > buf.length) break; @@ -102,7 +104,7 @@ function getRecord(obj: unknown): Record | undefined { function sqliteQuery(dbPath: string, sql: string): string { assertTrustedPath(dbPath); try { - return execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); + return execFileSync('sqlite3', ['-cmd', 'PRAGMA busy_timeout=1000', '-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); } catch { return ''; } @@ -118,9 +120,11 @@ function sqliteQuerySteps(dbPath: string): StepRow[] { assertTrustedPath(dbPath); try { const sql = 'SELECT idx, step_type, hex(step_payload) as payload_hex FROM steps ORDER BY idx'; - const raw = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); - if (!raw.trim()) return []; - return JSON.parse(raw) as StepRow[]; + const raw = execFileSync('sqlite3', ['-cmd', 'PRAGMA busy_timeout=1000', '-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); + const jsonStart = raw.indexOf('['); + const jsonStr = jsonStart !== -1 ? raw.slice(jsonStart) : ''; + if (!jsonStr.trim()) return []; + return JSON.parse(jsonStr) as StepRow[]; } catch { return []; } @@ -139,8 +143,10 @@ function parseWorkspaceMetadata(dbPath: string, birthtimeMs: number): MetadataRe try { const rawMeta = sqliteQuery(dbPath, "SELECT hex(data) as hex_data FROM trajectory_metadata_blob WHERE id = 'main'"); - if (rawMeta.trim()) { - const metaRows = JSON.parse(rawMeta) as { hex_data?: string }[]; + const jsonStart = rawMeta.indexOf('['); + const jsonStr = jsonStart !== -1 ? rawMeta.slice(jsonStart) : ''; + if (jsonStr.trim()) { + const metaRows = JSON.parse(jsonStr) as { hex_data?: string }[]; if (metaRows[0] && metaRows[0].hex_data) { const metaBuf = Buffer.from(metaRows[0].hex_data, 'hex'); const metaObj = decodeProtobuf(metaBuf); @@ -342,8 +348,13 @@ export function parseAntigravitySessions(conversationsDir: string): Session[] { 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', args, { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }, (err, stdout) => { + execFile('sqlite3', newArgs, { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }, (err, stdout) => { if (err) { resolve(''); } else { @@ -361,8 +372,10 @@ async function sqliteQueryStepsAsync(dbPath: string): Promise { try { const sql = 'SELECT idx, step_type, hex(step_payload) as payload_hex FROM steps ORDER BY idx'; const raw = await sqliteExecAsync(dbPath, ['-json', dbPath, sql]); - if (!raw.trim()) return []; - return JSON.parse(raw) as StepRow[]; + const jsonStart = raw.indexOf('['); + const jsonStr = jsonStart !== -1 ? raw.slice(jsonStart) : ''; + if (!jsonStr.trim()) return []; + return JSON.parse(jsonStr) as StepRow[]; } catch { return []; } @@ -375,8 +388,10 @@ async function parseWorkspaceMetadataAsync(dbPath: string, birthtimeMs: number): try { const rawMeta = await sqliteQueryAsync(dbPath, "SELECT hex(data) as hex_data FROM trajectory_metadata_blob WHERE id = 'main'"); - if (rawMeta.trim()) { - const metaRows = JSON.parse(rawMeta) as { hex_data?: string }[]; + const jsonStart = rawMeta.indexOf('['); + const jsonStr = jsonStart !== -1 ? rawMeta.slice(jsonStart) : ''; + if (jsonStr.trim()) { + const metaRows = JSON.parse(jsonStr) as { hex_data?: string }[]; if (metaRows[0] && metaRows[0].hex_data) { const metaBuf = Buffer.from(metaRows[0].hex_data, 'hex'); const metaObj = decodeProtobuf(metaBuf); From a3cd503b8dc54243947818c20ec414a1148f41b6 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 00:38:49 +0300 Subject: [PATCH 11/23] perf: merge metadata and steps queries into a single process execution --- src/core/parser-antigravity.test.ts | 31 +++--- src/core/parser-antigravity.ts | 162 ++++++++++++---------------- 2 files changed, 79 insertions(+), 114 deletions(-) diff --git a/src/core/parser-antigravity.test.ts b/src/core/parser-antigravity.test.ts index 32095ea3..e16c8860 100644 --- a/src/core/parser-antigravity.test.ts +++ b/src/core/parser-antigravity.test.ts @@ -53,21 +53,12 @@ describe('Antigravity Discovery & Decoder', () => { if (cmd === 'sqlite3') { const arr = args as string[]; const sql = arr?.[arr.length - 1] || ''; - if (sql.includes('trajectory_metadata_blob')) { - // Proto bytes for trajectory_metadata_blob - // Field 7 = "file:///Users/alex/src/swazz", Field 2 -> 1 = 1779825166 - // Hex representation + if (sql.includes('trajectory_metadata_blob') && sql.includes('steps')) { const metaHex = '3a1c66696c653a2f2f2f55736572732f616c65782f7372632f7377617a7a1206088ef4d7d006'; - return JSON.stringify([{ hex_data: metaHex }]); - } - if (sql.includes('steps')) { - // Step 0: type 14 (user prompt). Prompt text field 19 -> 2 = "hello prompt" - // Step 1: type 15 (assistant response). Response text field 20 -> 1 = "hello response" - // Step 2: type 21 (tool call). Tool name field 5 -> 4 -> 2 = "write_file", args field 5 -> 4 -> 3 = '{"TargetFile":"/path/to/file"}' const step0Hex = '9a010e120c68656c6c6f2070726f6d7074'; const step1Hex = 'a201100a0e68656c6c6f20726573706f6e7365'; const step2Hex = '2a2e222c120a77726974655f66696c651a1e7b2254617267657446696c65223a222f706174682f746f2f66696c65227d'; - return JSON.stringify([ + 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 }, @@ -103,18 +94,20 @@ describe('Antigravity Discovery & Decoder', () => { 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')) { + if (sql.includes('trajectory_metadata_blob') && sql.includes('steps')) { const metaHex = '3a1c66696c653a2f2f2f55736572732f616c65782f7372632f7377617a7a1206088ef4d7d006'; - cb(null, JSON.stringify([{ hex_data: metaHex }]), ''); - } else if (sql.includes('steps')) { const step0Hex = '9a010e120c68656c6c6f2070726f6d7074'; const step1Hex = 'a201100a0e68656c6c6f20726573706f6e7365'; const step2Hex = '2a2e222c120a77726974655f66696c651a1e7b2254617267657446696c65223a222f706174682f746f2f66696c65227d'; - cb(null, 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 }, - ]), ''); + 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', ''); } diff --git a/src/core/parser-antigravity.ts b/src/core/parser-antigravity.ts index 4eaf326f..9dc1cbdf 100644 --- a/src/core/parser-antigravity.ts +++ b/src/core/parser-antigravity.ts @@ -116,63 +116,58 @@ interface StepRow { payload_hex: string; } -function sqliteQuerySteps(dbPath: string): StepRow[] { - assertTrustedPath(dbPath); - try { - const sql = 'SELECT idx, step_type, hex(step_payload) as payload_hex FROM steps ORDER BY idx'; - const raw = execFileSync('sqlite3', ['-cmd', 'PRAGMA busy_timeout=1000', '-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); - const jsonStart = raw.indexOf('['); - const jsonStr = jsonStart !== -1 ? raw.slice(jsonStart) : ''; - if (!jsonStr.trim()) return []; - return JSON.parse(jsonStr) as StepRow[]; - } catch { - return []; - } -} - interface MetadataResult { workspaceRootPath?: string; workspaceName: string; creationDate: number; } -function parseWorkspaceMetadata(dbPath: string, birthtimeMs: number): MetadataResult { +function decodeMetadataRows(metaRows: { hex_data?: string }[], birthtimeMs: number): MetadataResult { let workspaceRootPath: string | undefined; let workspaceName = 'Antigravity Workspace'; let creationDate = birthtimeMs; - try { - const rawMeta = sqliteQuery(dbPath, "SELECT hex(data) as hex_data FROM trajectory_metadata_blob WHERE id = 'main'"); - const jsonStart = rawMeta.indexOf('['); - const jsonStr = jsonStart !== -1 ? rawMeta.slice(jsonStart) : ''; - if (jsonStr.trim()) { - const metaRows = JSON.parse(jsonStr) as { hex_data?: string }[]; - 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; - } - } + 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; } - } catch { - // Keep defaults } return { workspaceRootPath, workspaceName, creationDate }; } +function parseSqliteMultiResult(stdout: string): string[] { + const results: string[] = []; + let depth = 0; + let startIdx = -1; + for (let i = 0; i < stdout.length; i++) { + if (stdout[i] === '[') { + if (depth === 0) startIdx = i; + depth++; + } else if (stdout[i] === ']') { + depth--; + if (depth === 0 && startIdx !== -1) { + results.push(stdout.slice(startIdx, i + 1)); + startIdx = -1; + } + } + } + return results; +} + function processStepRow( row: StepRow, sessionId: string, @@ -307,8 +302,21 @@ export function parseAntigravitySessions(conversationsDir: string): Session[] { // Keep default } - const meta = parseWorkspaceMetadata(dbPath, birthtimeMs); - const stepRows = sqliteQuerySteps(dbPath); + 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 = { @@ -368,55 +376,6 @@ async function sqliteQueryAsync(dbPath: string, sql: string): Promise { return sqliteExecAsync(dbPath, ['-json', dbPath, sql]); } -async function sqliteQueryStepsAsync(dbPath: string): Promise { - try { - const sql = 'SELECT idx, step_type, hex(step_payload) as payload_hex FROM steps ORDER BY idx'; - const raw = await sqliteExecAsync(dbPath, ['-json', dbPath, sql]); - const jsonStart = raw.indexOf('['); - const jsonStr = jsonStart !== -1 ? raw.slice(jsonStart) : ''; - if (!jsonStr.trim()) return []; - return JSON.parse(jsonStr) as StepRow[]; - } catch { - return []; - } -} - -async function parseWorkspaceMetadataAsync(dbPath: string, birthtimeMs: number): Promise { - let workspaceRootPath: string | undefined; - let workspaceName = 'Antigravity Workspace'; - let creationDate = birthtimeMs; - - try { - const rawMeta = await sqliteQueryAsync(dbPath, "SELECT hex(data) as hex_data FROM trajectory_metadata_blob WHERE id = 'main'"); - const jsonStart = rawMeta.indexOf('['); - const jsonStr = jsonStart !== -1 ? rawMeta.slice(jsonStart) : ''; - if (jsonStr.trim()) { - const metaRows = JSON.parse(jsonStr) as { hex_data?: string }[]; - 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; - } - } - } - } catch { - // Keep defaults - } - return { workspaceRootPath, workspaceName, creationDate }; -} export async function parseAntigravitySessionsAsync(conversationsDir: string): Promise { const sessions: Session[] = []; @@ -447,8 +406,21 @@ export async function parseAntigravitySessionsAsync(conversationsDir: string): P // Keep default } - const meta = await parseWorkspaceMetadataAsync(dbPath, birthtimeMs); - const stepRows = await sqliteQueryStepsAsync(dbPath); + 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 = { From 37d7434d94b52f8144a30b054348b6c67cfb1b08 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 00:44:47 +0300 Subject: [PATCH 12/23] perf: fix offset increment bug and whitelist fields in protobuf decoder --- src/core/parser-antigravity.ts | 96 ++++++++++++++++++++-------------- src/core/parser-harnesses.ts | 4 +- 2 files changed, 59 insertions(+), 41 deletions(-) diff --git a/src/core/parser-antigravity.ts b/src/core/parser-antigravity.ts index 9dc1cbdf..bfe38e73 100644 --- a/src/core/parser-antigravity.ts +++ b/src/core/parser-antigravity.ts @@ -48,41 +48,45 @@ export function decodeProtobuf(buf: Buffer, depth = 0): Record const key = readVarint(buf, offset); const fieldNum = key >> 3; const wireType = key & 0x07; + const shouldDecode = depth > 0 || fieldNum === 1 || fieldNum === 2 || fieldNum === 5 || fieldNum === 7 || fieldNum === 19 || fieldNum === 20 || fieldNum === 24 || fieldNum === 114; + if (wireType === 0) { - result[fieldNum] = readVarint(buf, offset); + const val = readVarint(buf, offset); + if (shouldDecode) result[fieldNum] = val; } else if (wireType === 1) { if (offset.val + 8 > buf.length) break; - result[fieldNum] = buf.subarray(offset.val, offset.val + 8); + 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; - const val = buf.subarray(offset.val, offset.val + len); - offset.val += len; - - const str = val.toString('utf-8'); - let isPrintable = true; - for (let i = 0; i < Math.min(str.length, 100); i++) { - const code = str.charCodeAt(i); - if (code < 32 && code !== 9 && code !== 10 && code !== 13) { - isPrintable = false; - break; + if (shouldDecode) { + const val = buf.subarray(offset.val, offset.val + len); + const str = val.toString('utf-8'); + let isPrintable = true; + for (let i = 0; i < Math.min(str.length, 100); i++) { + const code = str.charCodeAt(i); + if (code < 32 && code !== 9 && code !== 10 && code !== 13) { + isPrintable = false; + break; + } } - } - if (isPrintable && val.length > 0) { - result[fieldNum] = str; - } else if (depth < 4 && val.length <= 16384) { - try { - result[fieldNum] = decodeProtobuf(val, depth + 1); - } catch { + if (isPrintable && val.length > 0) { + result[fieldNum] = str; + } else if (depth < 4 && val.length <= 16384) { + try { + result[fieldNum] = decodeProtobuf(val, depth + 1); + } catch { + result[fieldNum] = val; + } + } else { result[fieldNum] = val; } - } else { - result[fieldNum] = val; } + offset.val += len; } else if (wireType === 5) { if (offset.val + 4 > buf.length) break; - result[fieldNum] = buf.subarray(offset.val, offset.val + 4); + if (shouldDecode) result[fieldNum] = buf.subarray(offset.val, offset.val + 4); offset.val += 4; } else { break; @@ -150,22 +154,20 @@ function decodeMetadataRows(metaRows: { hex_data?: string }[], birthtimeMs: numb } function parseSqliteMultiResult(stdout: string): string[] { - const results: string[] = []; - let depth = 0; - let startIdx = -1; - for (let i = 0; i < stdout.length; i++) { - if (stdout[i] === '[') { - if (depth === 0) startIdx = i; - depth++; - } else if (stdout[i] === ']') { - depth--; - if (depth === 0 && startIdx !== -1) { - results.push(stdout.slice(startIdx, i + 1)); - startIdx = -1; - } - } - } - return results; + 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( @@ -377,7 +379,10 @@ async function sqliteQueryAsync(dbPath: string, sql: string): Promise { } -export async function parseAntigravitySessionsAsync(conversationsDir: string): Promise { +export async function parseAntigravitySessionsAsync( + conversationsDir: string, + onDetail?: (detail: string) => void, +): Promise { const sessions: Session[] = []; if (!fs.existsSync(conversationsDir)) return sessions; @@ -394,7 +399,13 @@ export async function parseAntigravitySessionsAsync(conversationsDir: string): P 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'); @@ -406,6 +417,8 @@ export async function parseAntigravitySessionsAsync(conversationsDir: string): P // 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); @@ -454,6 +467,11 @@ export async function parseAntigravitySessionsAsync(conversationsDir: string): P }); 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)); } diff --git a/src/core/parser-harnesses.ts b/src/core/parser-harnesses.ts index 66297aa8..d0c68050 100644 --- a/src/core/parser-harnesses.ts +++ b/src/core/parser-harnesses.ts @@ -77,9 +77,9 @@ const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [ for (const session of parseAntigravitySessions(agDir)) addSession(ctx.workspaces, ctx.sessions, session, agDir); } }, - async collectAsync(ctx, _reportDetail) { + async collectAsync(ctx, reportDetail) { for (const agDir of findAntigravityDirs()) { - const sessions = await parseAntigravitySessionsAsync(agDir); + const sessions = await parseAntigravitySessionsAsync(agDir, reportDetail); for (const session of sessions) { addSession(ctx.workspaces, ctx.sessions, session, agDir); } From 20de6f1e67c4635e8019baf17efdfd165477ce16 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 08:13:13 +0300 Subject: [PATCH 13/23] feat: improve Antigravity parser precision with path-based filtering and model detection, update model costs, and standardize UI harness colors. --- scratch_db.ts | 7 ++ src/core/constants.ts | 3 +- src/core/parser-antigravity.ts | 161 ++++++++++++++++++++++++++++---- src/core/parser-shared.ts | 2 +- src/core/parser-vscode-files.ts | 6 ++ src/webview/shared.ts | 22 +++-- 6 files changed, 171 insertions(+), 30 deletions(-) create mode 100644 scratch_db.ts diff --git a/scratch_db.ts b/scratch_db.ts new file mode 100644 index 00000000..b21755b4 --- /dev/null +++ b/scratch_db.ts @@ -0,0 +1,7 @@ +import { parseAntigravitySessions } from './src/core/parser-antigravity'; +const sessions = parseAntigravitySessions('/Users/alex/.gemini/antigravity/conversations/'); +for (const s of sessions) { + for (const r of s.requests) { + console.log(`Req: ${r.requestId}, model: ${r.modelId}, promptTokens: ${r.promptTokens}, completionTokens: ${r.completionTokens}`); + } +} diff --git a/src/core/constants.ts b/src/core/constants.ts index c5dd8935..9a1a075d 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -19,7 +19,7 @@ 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, 'grok-code-fast-1': 0.25, 'raptor-mini': 0, 'goldeneye': 1, 'copilot-internal': 0, 'auto': 1, 'custom-model': 1, }; @@ -59,6 +59,7 @@ 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 }, '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/parser-antigravity.ts b/src/core/parser-antigravity.ts index bfe38e73..b1f9dc59 100644 --- a/src/core/parser-antigravity.ts +++ b/src/core/parser-antigravity.ts @@ -8,7 +8,7 @@ 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 } from './parser-shared'; +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; @@ -40,7 +40,32 @@ function readVarint(buf: Buffer, offset: { val: number }): number { return result; } -export function decodeProtobuf(buf: Buffer, depth = 0): Record { +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 === 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 === 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; +} + +export function decodeProtobuf(buf: Buffer, path: number[] = []): Record { const result: Record = {}; const offset = { val: 0 }; while (offset.val < buf.length) { @@ -48,7 +73,9 @@ export function decodeProtobuf(buf: Buffer, depth = 0): Record const key = readVarint(buf, offset); const fieldNum = key >> 3; const wireType = key & 0x07; - const shouldDecode = depth > 0 || fieldNum === 1 || fieldNum === 2 || fieldNum === 5 || fieldNum === 7 || fieldNum === 19 || fieldNum === 20 || fieldNum === 24 || fieldNum === 114; + + const currentPath = [...path, fieldNum]; + const shouldDecode = isFieldAllowed(currentPath); if (wireType === 0) { const val = readVarint(buf, offset); @@ -62,20 +89,20 @@ export function decodeProtobuf(buf: Buffer, depth = 0): Record if (offset.val + len > buf.length) break; if (shouldDecode) { const val = buf.subarray(offset.val, offset.val + len); - const str = val.toString('utf-8'); let isPrintable = true; - for (let i = 0; i < Math.min(str.length, 100); i++) { - const code = str.charCodeAt(i); + 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; } } if (isPrintable && val.length > 0) { - result[fieldNum] = str; - } else if (depth < 4 && val.length <= 16384) { + result[fieldNum] = val.toString('utf-8'); + } else if (currentPath.length < 4 && val.length <= 16384) { try { - result[fieldNum] = decodeProtobuf(val, depth + 1); + result[fieldNum] = decodeProtobuf(val, currentPath); } catch { result[fieldNum] = val; } @@ -174,9 +201,16 @@ function processStepRow( row: StepRow, sessionId: string, creationDate: number, - state: { currentReq: SessionRequest | null; requests: SessionRequest[]; lastMessageDate: 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); @@ -193,7 +227,21 @@ function processStepRow( if (row.step_type === 14) { const p19 = getRecord(payloadObj[19]); const promptText = (p19 && typeof p19[2] === 'string') ? p19[2] : ''; - if (state.currentReq) state.requests.push(state.currentReq); + 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, @@ -206,7 +254,7 @@ function processStepRow( referencedFiles: [], promptTokens: 0, completionTokens: 0, - variableKinds: {}, + variableKinds: imageCount > 0 ? { image: imageCount } : {}, }); } else if (state.currentReq) { handleAssistantOrToolStep(row, payloadObj, state.currentReq); @@ -325,18 +373,19 @@ export function parseAntigravitySessions(conversationsDir: string): Session[] { 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) state.requests.push(state.currentReq); + if (state.currentReq) { finalizeRequest(state.currentReq); state.requests.push(state.currentReq); } if (state.requests.length === 0) continue; - const modelId = 'gemini-3.5-flash'; + const finalModelId = state.modelId || 'gemini-3.5-flash'; for (const r of state.requests) { - r.modelId = modelId; + r.modelId = finalModelId; } const session = createSession({ @@ -440,18 +489,19 @@ export async function parseAntigravitySessionsAsync( 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) state.requests.push(state.currentReq); + if (state.currentReq) { finalizeRequest(state.currentReq); state.requests.push(state.currentReq); } if (state.requests.length === 0) continue; - const modelId = 'gemini-3.5-flash'; + const finalModelId = state.modelId || 'gemini-3.5-flash'; for (const r of state.requests) { - r.modelId = modelId; + r.modelId = finalModelId; } const session = createSession({ @@ -478,3 +528,78 @@ export async function parseAntigravitySessionsAsync( 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-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/webview/shared.ts b/src/webview/shared.ts index 4dc0300d..6c2d805f 100644 --- a/src/webview/shared.ts +++ b/src/webview/shared.ts @@ -342,16 +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', - 'Antigravity': '#d97706', + '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 { From 829aeeea15178de88d5abc00967876262cded6b7 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 08:27:27 +0300 Subject: [PATCH 14/23] style: update Antigravity color to purple in Context Health page --- src/webview/page-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webview/page-config.ts b/src/webview/page-config.ts index 78a054e6..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', 'Antigravity': '#d97706' }; +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 */ From 5a66aeed440548f931132a08bb12f0b4d674d86f Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 08:31:50 +0300 Subject: [PATCH 15/23] chore: remove temporary design docs and plans --- .../plans/2026-06-30-antigravity-analysis.md | 500 ------------------ .../2026-06-30-antigravity-analysis-design.md | 73 --- 2 files changed, 573 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-30-antigravity-analysis.md delete mode 100644 docs/superpowers/specs/2026-06-30-antigravity-analysis-design.md diff --git a/docs/superpowers/plans/2026-06-30-antigravity-analysis.md b/docs/superpowers/plans/2026-06-30-antigravity-analysis.md deleted file mode 100644 index 57a6f41c..00000000 --- a/docs/superpowers/plans/2026-06-30-antigravity-analysis.md +++ /dev/null @@ -1,500 +0,0 @@ -# Antigravity Log Analysis Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Enable offline log analysis of local Antigravity conversations by parsing SQLite `.db` databases. - -**Architecture:** Detect `.db` files from Antigravity dirs, query them via sqlite3 CLI, decode binary Protobuf blobs, map them into Session and SessionRequest types, and register under the Antigravity harness name. - -**Tech Stack:** TypeScript, Node.js (`child_process`), SQLite. - -## Global Constraints - -- OS: macOS -- TypeScript strict mode -- Do not add any new npm runtime dependencies - ---- - -### Task 1: Protobuf Decoder & Directory Discovery - -**Files:** -- Create: `src/core/parser-antigravity.ts` -- Test: `src/core/parser-antigravity.test.ts` - -**Interfaces:** -- Produces: `findAntigravityDirs(): string[]` -- Produces: `decodeProtobuf(buf: Buffer): Record` - -- [ ] **Step 1: Write the failing test for discovery and decoding** - Create `src/core/parser-antigravity.test.ts` with a test that checks directory discovery and raw Protobuf decoding of a mock buffer. - ```typescript - import { describe, it, expect } from 'vitest'; - import { findAntigravityDirs, decodeProtobuf } from './parser-antigravity'; - - describe('Antigravity Discovery & Decoder', () => { - it('should find directories', () => { - const dirs = findAntigravityDirs(); - expect(Array.isArray(dirs)).toBe(true); - }); - - 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'); - }); - }); - ``` - -- [ ] **Step 2: Run test to verify it fails** - Run: `rtk npm test src/core/parser-antigravity.test.ts` - Expected: FAIL with "Cannot find module './parser-antigravity'" - -- [ ] **Step 3: Write minimal implementation** - Create `src/core/parser-antigravity.ts` containing the discovery paths and the zero-dependency Protobuf decoding loop. - ```typescript - import * as fs from 'fs'; - import * as path from 'path'; - - 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; - } - - export function decodeProtobuf(buf: Buffer): 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; - if (wireType === 0) { - result[fieldNum] = readVarint(buf, offset); - } else if (wireType === 1) { - if (offset.val + 8 > buf.length) break; - 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; - const val = buf.subarray(offset.val, offset.val + len); - offset.val += len; - - const str = val.toString('utf-8'); - let isPrintable = true; - for (let i = 0; i < Math.min(str.length, 100); i++) { - const code = str.charCodeAt(i); - if (code < 32 && code !== 9 && code !== 10 && code !== 13) { - isPrintable = false; - break; - } - } - if (isPrintable && val.length > 0) { - result[fieldNum] = str; - } else { - try { - result[fieldNum] = decodeProtobuf(val); - } catch { - result[fieldNum] = val; - } - } - } else if (wireType === 5) { - if (offset.val + 4 > buf.length) break; - result[fieldNum] = buf.subarray(offset.val, offset.val + 4); - offset.val += 4; - } else { - break; - } - } catch { - break; - } - } - return result; - } - ``` - -- [ ] **Step 4: Run test to verify it passes** - Run: `rtk npm test src/core/parser-antigravity.test.ts` - Expected: PASS - -- [ ] **Step 5: Commit** - Run: `rtk git add src/core/parser-antigravity.ts src/core/parser-antigravity.test.ts && rtk git commit -m "feat: add Antigravity discovery and protobuf decoder"` - ---- - -### Task 2: Implement Session Parser & Turn Assembly - -**Files:** -- Modify: `src/core/parser-antigravity.ts` -- Modify: `src/core/parser-antigravity.test.ts` - -**Interfaces:** -- Produces: `parseAntigravitySessions(conversationsDir: string): Session[]` - -- [ ] **Step 1: Write a failing test for parsing databases** - Add a test in `src/core/parser-antigravity.test.ts` that mocks a session parser run on a folder containing a sqlite database (using a mock sqlite query return format). - ```typescript - import { parseAntigravitySessions } from './parser-antigravity'; - // ... inside describe block ... - it('should parse database sessions', () => { - // Requires a mock database file or sqlite wrapper mocking. - // For TDD simplicity, we'll verify it returns an empty array or throws when no sqlite is present. - const sessions = parseAntigravitySessions('/invalid/path'); - expect(sessions).toEqual([]); - }); - ``` - -- [ ] **Step 2: Run test to verify it fails** - Run: `rtk npm test src/core/parser-antigravity.test.ts` - Expected: FAIL with "parseAntigravitySessions is not a function" - -- [ ] **Step 3: Write minimal implementation** - Implement `parseAntigravitySessions` in `src/core/parser-antigravity.ts`. This reads `.db` files in the given directory, queries them via `sqlite3` CLI, decodes the protobuf metadata and step blobs, and maps them to `Session` and `SessionRequest` objects. - ```typescript - import { execFileSync } from 'child_process'; - import * as os from 'os'; - import { Session, SessionRequest } from './types'; - import { assertTrustedPath, createRequest, createSession } from './parser-shared'; - - const SQLITE_QUERY_OPTS = { timeout: 30000, killSignal: 'SIGKILL', maxBuffer: 50 * 1024 * 1024, cwd: os.tmpdir() } as const; - - function sqliteQuery(dbPath: string, sql: string): string { - assertTrustedPath(dbPath); - try { - return execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); - } catch { - return ''; - } - } - - function sqliteQuerySteps(dbPath: string): { idx: number; step_type: number; payload_hex: string }[] { - assertTrustedPath(dbPath); - try { - const sql = 'SELECT idx, step_type, hex(step_payload) as payload_hex FROM steps ORDER BY idx'; - const raw = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf-8', ...SQLITE_QUERY_OPTS }); - if (!raw.trim()) return []; - return JSON.parse(raw); - } catch { - return []; - } - } - - 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; - } - - // Verify sqlite3 is available - 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'); - - // 1. Query metadata - let workspaceRootPath: string | undefined; - let workspaceName = 'Antigravity Workspace'; - let creationDate: number | null = null; - - try { - const rawMeta = sqliteQuery(dbPath, "SELECT hex(data) as hex_data FROM trajectory_metadata_blob WHERE id = 'main'"); - if (rawMeta.trim()) { - const metaRows = JSON.parse(rawMeta); - if (metaRows[0] && metaRows[0].hex_data) { - const metaBuf = Buffer.from(metaRows[0].hex_data, 'hex'); - const metaObj = decodeProtobuf(metaBuf); - if (typeof metaObj[7] === 'string') { - workspaceRootPath = metaObj[7].replace(/^file:\/\//, ''); - } else if (typeof metaObj[1] === 'string') { - const m = metaObj[1].match(/file:\/\/[^\s\u0012\u001a"]+/); - if (m) workspaceRootPath = m[0].replace(/^file:\/\//, ''); - } - if (workspaceRootPath) { - workspaceName = path.basename(workspaceRootPath); - } - if (metaObj[2] && metaObj[2][1]) { - creationDate = Number(metaObj[2][1]) * 1000; - } - } - } - } catch { - // Fallback to filesystem times on metadata error - } - - if (!creationDate) { - try { - const stat = fs.statSync(dbPath); - creationDate = stat.birthtimeMs || stat.mtimeMs; - } catch { - creationDate = Date.now(); - } - } - - // 2. Query steps - const stepRows = sqliteQuerySteps(dbPath); - if (stepRows.length === 0) continue; - - const requests: SessionRequest[] = []; - let currentReq: SessionRequest | null = null; - let lastMessageDate = creationDate; - - for (const row of stepRows) { - if (!row.payload_hex) continue; - const payloadBuf = Buffer.from(row.payload_hex, 'hex'); - const payloadObj = decodeProtobuf(payloadBuf); - - // Extract timestamp if available - let stepTime = creationDate; - if (payloadObj[5] && payloadObj[5][1] && payloadObj[5][1][1]) { - stepTime = Number(payloadObj[5][1][1]) * 1000; - if (stepTime > lastMessageDate) lastMessageDate = stepTime; - } - - if (row.step_type === 14) { // User Prompt - const promptText = payloadObj[19]?.[2] || ''; - if (currentReq) requests.push(currentReq); - currentReq = createRequest({ - requestId: `${sessionId}-${row.idx}`, - timestamp: stepTime, - messageText: promptText, - responseText: '', - agentName: 'Antigravity', - agentMode: 'agent', - toolsUsed: [], - editedFiles: [], - referencedFiles: [], - promptTokens: 0, - completionTokens: 0, - variableKinds: {}, - }); - } else if (currentReq) { - if (row.step_type === 15 || row.step_type === 101) { // Assistant Response - let resp = ''; - if (row.step_type === 101 && payloadObj[114] && typeof payloadObj[114][1] === 'string') { - resp = payloadObj[114][1]; - } else if (payloadObj[20] && typeof payloadObj[20][1] === 'string') { - resp = payloadObj[20][1]; - } - if (resp) { - if (currentReq.responseText) currentReq.responseText += '\n'; - currentReq.responseText += resp; - } - - // Extract tokens if available in field 5 -> 9 - if (payloadObj[5] && payloadObj[5][9]) { - const tokens = payloadObj[5][9]; - if (tokens[1]) currentReq.promptTokens = (currentReq.promptTokens || 0) + Number(tokens[1]); - if (tokens[2]) currentReq.completionTokens = (currentReq.completionTokens || 0) + Number(tokens[2]); - } - } else if (row.step_type === 21) { // Tool Call - const toolName = payloadObj[5]?.[4]?.[9] || payloadObj[5]?.[4]?.[2] || ''; - const toolArgsStr = payloadObj[5]?.[4]?.[3] || ''; - if (toolName) { - currentReq.toolsUsed?.push(toolName); - if (toolArgsStr) { - try { - const args = JSON.parse(toolArgsStr); - const filePath = args.AbsolutePath || args.TargetFile || args.file_path || 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 {} - } - } - } else if (row.step_type === 17) { // Error - const errText = payloadObj[24]?.[3]?.[5] || payloadObj[24]?.[3]?.[1] || ''; - if (errText) { - if (currentReq.responseText) currentReq.responseText += '\n'; - currentReq.responseText += `Error: ${errText}`; - } - } - } - } - - if (currentReq) requests.push(currentReq); - if (requests.length === 0) continue; - - // Extract model ID from error details or default to gemini-3.5-flash - const modelId = 'gemini-3.5-flash'; - for (const r of requests) { - r.modelId = modelId; - } - - const session = createSession({ - sessionId, - workspaceId: `antigravity-${sessionId}`, - workspaceName, - location: 'terminal', - harness: 'Antigravity', - creationDate, - lastMessageDate, - requests, - workspaceRootPath, - }); - sessions.push(session); - } - - return sessions; - } - ``` - -- [ ] **Step 4: Run test to verify it passes** - Run: `rtk npm test src/core/parser-antigravity.test.ts` - Expected: PASS - -- [ ] **Step 5: Commit** - Run: `rtk git add src/core/parser-antigravity.ts src/core/parser-antigravity.test.ts && rtk git commit -m "feat: implement Antigravity sqlite database parser"` - ---- - -### Task 3: Harness Registration & File Indexing Integration - -**Files:** -- Modify: `src/core/parser-harnesses.ts` -- Modify: `src/core/parser.ts` - -**Interfaces:** -- Consumes: `findAntigravityDirs(): string[]` -- Consumes: `parseAntigravitySessions(conversationsDir: string): Session[]` - -- [ ] **Step 1: Write a failing test for harness registration** - Check that the `'Antigravity'` harness is present in `EXTERNAL_HARNESS_SET` inside `src/core/parser-harnesses.ts`. - Modify `src/core/parser-harnesses.ts` to see that it imports and registers Antigravity. - Add a test block in `tests/parser-harnesses.test.ts` (if exists) or we can assert that it is in `EXTERNAL_HARNESS_SET`. - Let's modify `src/core/parser-harnesses.ts` first. - -- [ ] **Step 2: Modify `src/core/parser-harnesses.ts`** - Add the registration of the Antigravity external harness collector. - ```typescript - // Around line 10 - import { findAntigravityDirs, parseAntigravitySessions } from './parser-antigravity'; - - // Inside EXTERNAL_HARNESSES array (around line 72): - { - name: 'Antigravity', - collectSync(ctx) { - for (const agDir of findAntigravityDirs()) { - for (const session of parseAntigravitySessions(agDir)) { - addSession(ctx.workspaces, ctx.sessions, session, agDir); - } - } - } - } - - // Inside EXTERNAL_HARNESS_SET (around line 105): - 'Antigravity' - ``` - -- [ ] **Step 3: Modify `src/core/parser.ts`** - Modify directory discovery and directory partitioning to include Antigravity paths. - ```typescript - // Inside findLogsDirs() around line 97: - export function findLogsDirs(): string[] { - return [...findVsCodeDirs(), ...findXcodeDirs(), ...findAntigravityDirs()]; - } - - // Import findAntigravityDirs around line 16: - import { findAntigravityDirs } from './parser-antigravity'; - - // Inside partitionDirs() around line 101: - 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 if (d.includes('.gemini')) { - // Antigravity dirs belong to external harnesses, so they are not VS Code dirs - } else { - vsCodeDirs.push(d); - } - } - return { vsCodeDirs, xcodeDirs }; - } - ``` - -- [ ] **Step 4: Run typecheck and tests** - Run: `rtk npm run typecheck && rtk npm test` - Expected: PASS - -- [ ] **Step 5: Commit** - Run: `rtk git add src/core/parser-harnesses.ts src/core/parser.ts && rtk git commit -m "feat: register Antigravity external harness and discovery paths"` - ---- - -### Task 4: Webview UI Coloring Integration - -**Files:** -- Modify: `src/webview/shared.ts:344-355` -- Modify: `src/webview/page-config.ts:15-18` - -**Interfaces:** -- Produces: Color mapping for the `'Antigravity'` harness - -- [ ] **Step 1: Modify `src/webview/shared.ts`** - Add `'Antigravity'` to `HARNESS_COLORS`. - ```typescript - 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', - 'Antigravity': '#d97706', - }; - ``` - -- [ ] **Step 2: Modify `src/webview/page-config.ts`** - Add `'Antigravity'` to the `HC` color map. - ```typescript - 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': '#d97706' }; - ``` - -- [ ] **Step 3: Run the build command** - Run: `rtk npm run build` - Expected: PASS (build completes successfully) - -- [ ] **Step 4: Commit** - Run: `rtk git add src/webview/shared.ts src/webview/page-config.ts && rtk git commit -m "feat: add color styling for Antigravity harness in dashboard UI"` diff --git a/docs/superpowers/specs/2026-06-30-antigravity-analysis-design.md b/docs/superpowers/specs/2026-06-30-antigravity-analysis-design.md deleted file mode 100644 index 37ee8431..00000000 --- a/docs/superpowers/specs/2026-06-30-antigravity-analysis-design.md +++ /dev/null @@ -1,73 +0,0 @@ -# Design Specification: Antigravity Log Analysis Support - -This document outlines the technical design for adding log analysis support for **Antigravity** (Google DeepMind's agentic AI coding assistant) into the **AI Engineer Coach** VS Code extension. - -## 1. Goal - -Enable local offline analysis of Antigravity conversations by parsing their SQLite-backed trajectory databases (`.db` files) stored on the user's filesystem, mapping them to the standard `Session` and `SessionRequest` representations, and surfacing them in the extension's dashboard UI under the unified harness name `"Antigravity"`. - -## 2. Directory Discovery - -Antigravity stores its conversations under the user's home directory. The extension will automatically discover conversation logs from the following three locations: -* `~/.gemini/antigravity/conversations` (Antigravity Main) -* `~/.gemini/antigravity-cli/conversations` (Antigravity CLI) -* `~/.gemini/antigravity-ide/conversations` (Antigravity IDE) - -Files with the `.db` extension in these directories will be parsed. Files with the `.pb` extension are encrypted at the OS level and will be skipped. - -## 3. Database Schema & Querying - -Each `.db` file represents a single agent session (trajectory). The parser will use the system's `sqlite3` command-line tool to query: - -1. **Metadata Query**: - ```sql - SELECT data FROM trajectory_metadata_blob WHERE id = 'main' - ``` - This binary Protobuf blob contains workspace root path, repository name, branch, and session-level details. - -2. **Steps Query**: - ```sql - SELECT idx, step_type, hex(step_payload) FROM steps ORDER BY idx - ``` - This returns the trajectory steps. Returning `hex(step_payload)` ensures the binary Protobuf data is safely serialized to a hex string across the process stdout boundary. - -## 4. Protobuf Decoding - -A lightweight, zero-dependency Protobuf decoder will be implemented in TypeScript. It parses the binary buffer recursively using standard varint and length-delimited wire types, automatically decoding printable UTF-8 strings. - -Key field mappings for step payloads (`step_payload`): -* **Step Type 14 (User Prompt)**: - * Prompt Text: field `19` -> `2` (string) - * Timestamp: field `5` -> `1` -> `1` (varint, Unix seconds) -* **Step Type 15 / 101 (Assistant Response)**: - * Response Text: field `20` -> `1` (or `20` -> `8` as fallback) - * If `101` (Message): field `114` -> `1` (contains formatted message string) or `114` -> `4` -> `4` - * Timestamp: field `5` -> `1` -> `1` (varint, Unix seconds) - * Token usage (in field `5` -> `9`): - * Prompt tokens: field `1` (varint) - * Output tokens: field `2` (varint) - * Thinking/reasoning tokens: field `3` (varint) -* **Step Type 21 (Tool Call)**: - * Tool Name: field `5` -> `4` -> `9` or `5` -> `4` -> `2` - * Tool Arguments (JSON string): field `5` -> `4` -> `3`. We will parse this JSON to extract `editedFiles` (e.g. from `write_file`) and `referencedFiles` (e.g. from `view_file`). -* **Step Type 17 (Error/Status)**: - * Error message / JSON details: field `24` -> `3` -> `5` or `24` -> `3` -> `1`. We will parse this to check for model IDs (e.g., `gemini-3.5-flash-low`). - -## 5. UI Integration - -* **Harness Color**: A distinct orange/amber color (`#d97706`) will be registered for `"Antigravity"` in `src/webview/shared.ts` (`HARNESS_COLORS`) and `src/webview/page-config.ts` (`HC`). -* **Model Breakdown**: If model IDs are detected (e.g. `gemini-3.5-flash`), they will be populated on `SessionRequest` objects, enabling correct model distribution breakdown on the dashboard. - -## 6. Verification Plan - -### Automated Tests -We will add `src/core/parser-antigravity.test.ts` to cover: -* Directory discovery logic. -* Protobuf decoder decoding of varints and length-delimited fields. -* Turn assembly and session mapping from sample SQLite records. -* Integration checking inside `parser-harnesses.ts`. - -### Manual Verification -* Build the extension using `npm run build`. -* Run `npm test` to verify unit tests. -* Open the Dashboard inside VS Code to verify that local Antigravity sessions are correctly loaded, colored, and aggregated. From 895fa4a9388bf73bf74be15b5370047639a77208 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 08:32:42 +0300 Subject: [PATCH 16/23] test: add unit tests for Antigravity model detection, skills usage, and image extraction --- src/core/parser-antigravity.test.ts | 93 ++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/src/core/parser-antigravity.test.ts b/src/core/parser-antigravity.test.ts index e16c8860..4de96bc6 100644 --- a/src/core/parser-antigravity.test.ts +++ b/src/core/parser-antigravity.test.ts @@ -3,7 +3,7 @@ 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 } from './parser-antigravity'; +import { findAntigravityDirs, decodeProtobuf, parseAntigravitySessions, parseAntigravitySessionsAsync, extractAntigravityImages } from './parser-antigravity'; vi.mock('child_process', async () => { const original = await vi.importActual('child_process'); @@ -21,6 +21,7 @@ vi.mock('fs', async () => { existsSync: vi.fn(), readdirSync: vi.fn(), statSync: vi.fn(), + readFileSync: vi.fn(), }; }); @@ -125,5 +126,95 @@ describe('Antigravity Discovery & Decoder', () => { 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 modelBuf = Buffer.from('model: claude-3-5-sonnet', 'utf-8'); + const payload0 = Buffer.concat([ + Buffer.from([0x9a, 0x01, promptBuf.length]), + promptBuf, + modelBuf, + ]); + + const step0Hex = payload0.toString('hex'); + + const respBuf = Buffer.from('done brainstorming', 'utf-8'); + const payload1 = Buffer.concat([ + Buffer.from([0xa2, 0x01, respBuf.length]), + respBuf, + ]); + 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 images = extractAntigravityImages('/path/to/session.db', 'session-1-123'); + expect(images.length).toBe(1); + expect(images[0]).toBe('data:image/png;base64,ZmFrZS1pbWFnZS1ieXRlcw=='); }); }); From c134b41bb49593b8c204f8eda385f2bd6056b0ff Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 08:33:18 +0300 Subject: [PATCH 17/23] fix: add nested protobuf parsing support for prompt and attachments in Antigravity parser, update unit tests --- src/core/parser-antigravity.test.ts | 16 ++++++++++++---- src/core/parser-antigravity.ts | 3 ++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/core/parser-antigravity.test.ts b/src/core/parser-antigravity.test.ts index 4de96bc6..d50fbb26 100644 --- a/src/core/parser-antigravity.test.ts +++ b/src/core/parser-antigravity.test.ts @@ -143,20 +143,28 @@ describe('Antigravity Discovery & Decoder', () => { 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, promptBuf.length]), - promptBuf, + Buffer.from([0x9a, 0x01, p2Buf.length]), + p2Buf, modelBuf, ]); const step0Hex = payload0.toString('hex'); const respBuf = Buffer.from('done brainstorming', 'utf-8'); - const payload1 = Buffer.concat([ - Buffer.from([0xa2, 0x01, respBuf.length]), + 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([ diff --git a/src/core/parser-antigravity.ts b/src/core/parser-antigravity.ts index b1f9dc59..6e30eb44 100644 --- a/src/core/parser-antigravity.ts +++ b/src/core/parser-antigravity.ts @@ -47,7 +47,7 @@ function isFieldAllowed(path: number[]): boolean { if (path.length === 2) { const p0 = path[0], p1 = path[1]; if (p0 === 2) return p1 === 1; - if (p0 === 5) return p1 === 1 || p1 === 4 || p1 === 9; + 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; @@ -57,6 +57,7 @@ function isFieldAllowed(path: number[]): boolean { 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; From 301c25016160dbeafb7e5b7cc873a1df2cb1b338 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 08:33:30 +0300 Subject: [PATCH 18/23] chore: remove temporary scratch file scratch_db.ts --- scratch_db.ts | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 scratch_db.ts diff --git a/scratch_db.ts b/scratch_db.ts deleted file mode 100644 index b21755b4..00000000 --- a/scratch_db.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { parseAntigravitySessions } from './src/core/parser-antigravity'; -const sessions = parseAntigravitySessions('/Users/alex/.gemini/antigravity/conversations/'); -for (const s of sessions) { - for (const r of s.requests) { - console.log(`Req: ${r.requestId}, model: ${r.modelId}, promptTokens: ${r.promptTokens}, completionTokens: ${r.completionTokens}`); - } -} From d9fd1dad3052402b9704e2d138d0880098d2e1ea Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 08:34:26 +0300 Subject: [PATCH 19/23] fix: resolve test failures for Antigravity protobuf and image extraction --- src/core/parser-antigravity.test.ts | 3 ++- src/core/parser-antigravity.ts | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/core/parser-antigravity.test.ts b/src/core/parser-antigravity.test.ts index d50fbb26..6850a204 100644 --- a/src/core/parser-antigravity.test.ts +++ b/src/core/parser-antigravity.test.ts @@ -221,7 +221,8 @@ describe('Antigravity Discovery & Decoder', () => { return '3.41.0'; }); - const images = extractAntigravityImages('/path/to/session.db', 'session-1-123'); + 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 index 6e30eb44..de3a3dd2 100644 --- a/src/core/parser-antigravity.ts +++ b/src/core/parser-antigravity.ts @@ -66,6 +66,19 @@ function isFieldAllowed(path: number[]): boolean { 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 }; @@ -99,7 +112,7 @@ export function decodeProtobuf(buf: Buffer, path: number[] = []): Record 0) { + if (!isNestedMessage(currentPath) && isPrintable && val.length > 0) { result[fieldNum] = val.toString('utf-8'); } else if (currentPath.length < 4 && val.length <= 16384) { try { From 3044cf9f18af0dcbf926a85377c20d951aa903ad Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 08:34:54 +0300 Subject: [PATCH 20/23] fix: correct protobuf empty-record decoding fallback and fix test db path --- src/core/parser-antigravity.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/core/parser-antigravity.ts b/src/core/parser-antigravity.ts index de3a3dd2..39c2d7f6 100644 --- a/src/core/parser-antigravity.ts +++ b/src/core/parser-antigravity.ts @@ -112,14 +112,23 @@ export function decodeProtobuf(buf: Buffer, path: number[] = []): Record 0) { - result[fieldNum] = val.toString('utf-8'); - } else if (currentPath.length < 4 && val.length <= 16384) { + + let decoded: Record | null = null; + if (isNestedMessage(currentPath) && currentPath.length < 4 && val.length <= 16384) { try { - result[fieldNum] = decodeProtobuf(val, currentPath); + const rec = decodeProtobuf(val, currentPath); + if (Object.keys(rec).length > 0) { + decoded = rec; + } } catch { - result[fieldNum] = val; + // Ignore } + } + + if (decoded !== null) { + result[fieldNum] = decoded; + } else if (isPrintable && val.length > 0) { + result[fieldNum] = val.toString('utf-8'); } else { result[fieldNum] = val; } From a440558f3c5560bd28270d5b8ad86539134ed4a8 Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 08:43:10 +0300 Subject: [PATCH 21/23] fix: add support and correct cost/multiplier lookup for Antigravity model variants --- src/core/constants.ts | 5 ++++- src/core/dsl/interpreter.ts | 3 ++- src/core/helpers.test.ts | 26 +++++++++++++++++++++++++- src/core/helpers.ts | 17 ++++++++++++++--- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/core/constants.ts b/src/core/constants.ts index 9a1a075d..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.5-flash': 0.33, + '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, }; @@ -60,6 +61,8 @@ export const MODEL_TOKEN_RATES: Record = { '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..14082f01 100644 --- a/src/core/dsl/interpreter.ts +++ b/src/core/dsl/interpreter.ts @@ -278,8 +278,9 @@ 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': 0.33, 'gemini-3.1-flash': 0.33, '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, + 'gpt-oss-120b': 0.5, 'grok-code-fast-1': 0.25, }; 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..f058beac 100644 --- a/src/core/helpers.ts +++ b/src/core/helpers.ts @@ -116,7 +116,7 @@ export function normalizeModel(modelId: string): string { 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). @@ -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); From 770214a5eb648c07ce295fc1597f165f373e7e5f Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 08:46:51 +0300 Subject: [PATCH 22/23] feat: preserve model suffixes in charts, tables and legend --- src/core/analyzer-consumption.ts | 10 +++++----- src/core/analyzer-production.ts | 4 ++-- src/core/helpers.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) 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/helpers.ts b/src/core/helpers.ts index f058beac..74e4099a 100644 --- a/src/core/helpers.ts +++ b/src/core/helpers.ts @@ -111,7 +111,7 @@ 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; } @@ -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(); From c7afa9c03990958a30fa40bcc08718964a1f6dce Mon Sep 17 00:00:00 2001 From: SecH0us3 Date: Wed, 1 Jul 2026 08:56:05 +0300 Subject: [PATCH 23/23] feat: explicitly support all Antigravity suffix model variants in MODEL_TIERS --- src/core/dsl/interpreter.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/core/dsl/interpreter.ts b/src/core/dsl/interpreter.ts index 14082f01..aceb6321 100644 --- a/src/core/dsl/interpreter.ts +++ b/src/core/dsl/interpreter.ts @@ -278,15 +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.5-flash': 0.33, 'gemini-3.1-flash': 0.33, '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, - 'gpt-oss-120b': 0.5, + '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;