diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index 4438c043..c308c5fc 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -13,7 +13,7 @@ CodeBurn discovers Antigravity sessions from local directories on disk, then que 1. **Session Discovery:** It scans the following folders for `.pb` or `.db` files: - **Antigravity CLI:** `%USERPROFILE%\.gemini\antigravity-cli\conversations` (and `implicit`) - **Antigravity App/older path:** `%USERPROFILE%\.gemini\antigravity\conversations` - - **Antigravity IDE (VSCode-based):** `%USERPROFILE%\.gemini\antigravity-ide\conversations` (and `implicit`) + - **Antigravity IDE:** `%USERPROFILE%\.gemini\antigravity-ide\conversations` (and `implicit`). The IDE also maintains VSCode-style global state at `%APPDATA%\Antigravity IDE\User\globalStorage\state.vscdb`, but that DB stores trajectory metadata (titles, timestamps, workspace paths) — not token usage. Token usage data still comes from the `.db` conversation files. 2. **Language Server RPC Query:** It locates the active language-server process via `ps` on POSIX or `Get-CimInstance Win32_Process` on Windows. It extracts the port and CSRF token from the process arguments, and queries the local HTTPS RPC endpoint `GetCascadeTrajectoryGeneratorMetadata` to parse the session. 3. **Cache Fallback:** If the language server is not running, it falls back to the local results cache. diff --git a/package-lock.json b/package-lock.json index d7b693f1..e649491f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeburn", - "version": "0.9.12", + "version": "0.9.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeburn", - "version": "0.9.12", + "version": "0.9.15", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/src/main.ts b/src/main.ts index 10b4f632..79f87007 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1496,9 +1496,10 @@ program try { if (action === 'install') { const result = await installAntigravityStatusLineHook(!!opts.force) - console.log(result === 'already-installed' - ? '\n Antigravity CLI usage capture is already installed.\n' - : '\n Antigravity CLI usage capture installed.\n') + const headline = result === 'already-installed' + ? 'Antigravity CLI usage capture is already installed.' + : 'Antigravity CLI usage capture installed.' + console.log(`\n ${headline}\n Note: this captures CLI (agy) sessions only. IDE sessions are read from .db files automatically.\n`) return } if (action === 'uninstall') { diff --git a/src/providers/antigravity.ts b/src/providers/antigravity.ts index 710b4bf7..b8a85d08 100644 --- a/src/providers/antigravity.ts +++ b/src/providers/antigravity.ts @@ -16,34 +16,39 @@ type AntigravityConversationRoot = { extensions: readonly string[] } -const CONVERSATION_ROOTS: readonly AntigravityConversationRoot[] = [ - { - dir: join(homedir(), '.gemini', 'antigravity', 'conversations'), - project: 'antigravity', - extensions: ['.pb', '.db'], - }, - { - dir: join(homedir(), '.gemini', 'antigravity-cli', 'conversations'), - project: 'antigravity-cli', - extensions: ['.pb', '.db'], - }, - { - dir: join(homedir(), '.gemini', 'antigravity-cli', 'implicit'), - project: 'antigravity-cli', - extensions: ['.pb'], - }, - { - dir: join(homedir(), '.gemini', 'antigravity-ide', 'conversations'), - project: 'antigravity-ide', - extensions: ['.pb', '.db'], - }, - { - dir: join(homedir(), '.gemini', 'antigravity-ide', 'implicit'), - project: 'antigravity-ide', - extensions: ['.pb'], - }, -] as const -const CACHE_VERSION = 2 +// Computed on each call rather than frozen at module load so discovery honors +// the current home directory (env overrides in tests, and any runtime change). +function conversationRoots(): readonly AntigravityConversationRoot[] { + const home = homedir() + return [ + { + dir: join(home, '.gemini', 'antigravity', 'conversations'), + project: 'antigravity', + extensions: ['.pb', '.db'], + }, + { + dir: join(home, '.gemini', 'antigravity-cli', 'conversations'), + project: 'antigravity-cli', + extensions: ['.pb', '.db'], + }, + { + dir: join(home, '.gemini', 'antigravity-cli', 'implicit'), + project: 'antigravity-cli', + extensions: ['.pb'], + }, + { + dir: join(home, '.gemini', 'antigravity-ide', 'conversations'), + project: 'antigravity-ide', + extensions: ['.pb', '.db'], + }, + { + dir: join(home, '.gemini', 'antigravity-ide', 'implicit'), + project: 'antigravity-ide', + extensions: ['.pb'], + }, + ] +} +const CACHE_VERSION = 5 const RPC_TIMEOUT_MS = 5000 const MAX_RESPONSE_BYTES = 16 * 1024 * 1024 @@ -690,6 +695,40 @@ function antigravitySqliteModel(chatFields: readonly ProtoField[]): string { return getCanonicalModelId(rawModel, displayName) } +// Decode a proto field that carries a time into an ISO-8601 string. Antigravity +// may encode ChatStartMetadata.created_at as an ISO string, a Timestamp +// submessage (seconds in field 1), or a bare unix varint. Returns '' when the +// field is absent or unparseable so the caller can fall back. +function protoTimestampToIso(field: ProtoField | undefined): string { + if (!field) return '' + const text = protoFieldText(field) + if (text && !Number.isNaN(Date.parse(text))) return new Date(text).toISOString() + if (field.bytes) { + // google.protobuf.Timestamp submessage: seconds (#1), nanos (#2). + const tsFields = parseProtoFields(field.bytes) + const seconds = firstProtoField(tsFields, 1)?.value + if (seconds !== undefined) { + const nanos = firstProtoField(tsFields, 2)?.value ?? 0n + const ms = Number(seconds) * 1000 + Math.floor(Number(nanos) / 1e6) + if (Number.isSafeInteger(ms) && ms > 0) return new Date(ms).toISOString() + } + } + if (field.value !== undefined) { + const raw = Number(field.value) + const ms = raw < 1e12 ? raw * 1000 : raw + if (Number.isSafeInteger(ms) && ms > 0) return new Date(ms).toISOString() + } + return '' +} + +// ChatStartMetadata lives at chatModel(#1).#9; its created_at is #4. Not every +// gen_metadata row carries it, so this returns '' when missing. +function antigravitySqliteCreatedAt(chatFields: readonly ProtoField[]): string { + const metadataBytes = protoFieldBytes(firstProtoField(chatFields, 9)) + if (!metadataBytes) return '' + return protoTimestampToIso(firstProtoField(parseProtoFields(metadataBytes), 4)) +} + function buildCallFromSqliteGenMetadataRow(cascadeId: string, row: AntigravityGenMetadataRow): ParsedProviderCall | null { const rootFields = parseProtoFields(genMetadataDataBytes(row.data)) const chatFields = parseProtoFields(protoFieldBytes(firstProtoField(rootFields, 1)) ?? new Uint8Array()) @@ -729,7 +768,7 @@ function buildCallFromSqliteGenMetadataRow(cascadeId: string, row: AntigravityGe costUSD, tools: [], bashCommands: [], - timestamp: '', + timestamp: antigravitySqliteCreatedAt(chatFields), speed: 'standard', deduplicationKey: `antigravity:${cascadeId}:${responseId}`, userMessage: '', @@ -880,11 +919,14 @@ export function isAntigravityStatusLineEventsPath(path: string): boolean { } export async function discoverAntigravitySessionSources( - roots: readonly AntigravityConversationRoot[] = CONVERSATION_ROOTS, + roots?: readonly AntigravityConversationRoot[], ): Promise { - const includeStatusLineEvents = roots === CONVERSATION_ROOTS + // The statusline JSONL is a synthetic source only appended for the real + // default roots, not when a caller passes an explicit (test) root set. + const includeStatusLineEvents = roots === undefined + const effectiveRoots = roots ?? conversationRoots() const sources: SessionSource[] = [] - for (const root of roots) { + for (const root of effectiveRoots) { let files: string[] try { files = await readdir(root.dir) @@ -1134,10 +1176,12 @@ export async function snapshotAntigravityStatusLinePayload(input: unknown): Prom metadata = extractAntigravityGeneratorMetadata( await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }), ) + const snapshotCalls = buildCallsFromGeneratorMetadata(cascadeId, metadata, modelMap) + assignStableTimestamps(snapshotCalls, cached?.calls, new Date(s.mtimeMs).toISOString()) cache.cascades[cascadeId] = { mtimeMs: s.mtimeMs, sizeBytes: s.size, - calls: buildCallsFromGeneratorMetadata(cascadeId, metadata, modelMap), + calls: snapshotCalls, } cacheDirty = true await flushCache() @@ -1197,6 +1241,41 @@ function applyAntigravityProject(call: ParsedProviderCall, source: SessionSource call.project = source.project } +// gen_metadata rows and RPC entries without a real ChatStartMetadata.created_at +// carry no per-call timestamp. Left empty, those calls are dropped by the +// date-range filters in parser.ts (`if (!callTs) continue`), so each needs a +// fallback. The fallback must be *stable* across file rewrites: the generic +// session-cache persists whatever timestamp is emitted, and a non-durable +// source is cleared and reparsed whenever its mtime changes, so stamping the +// current mtime on every reparse would retro-date the whole session forward. +// +// assignStableTimestamps carries forward the timestamp already recorded for a +// dedup key (its first-seen time, held in the durable Antigravity cache) and +// only falls back to the current file mtime for genuinely new calls. Real +// timestamps (created_at) are preserved untouched. This runs on the fresh-parse +// paths whose result is written back to the cache. +function assignStableTimestamps( + calls: ParsedProviderCall[], + priorCalls: readonly ParsedProviderCall[] | undefined, + firstSeenTimestamp: string, +): void { + const priorByKey = new Map() + for (const prior of priorCalls ?? []) { + if (prior.timestamp) priorByKey.set(prior.deduplicationKey, prior.timestamp) + } + for (const call of calls) { + if (call.timestamp) continue + call.timestamp = priorByKey.get(call.deduplicationKey) ?? firstSeenTimestamp + } +} + +// Emit-time safety net for cache-hit / cached-fallback paths, where the calls +// already carry stable timestamps from a prior parse. Applied to a copy so the +// cache is never mutated; only fills a still-empty timestamp defensively. +function withFallbackTimestamp(call: ParsedProviderCall, fallbackTimestamp: string): ParsedProviderCall { + return call.timestamp ? call : { ...call, timestamp: fallbackTimestamp } +} + function createParser(source: SessionSource, seenKeys: Set): SessionParser { return { async *parse(): AsyncGenerator { @@ -1215,6 +1294,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars if (!s) return const projectPath = await extractWorkspacePath(source.path) + const fallbackTimestamp = new Date(s.mtimeMs).toISOString() const cached = cache.cascades[cascadeId] if (cached && cached.mtimeMs === s.mtimeMs && cached.sizeBytes === s.size && cached.calls.length > 0) { @@ -1222,13 +1302,14 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars applyAntigravityProject(call, source, projectPath) if (seenKeys.has(call.deduplicationKey)) continue seenKeys.add(call.deduplicationKey) - yield call + yield withFallbackTimestamp(call, fallbackTimestamp) } return } const sqliteResults = await parseSqliteGenMetadataCalls(source.path, cascadeId) if (sqliteResults.length > 0) { + assignStableTimestamps(sqliteResults, cached?.calls, fallbackTimestamp) for (const call of sqliteResults) { applyAntigravityProject(call, source, projectPath) } @@ -1255,7 +1336,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars applyAntigravityProject(call, source, projectPath) if (seenKeys.has(call.deduplicationKey)) continue seenKeys.add(call.deduplicationKey) - yield call + yield withFallbackTimestamp(call, fallbackTimestamp) } } return @@ -1274,13 +1355,14 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars applyAntigravityProject(call, source, projectPath) if (seenKeys.has(call.deduplicationKey)) continue seenKeys.add(call.deduplicationKey) - yield call + yield withFallbackTimestamp(call, fallbackTimestamp) } } return } const results = buildCallsFromGeneratorMetadata(cascadeId, metadata, modelMap) + assignStableTimestamps(results, cached?.calls, fallbackTimestamp) for (const call of results) { applyAntigravityProject(call, source, projectPath) } diff --git a/src/session-cache.ts b/src/session-cache.ts index abfb41c8..79424112 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -118,7 +118,7 @@ const PROVIDER_PARSE_VERSIONS: Record = { 'kilo-code': 'worktree-project-grouping-v1', 'roo-code': 'worktree-project-grouping-v1', warp: 'worktree-project-grouping-v1', - antigravity: 'worktree-project-grouping-v3', + antigravity: 'worktree-project-grouping-v5', } // ── Cache Dir ────────────────────────────────────────────────────────── diff --git a/tests/parser-antigravity-timestamp.test.ts b/tests/parser-antigravity-timestamp.test.ts new file mode 100644 index 00000000..72a32feb --- /dev/null +++ b/tests/parser-antigravity-timestamp.test.ts @@ -0,0 +1,152 @@ +import { mkdir, mkdtemp, readFile, rm, utimes } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { createRequire } from 'node:module' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { getDateRange } from '../src/cli-date.js' +import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { isSqliteAvailable } from '../src/sqlite.js' +import type { DateRange } from '../src/types.js' + +const requireForTest = createRequire(import.meta.url) + +type Fixture = { conversationId: string; rows: Array<{ idx: number; hex: string }> } +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +function createGenMetadataDb(dbPath: string, fixture: Fixture): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) as TestDb + try { + db.exec('CREATE TABLE gen_metadata (idx integer, data blob, size integer NOT NULL DEFAULT 0, PRIMARY KEY (idx))') + db.exec('CREATE TABLE trajectory_metadata_blob (id text DEFAULT "main", data blob, PRIMARY KEY (id))') + db.prepare('INSERT INTO trajectory_metadata_blob (id, data) VALUES (?, ?)').run( + 'main', + Buffer.from('file:///Users/example/private-project'), + ) + for (const row of fixture.rows) { + const data = Buffer.from(row.hex, 'hex') + db.prepare('INSERT INTO gen_metadata (idx, data, size) VALUES (?, ?, ?)').run(row.idx, data, data.length) + } + } finally { + db.close() + } +} + +async function cachedAntigravityTurns(cacheDir: string, dbPath: string): Promise> { + const saved = JSON.parse(await readFile(join(cacheDir, 'session-cache.json'), 'utf-8')) as { + providers: Record }> }> + } + return saved.providers['antigravity']?.files[dbPath]?.turns ?? [] +} + +let home: string +let cacheDir: string +let previousHome: string | undefined +let previousUserProfile: string | undefined +let previousCacheDir: string | undefined + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-ts-home-')) + cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-ts-cache-')) + previousHome = process.env['HOME'] + previousUserProfile = process.env['USERPROFILE'] + previousCacheDir = process.env['CODEBURN_CACHE_DIR'] + // os.homedir() reads HOME on POSIX and USERPROFILE on Windows — set both so + // discovery walks the temp home on either platform. + process.env['HOME'] = home + process.env['USERPROFILE'] = home + process.env['CODEBURN_CACHE_DIR'] = cacheDir +}) + +afterEach(async () => { + clearSessionCache() + if (previousHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = previousHome + if (previousUserProfile === undefined) delete process.env['USERPROFILE'] + else process.env['USERPROFILE'] = previousUserProfile + if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir + await rm(home, { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) +}) + +describe('Antigravity timestamp stability across .db rewrites', () => { + it('keeps a timeless call\'s first-seen timestamp when only the file mtime changes', async () => { + if (!isSqliteAvailable()) return + + const fixture = JSON.parse(await readFile( + new URL('./fixtures/antigravity-cli-current/gen-metadata.json', import.meta.url), + 'utf-8', + )) as Fixture + const conversationsDir = join(home, '.gemini', 'antigravity-ide', 'conversations') + await mkdir(conversationsDir, { recursive: true }) + const dbPath = join(conversationsDir, `${fixture.conversationId}.db`) + createGenMetadataDb(dbPath, fixture) + + // The fixture row carries no ChatStartMetadata.created_at, so the call + // inherits the file mtime as its first-seen fallback. Pin it to January. + const firstSeen = new Date('2026-01-02T03:04:05.000Z') + await utimes(dbPath, firstSeen, firstSeen) + + const wideRange: DateRange = { + start: new Date('2026-01-01T00:00:00.000Z'), + end: new Date('2026-12-31T23:59:59.999Z'), + } + + // First parse — through the generic parser + session-cache. + await parseAllSessions(wideRange, 'antigravity') + const firstTurns = await cachedAntigravityTurns(cacheDir, dbPath) + expect(firstTurns.length).toBeGreaterThan(0) + const firstTs = firstTurns[0]!.timestamp + expect(Math.abs(new Date(firstTs).getTime() - firstSeen.getTime())).toBeLessThan(2000) + + // Cache hit — unchanged fingerprint, identical timestamp. + clearSessionCache() + await parseAllSessions(wideRange, 'antigravity') + expect((await cachedAntigravityTurns(cacheDir, dbPath))[0]!.timestamp).toBe(firstTs) + + // Rewrite only the mtime to a much later day, then reparse. The non-durable + // source is cleared and reparsed, but the dedup key must retain its + // first-seen time rather than jumping to the new mtime. + clearSessionCache() + const rewritten = new Date('2026-06-07T08:09:10.000Z') + await utimes(dbPath, rewritten, rewritten) + await parseAllSessions(wideRange, 'antigravity') + const afterRewrite = await cachedAntigravityTurns(cacheDir, dbPath) + expect(afterRewrite[0]!.timestamp).toBe(firstTs) + expect(Math.abs(new Date(afterRewrite[0]!.timestamp).getTime() - rewritten.getTime())).toBeGreaterThan(1000) + + // Date-range consequence: a January-only range still includes the call + // after the June rewrite, because its timestamp stayed in January. + clearSessionCache() + const januaryRange: DateRange = { + start: new Date('2026-01-01T00:00:00.000Z'), + end: new Date('2026-01-31T23:59:59.999Z'), + } + const januaryProjects = await parseAllSessions(januaryRange, 'antigravity') + const januaryKeys = januaryProjects.flatMap(project => + project.sessions.flatMap(session => + session.turns.flatMap(turn => turn.assistantCalls.map(call => call.deduplicationKey)), + ), + ) + expect(januaryKeys.length).toBeGreaterThan(0) + + // `today` must NOT include the call: first-seen is January, not "now", + // even though the file mtime was rewritten to June (and wall-clock is later). + clearSessionCache() + const { range: todayRange } = getDateRange('today') + const todayProjects = await parseAllSessions(todayRange, 'antigravity') + const todayKeys = todayProjects.flatMap(project => + project.sessions.flatMap(session => + session.turns.flatMap(turn => turn.assistantCalls.map(call => call.deduplicationKey)), + ), + ) + expect(todayKeys).toHaveLength(0) + }) +}) diff --git a/tests/providers/antigravity.test.ts b/tests/providers/antigravity.test.ts index e98fd591..42e64680 100644 --- a/tests/providers/antigravity.test.ts +++ b/tests/providers/antigravity.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises' +import { mkdtemp, mkdir, readFile, rm, stat, utimes, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' import { createRequire } from 'node:module' @@ -605,4 +605,120 @@ describe('antigravity provider helpers', () => { await rm(tempHome, { recursive: true, force: true }) } }) + + async function withTempAntigravityHome(prefix: string, fn: (tempHome: string) => Promise): Promise { + const tempHome = await mkdtemp(join(tmpdir(), prefix)) + const previousCacheDir = process.env['CODEBURN_CACHE_DIR'] + process.env['CODEBURN_CACHE_DIR'] = join(tempHome, 'cache') + try { + await fn(tempHome) + } finally { + if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir + await rm(tempHome, { recursive: true, force: true }) + } + } + + it('stamps file mtime as fallback timestamp for SQLite-parsed calls', async () => { + if (!isSqliteAvailable()) return + + await withTempAntigravityHome('codeburn-antigravity-timestamp-', async (tempHome) => { + const fixture = JSON.parse(await readFile( + new URL('../fixtures/antigravity-cli-current/gen-metadata.json', import.meta.url), + 'utf-8', + )) as CurrentCliFixture + const conversationsDir = join(tempHome, '.gemini', 'antigravity-ide', 'conversations') + + await mkdir(conversationsDir, { recursive: true }) + + const dbPath = join(conversationsDir, `${fixture.conversationId}.db`) + createCurrentAntigravityCliDb(dbPath, fixture) + + const beforeStat = await stat(dbPath) + + const parser = createAntigravityProvider().createSessionParser({ + path: dbPath, + project: 'antigravity-ide', + provider: 'antigravity', + }, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + + expect(calls.length).toBeGreaterThan(0) + for (const call of calls) { + expect(call.timestamp).not.toBe('') + const callTime = new Date(call.timestamp).getTime() + expect(Math.abs(callTime - beforeStat.mtimeMs)).toBeLessThan(5000) + } + }) + }) + + it('decodes ChatStartMetadata.created_at and prefers it over the file mtime', async () => { + if (!isSqliteAvailable()) return + + await withTempAntigravityHome('codeburn-antigravity-createdat-', async (tempHome) => { + // Encode a gen_metadata blob matching the real on-disk shape: + // GeneratorMetadata.chatModel(#1) { + // usage(#4) { input(#2), totalOutput(#3) } + // chatStartMetadata(#9) { created_at(#4): Timestamp { seconds(#1), nanos(#2) } } + // } + const varint = (n: number): number[] => { + const out: number[] = [] + let v = n + while (v > 0x7f) { out.push((v & 0x7f) | 0x80); v = Math.floor(v / 128) } + out.push(v) + return out + } + const tag = (field: number, wire: number): number[] => varint(field * 8 + wire) + const varintField = (field: number, n: number): number[] => [...tag(field, 0), ...varint(n)] + const lenField = (field: number, bytes: number[]): number[] => [...tag(field, 2), ...varint(bytes.length), ...bytes] + + const seconds = 1783326234 + const nanos = 724675400 + const timestamp = [...varintField(1, seconds), ...varintField(2, nanos)] + const chatStartMetadata = lenField(4, timestamp) + const usage = lenField(4, [...varintField(2, 100), ...varintField(3, 50)]) + const chatModel = [...usage, ...lenField(9, chatStartMetadata)] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const conversationsDir = join(tempHome, '.gemini', 'antigravity-ide', 'conversations') + await mkdir(conversationsDir, { recursive: true }) + const dbPath = join(conversationsDir, 'created-at-session.db') + createCurrentAntigravityCliDb(dbPath, { conversationId: 'created-at-session', rows: [{ idx: 0, hex }] }) + + // Pin the file mtime to a different day so a wrong fallback is obvious. + const mtime = new Date('2026-01-01T00:00:00.000Z') + await utimes(dbPath, mtime, mtime) + + const calls = await collectAntigravityCalls({ path: dbPath, project: 'antigravity-ide', provider: 'antigravity' }) + + expect(calls.length).toBe(1) + // The real created_at (July), not the January file mtime. + expect(calls[0]!.timestamp).toBe('2026-07-06T08:23:54.724Z') + }) + }) + + it('classifies paths by their .gemini root, not by the profile directory name', () => { + expect(antigravityAppDataDirFromSourcePath( + '/Users/User/.gemini/antigravity-ide/conversations/abc.db', + )).toBe('antigravity-ide') + + expect(antigravityAppDataDirFromSourcePath( + '/Users/User/.gemini/antigravity-cli/conversations/abc.db', + )).toBe('antigravity-cli') + + expect(antigravityAppDataDirFromSourcePath( + '/Users/User/.gemini/antigravity/conversations/abc.db', + )).toBe('antigravity') + + // A profile directory literally named "Antigravity IDE" must not override + // the .gemini root: these are CLI and base-app paths, not IDE paths. + expect(antigravityAppDataDirFromSourcePath( + 'C:\\Users\\Antigravity IDE\\.gemini\\antigravity-cli\\conversations\\abc.db', + )).toBe('antigravity-cli') + + expect(antigravityAppDataDirFromSourcePath( + 'C:\\Users\\Antigravity IDE\\.gemini\\antigravity\\conversations\\abc.db', + )).toBe('antigravity') + }) })