From d4907f3082abb349dcd7be9cb4d8a73a9ddd0f47 Mon Sep 17 00:00:00 2001 From: Justin Jeong Date: Tue, 18 Aug 2026 10:43:29 +0900 Subject: [PATCH 1/7] feat(antigravity): support multi-root discovery for Antigravity 2.0 and IDE --- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- .../Core/LocalAntigravityUsageReader.swift | 65 ++++++++++++++----- .../AntigravityUsageTests.swift | 48 ++++++++++++-- 5 files changed, 95 insertions(+), 24 deletions(-) diff --git a/README.ja.md b/README.ja.md index 74ebddc88..6aec72817 100644 --- a/README.ja.md +++ b/README.ja.md @@ -158,7 +158,7 @@ swift test # ユニットテスト |---|---|---| | `~/.claude/projects/**/*.jsonl` | Claude Code daily/blocks/weekly/monthly | 直接読み取り;メッセージ id で重複排除;増分キャッシュ | | `~/.gemini/tmp/**/chats/*.json(l)` | Gemini CLI daily/monthly | セッションレコード(メッセージ別 `tokens`);週間 = daily 合算 | -| `~/.gemini/antigravity-cli/conversations/*.db` | Antigravity daily/blocks/weekly/monthly | SQLite 読み取り専用;Cascade protobuf blob の呼び出し単位の使用量;Gemini には合算しない独立プロバイダ;サブスクのためコストは推定しない | +| `~/.gemini/antigravity/conversations/*.db`
`~/.gemini/antigravity-cli/conversations/*.db`
`~/.gemini/antigravity-ide/conversations/*.db` | Antigravity daily/blocks/weekly/monthly | SQLite 読み取り専用;Cascade protobuf blob の呼び出し単位の使用量;Antigravity 2.0/Core, CLI, IDE をサポート;Gemini には合算しない独立プロバイダ;サブスクのためコストは推定しない | | `~/.codex/sessions/**/*.jsonl` | Codex daily/monthly | `token_count` イベント;週間 = daily 合算 | | `~/.local/share/opencode/opencode.db` | OpenCode daily/blocks/weekly/monthly | SQLite 読み取り専用;レガシー `storage/message` JSON にも対応 | | `~/.hermes/state.db` | Hermes Agent daily/blocks/weekly/monthly | SQLite 読み取り専用;セッショントークン合計と保存済みコスト | diff --git a/README.ko.md b/README.ko.md index 0d764bc83..c395074fe 100644 --- a/README.ko.md +++ b/README.ko.md @@ -158,7 +158,7 @@ swift test # 단위 테스트 |---|---|---| | `~/.claude/projects/**/*.jsonl` | Claude Code daily/blocks/weekly/monthly | 직접 읽음; 메시지 id 로 중복제거; 증분 캐시 | | `~/.gemini/tmp/**/chats/*.json(l)` | Gemini CLI daily/monthly | 세션 레코드(메시지별 `tokens`); 주간 = daily 합산 | -| `~/.gemini/antigravity-cli/conversations/*.db` | Antigravity daily/blocks/weekly/monthly | SQLite 읽기 전용; Cascade protobuf blob 의 호출별 사용량; Gemini 에 합산하지 않는 별도 프로바이더; 구독제라 비용은 추정하지 않음 | +| `~/.gemini/antigravity/conversations/*.db`
`~/.gemini/antigravity-cli/conversations/*.db`
`~/.gemini/antigravity-ide/conversations/*.db` | Antigravity daily/blocks/weekly/monthly | SQLite 읽기 전용; Cascade protobuf blob 의 호출별 사용량; Antigravity 2.0/Core, CLI, IDE 모두 지원; Gemini 에 합산하지 않는 별도 프로바이더; 구독제라 비용은 추정하지 않음 | | `~/.codex/sessions/**/*.jsonl` | Codex daily/monthly | `token_count` 이벤트; 주간 = daily 합산 | | `~/.local/share/opencode/opencode.db` | OpenCode daily/blocks/weekly/monthly | SQLite 읽기 전용; 레거시 `storage/message` JSON도 지원 | | `~/.hermes/state.db` | Hermes Agent daily/blocks/weekly/monthly | SQLite 읽기 전용; 세션 토큰 합계와 저장된 비용 | diff --git a/README.md b/README.md index 5facab9ab..9eec23bfb 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ swift test # unit tests |---|---|---| | `~/.claude/projects/**/*.jsonl` | Claude Code daily/blocks/weekly/monthly | read directly; deduped by message id; cached incrementally | | `~/.gemini/tmp/**/chats/*.json(l)` | Gemini CLI daily/monthly | session records (`tokens` per message); weekly = daily sum | -| `~/.gemini/antigravity-cli/conversations/*.db` | Antigravity daily/blocks/weekly/monthly | SQLite read-only; per-call usage from the Cascade protobuf blob; its own provider, not folded into Gemini; a subscription, so no cost is estimated | +| `~/.gemini/antigravity/conversations/*.db`
`~/.gemini/antigravity-cli/conversations/*.db`
`~/.gemini/antigravity-ide/conversations/*.db` | Antigravity daily/blocks/weekly/monthly | SQLite read-only; per-call usage from the Cascade protobuf blob; supports Antigravity 2.0/Core, CLI & IDE; its own provider, not folded into Gemini; a subscription, so no cost is estimated | | `~/.codex/sessions/**/*.jsonl` | Codex daily/monthly | `token_count` events; weekly = daily sum | | `~/.local/share/opencode/opencode.db` | OpenCode daily/blocks/weekly/monthly | SQLite read-only; legacy `storage/message` JSON is also supported | | `~/.hermes/state.db` | Hermes Agent daily/blocks/weekly/monthly | SQLite read-only; session token totals and persisted cost | diff --git a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift index 18c91a850..bd2ce90d3 100644 --- a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift @@ -31,21 +31,36 @@ import SQLite3 /// populated counters — which is exactly the identity `LocalUsageReader.Entry` already keeps. enum LocalAntigravityUsageReader { - /// One database per conversation. The directory is absent unless Antigravity CLI ran. + /// Known conversation directories across Antigravity editions (2.0/Core, CLI, IDE). + /// The directory is absent unless the respective Antigravity flavor ran. + static var defaultRoots: [URL] { + let home = FileManager.default.homeDirectoryForCurrentUser + return [ + home.appendingPathComponent(".gemini/antigravity/conversations"), + home.appendingPathComponent(".gemini/antigravity-cli/conversations"), + home.appendingPathComponent(".gemini/antigravity-ide/conversations"), + ] + } + + /// Primary default directory for single-root callers and backwards compatibility. static var defaultRoot: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".gemini/antigravity-cli/conversations") + defaultRoots[0] } /// Usage rows whose `created_at` falls at or after `modifiedSince`. - static func entries(modifiedSince: Date, root: URL? = nil) -> [LocalUsageReader.Entry] { - let scanned = scan(root: root ?? defaultRoot, modifiedSince: modifiedSince, known: [:]) + static func entries(modifiedSince: Date, roots: [URL]? = nil) -> [LocalUsageReader.Entry] { + let scanned = scan(roots: roots ?? defaultRoots, modifiedSince: modifiedSince, known: [:]) // The one place the side effects live. `AppLog.write` returns early outside the bundled // app, so the decisions above it are kept pure and tested on their own. for line in scanned.log { AppLog.write(line) } return assemble(scanned.blobs, since: modifiedSince) } + /// Single-root overload for backwards compatibility and tests. + static func entries(modifiedSince: Date, root: URL?) -> [LocalUsageReader.Entry] { + entries(modifiedSince: modifiedSince, roots: root.map { [$0] } ?? defaultRoots) + } + /// One conversation store's rows, valid for as long as its `(mtime, size)` hold. The rows /// are deliberately *unfiltered*: the window is applied after the lookup, so one blob serves /// both the daily call and the enrichment call — the contract `LocalUsageCache.Blob` keeps, @@ -68,14 +83,14 @@ enum LocalAntigravityUsageReader { LocalUsageReader.dedupKeepMax(blobs.values.flatMap(\.entries).filter { $0.date >= since }) } - /// Reads every conversation store the window admits, reusing any blob in `known` whose - /// signature still matches. `known` is empty for a one-shot read. - static func scan(root: URL, modifiedSince: Date, known: [String: Blob]) -> Scan { + /// Reads every conversation store the window admits across roots, reusing any blob in `known` + /// whose signature still matches. `known` is empty for a one-shot read. + static func scan(roots: [URL], modifiedSince: Date, known: [String: Blob]) -> Scan { let formatter = LocalUsageReader.localDayFormatter() var blobs: [String: Blob] = [:] var reads: [(conversation: String, read: ConversationRead)] = [] - for database in databases(in: root) { + for database in databases(in: roots) { // Stat before the read, never after. A commit that lands mid-read then differs from // the stored signature on the next sweep and is re-read; stat afterwards and that // same commit is frozen into a signature that already looks current. @@ -107,6 +122,11 @@ enum LocalAntigravityUsageReader { return Scan(blobs: blobs, log: lossLog(reads) + discardLog(reads)) } + /// Single-root scan overload. + static func scan(root: URL, modifiedSince: Date, known: [String: Blob]) -> Scan { + scan(roots: [root], modifiedSince: modifiedSince, known: known) + } + // MARK: Database discovery /// The cache key for one conversation store, and the same value the scan window is tested @@ -133,9 +153,17 @@ enum LocalAntigravityUsageReader { return newest.map { ($0, size) } } + private static func databases(in roots: [URL]) -> [URL] { + var results: [URL] = [] + for root in roots { + guard let names = try? FileManager.default.contentsOfDirectory(atPath: root.path) else { continue } + results.append(contentsOf: names.filter { $0.hasSuffix(".db") }.sorted().map { root.appendingPathComponent($0) }) + } + return results + } + private static func databases(in root: URL) -> [URL] { - guard let names = try? FileManager.default.contentsOfDirectory(atPath: root.path) else { return [] } - return names.filter { $0.hasSuffix(".db") }.sorted().map { root.appendingPathComponent($0) } + databases(in: [root]) } // MARK: Reading one conversation @@ -495,13 +523,18 @@ enum AntigravityProto { actor LocalAntigravityUsageCache { static let shared = LocalAntigravityUsageCache() - private let root: URL? + private let roots: [URL]? private let now: @Sendable () -> Date private var blobs: [String: LocalAntigravityUsageReader.Blob] = [:] private var inFlight: Task? - init(root: URL? = nil, now: @escaping @Sendable () -> Date = Date.init) { - self.root = root + init(roots: [URL]? = nil, now: @escaping @Sendable () -> Date = Date.init) { + self.roots = roots + self.now = now + } + + init(root: URL?, now: @escaping @Sendable () -> Date = Date.init) { + self.roots = root.map { [$0] } self.now = now } @@ -518,12 +551,12 @@ actor LocalAntigravityUsageCache { // Join rather than start a second scan, and leave the log to the owner. scanned = await inFlight.value } else { - let root = self.root ?? LocalAntigravityUsageReader.defaultRoot + let targetRoots = self.roots ?? LocalAntigravityUsageReader.defaultRoots let known = blobs // Nothing awaits between the miss above and this assignment — that is what stops two // callers from both starting a scan. let task = Task.detached(priority: .utility) { - LocalAntigravityUsageReader.scan(root: root, modifiedSince: since, known: known) + LocalAntigravityUsageReader.scan(roots: targetRoots, modifiedSince: since, known: known) } inFlight = task scanned = await task.value diff --git a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift index 71aa5d3fb..057f1232a 100644 --- a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift +++ b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift @@ -514,13 +514,50 @@ final class AntigravityUsageTests: XCTestCase { /// Someone who has never run Antigravity must get silence rather than a zero: throwing /// colours the whole refresh as an error, and a zero raises a tab for a tool they don't use. func testProviderIsSilentWithoutAnyConversationStore() async throws { - guard !FileManager.default.fileExists(atPath: LocalAntigravityUsageReader.defaultRoot.path) else { + let hasAnyStore = LocalAntigravityUsageReader.defaultRoots.contains { + FileManager.default.fileExists(atPath: $0.path) + } + guard !hasAnyStore else { throw XCTSkip("this machine has Antigravity conversation stores — the absent path cannot be exercised") } let daily = try await LocalAntigravityProvider().fetchDaily() XCTAssertNil(daily) } + func testDefaultRootsContainsAllKnownLocations() { + let paths = LocalAntigravityUsageReader.defaultRoots.map(\.path) + XCTAssertTrue(paths.contains { $0.hasSuffix(".gemini/antigravity/conversations") }) + XCTAssertTrue(paths.contains { $0.hasSuffix(".gemini/antigravity-cli/conversations") }) + XCTAssertTrue(paths.contains { $0.hasSuffix(".gemini/antigravity-ide/conversations") }) + XCTAssertEqual(LocalAntigravityUsageReader.defaultRoot, LocalAntigravityUsageReader.defaultRoots[0]) + } + + func testMultiRootScanDiscoversAndDeduplicatesAcrossDirectories() throws { + let rootA = temporaryDirectory.appendingPathComponent("rootA") + let rootB = temporaryDirectory.appendingPathComponent("rootB") + try FileManager.default.createDirectory(at: rootA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: rootB, withIntermediateDirectories: true) + + let stamp = try date("2026-03-04T10:00:00Z") + + // rootA has unique entry r1, and duplicate r_shared + try writeConversation("c1", at: rootA, records: [ + record(responseID: "r1", model: "gemini-3.6-flash", createdAt: stamp, input: 100, output: 50, cacheRead: 0), + record(responseID: "r_shared", model: "gemini-3.6-flash", createdAt: stamp, input: 200, output: 100, cacheRead: 0), + ]) + + // rootB has unique entry r2, and duplicate r_shared + try writeConversation("c2", at: rootB, records: [ + record(responseID: "r2", model: "gemini-3.6-flash", createdAt: stamp, input: 300, output: 150, cacheRead: 0), + record(responseID: "r_shared", model: "gemini-3.6-flash", createdAt: stamp, input: 200, output: 100, cacheRead: 0), + ]) + + let entries = LocalAntigravityUsageReader.entries(modifiedSince: .distantPast, roots: [rootA, rootB]) + let ids = Set(entries.map(\.id)) + XCTAssertEqual(ids, ["antigravity|r1", "antigravity|r2", "antigravity|r_shared"]) + XCTAssertEqual(entries.count, 3, "duplicate r_shared must collapse to a single entry") + } + // MARK: - Fixtures private func readAll() -> [LocalUsageReader.Entry] { @@ -614,8 +651,8 @@ final class AntigravityUsageTests: XCTestCase { return Data(AntigravityProto.encodeMessage(field: 1, chatModel)) } - private func writeConversation(_ name: String, records: [Data], walMode: Bool = false) throws { - try writeConversation(name, blobs: records, walMode: walMode) + private func writeConversation(_ name: String, at root: URL? = nil, records: [Data], walMode: Bool = false) throws { + try writeConversation(name, at: root, blobs: records, walMode: walMode) } /// A store spanning several pages, so damaging the last one still leaves earlier rows @@ -674,9 +711,10 @@ final class AntigravityUsageTests: XCTestCase { } } - private func writeConversation(_ name: String, blobs: [Data], walMode: Bool = false, + private func writeConversation(_ name: String, at root: URL? = nil, blobs: [Data], walMode: Bool = false, pageSize: Int? = nil) throws { - let database = temporaryDirectory.appendingPathComponent("\(name).db") + let directory = root ?? temporaryDirectory! + let database = directory.appendingPathComponent("\(name).db") // `page_size` only takes effect before the first table exists. var sql = pageSize.map { "PRAGMA page_size=\($0);\n" } ?? "" sql += walMode ? "PRAGMA journal_mode=WAL;\n" : "" From f347ff79ee1f41897e2b53eb141266ce88930cad Mon Sep 17 00:00:00 2001 From: Justin Jeong Date: Tue, 18 Aug 2026 10:50:02 +0900 Subject: [PATCH 2/7] docs: update doc comments to reflect multi-root Antigravity discovery --- .../PokeTokenBar/Core/LocalAntigravityUsageReader.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift index bd2ce90d3..c630855a7 100644 --- a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift @@ -1,8 +1,10 @@ import Foundation import SQLite3 -/// Antigravity CLI usage, read from the conversation stores the CLI writes under -/// `~/.gemini/antigravity-cli/conversations/.db`. +/// Antigravity usage, read from the conversation stores written under +/// `~/.gemini/antigravity/conversations/.db`, +/// `~/.gemini/antigravity-cli/conversations/.db`, or +/// `~/.gemini/antigravity-ide/conversations/.db`. /// /// Antigravity shares the `~/.gemini/` parent directory with Gemini CLI and nothing else. /// Where Gemini CLI appends JSON lines, Antigravity keeps one SQLite database per @@ -183,7 +185,7 @@ enum LocalAntigravityUsageReader { /// other than the table being absent. case unreadable(status: Int32?) /// No `gen_metadata`: this file is not a conversation store. A permanent, legitimate - /// empty — `~/.gemini/antigravity-cli/conversations/` may hold databases we don't read. + /// empty — the candidate directories may hold databases we don't read. case notAConversation var entries: [LocalUsageReader.Entry] { From fe0bfcfdbd20ee3daabb5302f72437da0609581f Mon Sep 17 00:00:00 2001 From: Justin Jeong Date: Tue, 18 Aug 2026 10:58:31 +0900 Subject: [PATCH 3/7] fix: prevent MainActor isolation crash on notification authorization request --- Sources/PokeTokenBar/Core/UsageStore.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index 23b6dd40e..79fe09d18 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -815,7 +815,9 @@ final class UsageStore { guard !notifAuthRequested else { return } guard AppEnv.isBundledApp else { return } notifAuthRequested = true - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + Task { + try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) + } } /// 한도 알림 1건의 발화 지시(순수 판정 결과). 부수효과와 분리해 테스트 가능하게. From 2e7de3f57cfa197fc373ee75bbea12e60e44e390 Mon Sep 17 00:00:00 2001 From: Justin Jeong Date: Tue, 18 Aug 2026 16:37:31 +0900 Subject: [PATCH 4/7] fix: fallback to database mtime when Protobuf created_at is absent in Antigravity 2.0 --- .../Core/LocalAntigravityUsageReader.swift | 6 ++-- .../AntigravityUsageTests.swift | 35 ++++++++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift index c630855a7..fb8e1bd95 100644 --- a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift @@ -266,6 +266,7 @@ enum LocalAntigravityUsageReader { } defer { sqlite3_finalize(statement) } + let fallbackDate = signature(of: database)?.mtime ?? Date() let conversation = database.deletingPathExtension().lastPathComponent var entries: [LocalUsageReader.Entry] = [] var discardedCounters = 0 @@ -281,7 +282,7 @@ enum LocalAntigravityUsageReader { guard count > 0 else { return } let blob = Data(bytes: pointer, count: count) let record = parseGenerationMetadata( - blob, conversation: conversation, index: index, formatter: formatter) + blob, conversation: conversation, index: index, fallbackDate: fallbackDate, formatter: formatter) discardedCounters += record.discardedCounters guard let entry = record.entry else { return } entries.append(entry) @@ -335,12 +336,13 @@ enum LocalAntigravityUsageReader { _ blob: Data, conversation: String, index: Int64, + fallbackDate: Date? = nil, formatter: DateFormatter ) -> Record { let bytes = [UInt8](blob) guard let chatModel = AntigravityProto.message(bytes[...], field: 1), let usage = AntigravityProto.message(chatModel, field: 4), - let date = createdAt(chatModel) else { return Record() } + let date = createdAt(chatModel) ?? fallbackDate else { return Record() } // The turn's own id, not the file it happens to sit in — a copied conversation must // not read as fresh spend. `response_id` is populated on every recorded call. diff --git a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift index 057f1232a..3d5737bc3 100644 --- a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift +++ b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift @@ -620,11 +620,35 @@ final class AntigravityUsageTests: XCTestCase { input: input, output: output, cacheRead: cacheRead) } + /// Antigravity 2.0 / IDE sessions may omit `chat_start_metadata.created_at` from the protobuf. + /// The reader should fallback to the database file's mtime so tokens are not dropped. + func testConversationWithoutProtobufCreatedAtUsesFileMtimeFallback() throws { + let recordWithoutTimestamp = makeRecord( + responseID: "r_modern_1", + model: "gemini-3.7-flash", + createdAtSeconds: nil, + input: 500, + output: 100, + cacheRead: 2000 + ) + try writeConversation("c_modern", records: [recordWithoutTimestamp]) + + let entries = readAll() + XCTAssertEqual(entries.count, 1) + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.id, "antigravity|r_modern_1") + XCTAssertEqual(entry.input, 500) + XCTAssertEqual(entry.output, 100) + XCTAssertEqual(entry.cacheRead, 2000) + XCTAssertEqual(entry.total, 2600) + XCTAssertEqual(entry.model, "antigravity/gemini-3.7-flash") + } + /// `CortexStepGeneratorMetadata { 1 chat_model { 4 usage, 9 chat_start_metadata, 19 response_model } }` private func makeRecord( responseID: String?, model: String, - createdAtSeconds: UInt64, + createdAtSeconds: UInt64? = nil, input: UInt64, output: UInt64, cacheRead: UInt64, @@ -640,12 +664,13 @@ final class AntigravityUsageTests: XCTestCase { if let response { usage += AntigravityProto.encodeVarint(field: 10, response) } if let responseID { usage += AntigravityProto.encodeString(field: 11, responseID) } - let timestamp = AntigravityProto.encodeVarint(field: 1, createdAtSeconds) - let chatStart = AntigravityProto.encodeMessage(field: 4, timestamp) - var chatModel = AntigravityProto.encodeVarint(field: 3, 1071) chatModel += AntigravityProto.encodeMessage(field: 4, usage) - chatModel += AntigravityProto.encodeMessage(field: 9, chatStart) + if let createdAtSeconds { + let timestamp = AntigravityProto.encodeVarint(field: 1, createdAtSeconds) + let chatStart = AntigravityProto.encodeMessage(field: 4, timestamp) + chatModel += AntigravityProto.encodeMessage(field: 9, chatStart) + } chatModel += AntigravityProto.encodeString(field: 19, model) return Data(AntigravityProto.encodeMessage(field: 1, chatModel)) From 221360c5e84fa78c9d30937df714dc06bad61d0f Mon Sep 17 00:00:00 2001 From: Justin Jeong Date: Tue, 18 Aug 2026 16:39:32 +0900 Subject: [PATCH 5/7] fix: robustly distinguish absent Protobuf timestamp from invalid sentinels --- .../Core/LocalAntigravityUsageReader.swift | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift index fb8e1bd95..24a2ed5de 100644 --- a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift @@ -341,8 +341,18 @@ enum LocalAntigravityUsageReader { ) -> Record { let bytes = [UInt8](blob) guard let chatModel = AntigravityProto.message(bytes[...], field: 1), - let usage = AntigravityProto.message(chatModel, field: 4), - let date = createdAt(chatModel) ?? fallbackDate else { return Record() } + let usage = AntigravityProto.message(chatModel, field: 4) else { return Record() } + + let date: Date + switch createdAt(chatModel) { + case .valid(let d): + date = d + case .absent: + guard let fallbackDate else { return Record() } + date = fallbackDate + case .invalid: + return Record() + } // The turn's own id, not the file it happens to sit in — a copied conversation must // not read as fresh spend. `response_id` is populated on every recorded call. @@ -371,16 +381,24 @@ enum LocalAntigravityUsageReader { discardedCounters: [input, output, cacheWrite, cacheRead].filter { $0 == nil }.count) } + private enum ParsedDate { + case valid(Date) + case absent + case invalid + } + /// `chat_start_metadata.created_at`, a `google.protobuf.Timestamp`. - private static func createdAt(_ chatModel: ArraySlice) -> Date? { + private static func createdAt(_ chatModel: ArraySlice) -> ParsedDate { guard let start = AntigravityProto.message(chatModel, field: 9), - let stamp = AntigravityProto.message(start, field: 4), - let seconds = AntigravityProto.varint(stamp, field: 1) else { return nil } - // A malformed varint can carry the whole uint64 range; a date built from it would - // overflow downstream arithmetic. Anything outside a plausible window is not a time. - guard seconds >= 1_000_000_000, seconds <= 4_102_444_800 else { return nil } + let stamp = AntigravityProto.message(start, field: 4) else { + return .absent + } + guard let seconds = AntigravityProto.varint(stamp, field: 1), + seconds >= 1_000_000_000, seconds <= 4_102_444_800 else { + return .invalid + } let nanos = AntigravityProto.varint(stamp, field: 2).map { $0 < 1_000_000_000 ? Double($0) : 0 } ?? 0 - return Date(timeIntervalSince1970: Double(seconds) + nanos / 1_000_000_000) + return .valid(Date(timeIntervalSince1970: Double(seconds) + nanos / 1_000_000_000)) } private static func makeEntry( From 64db421b42640a1cb726d8bf58be5134793761ac Mon Sep 17 00:00:00 2001 From: Jihan <2jihan000@gmail.com> Date: Wed, 19 Aug 2026 12:22:29 +0900 Subject: [PATCH 6/7] fix: date Antigravity 2.0 records from their own execution steps The store's mtime is one value for every record it holds, so a conversation that ran across midnight files its earlier spend under the day it was last written, and that date moves again every time the store grows. Measured on ten 2.0-format stores: 11,523,909 tokens belonging to 2026-08-18 were reported under 2026-08-19, and the day they belong to read zero. Antigravity 2.0 still names the execution each record belongs to (`execution_id`, field 4), and the `steps` table of the same store holds per-step `created_at`, `finished_at` and `execution_id`. Dating each record from its own execution lands within ~2 minutes of the writer's own value, measured against the stores that still carry `created_at`. The join is on `execution_id` rather than on the row index, which is its own dense sequence and drifts from `steps.idx` by up to 21 minutes inside one conversation. Step times are handed out in order within an execution so a long execution is followed as it runs. The store's mtime stays as the last resort for a record no step can date. Co-Authored-By: Claude Opus 5 (1M context) --- .../Core/LocalAntigravityUsageReader.swift | 135 ++++++++++++- .../AntigravityUsageTests.swift | 180 ++++++++++++++++-- 2 files changed, 297 insertions(+), 18 deletions(-) diff --git a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift index 24a2ed5de..7756c23ac 100644 --- a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift @@ -26,8 +26,24 @@ import SQLite3 /// 1.4.5 cache_read_tokens prompt cache hit /// 1.4.11 response_id globally unique per call /// 1.9 chat_start_metadata exa.cortex_pb.ChatStartMetadata -/// 1.9.4 created_at google.protobuf.Timestamp +/// 1.9.4 created_at google.protobuf.Timestamp, absent since Antigravity 2.0 /// 1.19 response_model e.g. "gemini-3.6-flash" +/// 4 execution_id the execution these tokens were spent in +/// +/// Records written by Antigravity 2.0 carry `chat_start_metadata` with `created_at` removed, and +/// nothing else in the blob is a time. The date for those comes from the `steps` table of the +/// same store, whose `metadata` is an `exa.cortex_pb.StepMetadata`: +/// +/// steps.metadata exa.cortex_pb.StepMetadata +/// 1 created_at google.protobuf.Timestamp — when the step was queued +/// 8 finished_at google.protobuf.Timestamp — absent while the step is running +/// 12 execution_id matches `gen_metadata.data` field 4 +/// +/// The join is on `execution_id`, not on `idx`: `gen_metadata.idx` is its own dense sequence and +/// drifts from `steps.idx` by minutes within a single conversation. Against the stores that still +/// carry `created_at`, dating each record from the step timings of its own execution lands within +/// ~2 minutes of the writer's own value, where the store's mtime — one value for every record it +/// holds — moves a conversation's earlier days onto the day it was last written. /// /// There is no total field anywhere in the schema, so the total is the sum of the three /// populated counters — which is exactly the identity `LocalUsageReader.Entry` already keeps. @@ -257,7 +273,8 @@ enum LocalAntigravityUsageReader { var statement: OpaquePointer? let prepared = sqlite3_prepare_v2( - handle, "SELECT idx, data FROM gen_metadata WHERE data IS NOT NULL", -1, &statement, nil) + handle, "SELECT idx, data FROM gen_metadata WHERE data IS NOT NULL ORDER BY idx", + -1, &statement, nil) guard prepared == SQLITE_OK, let statement else { // `SQLITE_ERROR` here is "no such table", which is a fact about the file and will // never change; anything else (BUSY, I/O) is this moment failing to read a store @@ -266,7 +283,13 @@ enum LocalAntigravityUsageReader { } defer { sqlite3_finalize(statement) } - let fallbackDate = signature(of: database)?.mtime ?? Date() + // Three sources, most specific first: the writer's own `created_at`, the timings of the + // execution the record belongs to, and — for a store that offers neither — the file's + // own mtime, which is the same date for every record it holds. + let storeDate = signature(of: database)?.mtime ?? Date() + // Loads itself on the first record that has no `created_at`, so a store written before + // Antigravity 2.0 never reads `steps` at all. + let timeline = StepTimeline(handle: handle) let conversation = database.deletingPathExtension().lastPathComponent var entries: [LocalUsageReader.Entry] = [] var discardedCounters = 0 @@ -282,7 +305,9 @@ enum LocalAntigravityUsageReader { guard count > 0 else { return } let blob = Data(bytes: pointer, count: count) let record = parseGenerationMetadata( - blob, conversation: conversation, index: index, fallbackDate: fallbackDate, formatter: formatter) + blob, conversation: conversation, index: index, + fallbackDate: { timeline.next(for: $0) ?? storeDate }, + formatter: formatter) discardedCounters += record.discardedCounters guard let entry = record.entry else { return } entries.append(entry) @@ -295,6 +320,10 @@ enum LocalAntigravityUsageReader { guard step == SQLITE_DONE else { return .incompleteScan(status: step, rows: rows) } break } + // A `steps` read that stopped early leaves some records dated from their own execution + // and the rest from the store's mtime. That mixture must not be cached as the whole of + // the conversation, for the same reason half its rows must not be. + if let status = timeline.failure { return .incompleteScan(status: status, rows: rows) } return .complete(entries: entries, discardedCounters: discardedCounters) } @@ -322,6 +351,83 @@ enum LocalAntigravityUsageReader { return nil } + // MARK: Dating a record Antigravity 2.0 left undated + + /// Step timings from the same store, grouped by the execution that produced them, handed out + /// in order to the generation records of that execution. + /// + /// Ordered rather than "the last step of the execution": both sequences run forward in time, + /// so pairing the nth generation with the nth step follows a long execution as it goes + /// instead of collapsing all of its records onto its final moment. Measured against the + /// stores that still carry `created_at`, ordered pairing lands a couple of minutes out where + /// taking the last step is four times that — and the further out it is, the more turns a day + /// boundary can catch on the wrong side. + /// + /// A `class` because it is loaded lazily from inside the row loop and every call must see the + /// same consumption. + final class StepTimeline { + private let handle: OpaquePointer + private var times: [String: [Date]] = [:] + private var taken: [String: Int] = [:] + private var loaded = false + + /// The status of a `steps` read that stopped before `SQLITE_DONE`. A store with no + /// `steps` table at all is not a failure — it is one this fallback does not apply to, + /// and the caller still has the store's mtime to date it with. + private(set) var failure: Int32? + + init(handle: OpaquePointer) { self.handle = handle } + + /// The next unclaimed step time of `execution`, or its last one once they run out — a + /// step whose `finished_at` has not been written yet still dates the turn it belongs to. + func next(for execution: String?) -> Date? { + guard let execution else { return nil } + if !loaded { + loaded = true + load() + } + guard let steps = times[execution], !steps.isEmpty else { return nil } + let ordinal = taken[execution, default: 0] + taken[execution] = ordinal + 1 + return steps[min(ordinal, steps.count - 1)] + } + + private func load() { + var statement: OpaquePointer? + let prepared = sqlite3_prepare_v2( + handle, "SELECT metadata FROM steps WHERE metadata IS NOT NULL ORDER BY idx", + -1, &statement, nil) + guard prepared == SQLITE_OK, let statement else { + // `SQLITE_ERROR` is "no such table": a store that keeps generation metadata and + // no steps is one this fallback does not apply to, not one that failed to read. + if prepared != SQLITE_ERROR { failure = prepared } + return + } + defer { sqlite3_finalize(statement) } + + while true { + let step = sqlite3_step(statement) + if step == SQLITE_ROW { + autoreleasepool { + guard let pointer = sqlite3_column_blob(statement, 0) else { return } + let count = Int(sqlite3_column_bytes(statement, 0)) + guard count > 0 else { return } + let bytes = [UInt8](Data(bytes: pointer, count: count)) + guard let execution = AntigravityProto.string(bytes[...], field: 12), + // `finished_at` is when the tokens were actually spent; a step + // still running has only the moment it was queued. + let date = timestamp(bytes[...], field: 8) + ?? timestamp(bytes[...], field: 1) else { return } + times[execution, default: []].append(date) + } + continue + } + if step != SQLITE_DONE { failure = step } + return + } + } + } + // MARK: Parsing one generation record /// What one `gen_metadata` row yielded. `discardedCounters` travels with the entry because @@ -336,7 +442,7 @@ enum LocalAntigravityUsageReader { _ blob: Data, conversation: String, index: Int64, - fallbackDate: Date? = nil, + fallbackDate: (_ execution: String?) -> Date? = { _ in nil }, formatter: DateFormatter ) -> Record { let bytes = [UInt8](blob) @@ -348,8 +454,12 @@ enum LocalAntigravityUsageReader { case .valid(let d): date = d case .absent: - guard let fallbackDate else { return Record() } - date = fallbackDate + // `execution_id` names the execution this record belongs to; the resolver turns it + // into a time from the same store's steps. + guard let inferred = fallbackDate(AntigravityProto.string(bytes[...], field: 4)) else { + return Record() + } + date = inferred case .invalid: return Record() } @@ -401,6 +511,17 @@ enum LocalAntigravityUsageReader { return .valid(Date(timeIntervalSince1970: Double(seconds) + nanos / 1_000_000_000)) } + /// A `google.protobuf.Timestamp` held at `field`, with the same plausibility window + /// `createdAt` applies — a malformed varint can carry the whole `uint64` range, and a `Date` + /// built from one would overflow the arithmetic downstream of it. + static func timestamp(_ data: ArraySlice, field: Int) -> Date? { + guard let stamp = AntigravityProto.message(data, field: field), + let seconds = AntigravityProto.varint(stamp, field: 1), + seconds >= 1_000_000_000, seconds <= 4_102_444_800 else { return nil } + let nanos = AntigravityProto.varint(stamp, field: 2).map { $0 < 1_000_000_000 ? Double($0) : 0 } ?? 0 + return Date(timeIntervalSince1970: Double(seconds) + nanos / 1_000_000_000) + } + private static func makeEntry( id: String, date: Date, diff --git a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift index 3d5737bc3..8ef82c796 100644 --- a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift +++ b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift @@ -134,6 +134,125 @@ final class AntigravityUsageTests: XCTestCase { XCTAssertTrue(readAll().isEmpty) } + // MARK: - Dating what Antigravity 2.0 leaves undated + + /// The store's mtime is one value for every record it holds, so a conversation that ran + /// across midnight files yesterday's spend under today. The step that produced the record + /// knows better, and it is in the same database. + func testStepTimeWinsOverTheStoreMtime() throws { + let yesterday = try date("2026-03-04T23:40:00Z") + try writeConversation("c1", records: [ + undatedRecord(responseID: "r1", model: "gemini-3.7-flash", execution: "e1", + input: 100, output: 20, cacheRead: 300), + ], steps: [makeStep(execution: "e1", queued: yesterday, finished: yesterday)]) + // The store was last written well after the turn it holds, which is the ordinary state + // of a conversation somebody is still using. + let database = temporaryDirectory.appendingPathComponent("c1.db") + let touched = try date("2026-03-05T09:00:00Z") + try FileManager.default.setAttributes([.modificationDate: touched], ofItemAtPath: database.path) + + let entry = try XCTUnwrap(readAll().first) + XCTAssertEqual(entry.date.timeIntervalSince1970, yesterday.timeIntervalSince1970, accuracy: 0.001) + XCTAssertEqual(entry.localDay, LocalUsageReader.localDayFormatter().string(from: yesterday), + "the mtime would have moved this turn onto the following day") + } + + /// `finished_at` is when the tokens were spent, so it is preferred over the moment the step + /// was queued. + func testRecordIsDatedFromTheFinishOfItsStep() throws { + let queued = try date("2026-03-04T10:00:00Z") + let finished = try date("2026-03-04T10:00:12Z") + try writeConversation("c1", records: [ + undatedRecord(responseID: "r1", model: "gemini-3.7-flash", execution: "e1", + input: 100, output: 20, cacheRead: 300), + ], steps: [makeStep(execution: "e1", queued: queued, finished: finished)]) + + let entry = try XCTUnwrap(readAll().first) + XCTAssertEqual(entry.date.timeIntervalSince1970, finished.timeIntervalSince1970, accuracy: 0.001) + XCTAssertEqual(entry.total, 420, "the counters are the writer's — only the date is inferred") + } + + /// `gen_metadata.idx` is its own dense sequence and drifts from `steps.idx` by minutes inside + /// one conversation, so the join has to be the execution id and nothing else. + func testTheJoinIsTheExecutionIdAndNotTheRowIndex() throws { + let first = try date("2026-03-04T10:00:00Z") + let second = try date("2026-03-05T18:00:00Z") + try writeConversation("c1", records: [ + undatedRecord(responseID: "r1", model: "gemini-3.7-flash", execution: "e2", + input: 100, output: 20, cacheRead: 300), + ], steps: [ + makeStep(execution: "e1", queued: first, finished: first), + makeStep(execution: "e2", queued: second, finished: second), + ]) + + let entry = try XCTUnwrap(readAll().first) + XCTAssertEqual(entry.date.timeIntervalSince1970, second.timeIntervalSince1970, accuracy: 0.001, + "row 0 was dated from step 0 instead of from its own execution") + } + + /// One execution answers several times. Pairing them in order follows the execution as it + /// runs; collapsing them onto its last step would file a turn under the wrong day whenever + /// an execution straddles midnight. + func testStepTimesAreHandedOutInOrderWithinAnExecution() throws { + let times = try [ + date("2026-03-04T23:50:00Z"), date("2026-03-04T23:55:00Z"), date("2026-03-05T00:05:00Z"), + ] + try writeConversation("c1", records: (0..<3).map { + undatedRecord(responseID: "r\($0)", model: "gemini-3.7-flash", execution: "e1", + input: 100, output: 20, cacheRead: 300) + }, steps: times.map { makeStep(execution: "e1", queued: $0, finished: $0) }) + + let dates = readAll().sorted { $0.id < $1.id }.map(\.date.timeIntervalSince1970) + XCTAssertEqual(dates, times.map(\.timeIntervalSince1970)) + } + + /// A step still running has no `finished_at`, and more records than steps is what a store + /// read mid-execution looks like. The last known time still dates them. + func testRecordsBeyondTheStepsFallBackToTheLastKnownTime() throws { + let queued = try date("2026-03-04T10:00:00Z") + try writeConversation("c1", records: (0..<2).map { + undatedRecord(responseID: "r\($0)", model: "gemini-3.7-flash", execution: "e1", + input: 100, output: 20, cacheRead: 300) + }, steps: [makeStep(execution: "e1", queued: queued, finished: nil)]) + + let dates = readAll().map(\.date.timeIntervalSince1970) + XCTAssertEqual(dates.count, 2) + XCTAssertEqual(Set(dates), [queued.timeIntervalSince1970]) + } + + /// The writer's own value where there is one — the step time is an inference, and an + /// inference must never displace the thing it stands in for. + func testWriterCreatedAtWinsOverTheStepTime() throws { + let created = try date("2026-03-04T10:00:00Z") + try writeConversation("c1", records: [ + makeRecord(responseID: "r1", model: "gemini-3.6-flash", + createdAtSeconds: UInt64(created.timeIntervalSince1970), + input: 100, output: 20, cacheRead: 300, execution: "e1"), + ], steps: [makeStep(execution: "e1", + queued: try date("2026-03-09T10:00:00Z"), + finished: try date("2026-03-09T10:00:01Z"))]) + + let entry = try XCTUnwrap(readAll().first) + XCTAssertEqual(entry.date.timeIntervalSince1970, created.timeIntervalSince1970, accuracy: 0.001) + } + + /// The store's mtime stays as the last resort: a record whose execution has no steps to date + /// it with must still be counted rather than dropped. + func testStoreMtimeRemainsTheLastResort() throws { + try writeConversation("c1", records: [ + undatedRecord(responseID: "r1", model: "gemini-3.7-flash", execution: "e1", + input: 100, output: 20, cacheRead: 300), + ], steps: [makeStep(execution: "another-execution", + queued: try date("2026-03-01T10:00:00Z"), finished: nil)]) + let database = temporaryDirectory.appendingPathComponent("c1.db") + let touched = try date("2026-03-05T09:00:00Z") + try FileManager.default.setAttributes([.modificationDate: touched], ofItemAtPath: database.path) + + let entry = try XCTUnwrap(readAll().first) + XCTAssertEqual(entry.date.timeIntervalSince1970, touched.timeIntervalSince1970, accuracy: 1) + XCTAssertEqual(entry.total, 420) + } + // MARK: - Hostile input /// A `uint64` sentinel widened into `Int` would trap the process on the next addition, and @@ -645,6 +764,32 @@ final class AntigravityUsageTests: XCTestCase { } /// `CortexStepGeneratorMetadata { 1 chat_model { 4 usage, 9 chat_start_metadata, 19 response_model } }` + /// An Antigravity 2.0 record: it names the execution it belongs to and carries no time. + private func undatedRecord( + responseID: String?, + model: String, + execution: String?, + input: UInt64, + output: UInt64, + cacheRead: UInt64 + ) -> Data { + makeRecord(responseID: responseID, model: model, createdAtSeconds: nil, + input: input, output: output, cacheRead: cacheRead, execution: execution) + } + + /// `StepMetadata { 1 created_at, 8 finished_at, 12 execution_id }` + private func makeStep(execution: String, queued: Date, finished: Date?) -> Data { + func stamp(_ field: Int, _ date: Date) -> [UInt8] { + AntigravityProto.encodeMessage( + field: field, + AntigravityProto.encodeVarint(field: 1, UInt64(date.timeIntervalSince1970))) + } + var metadata = stamp(1, queued) + if let finished { metadata += stamp(8, finished) } + metadata += AntigravityProto.encodeString(field: 12, execution) + return Data(metadata) + } + private func makeRecord( responseID: String?, model: String, @@ -653,7 +798,8 @@ final class AntigravityUsageTests: XCTestCase { output: UInt64, cacheRead: UInt64, thinking: UInt64? = nil, - response: UInt64? = nil + response: UInt64? = nil, + execution: String? = nil ) -> Data { var usage = AntigravityProto.encodeVarint(field: 1, 1071) // model enum usage += AntigravityProto.encodeVarint(field: 2, input) // input_tokens @@ -666,18 +812,23 @@ final class AntigravityUsageTests: XCTestCase { var chatModel = AntigravityProto.encodeVarint(field: 3, 1071) chatModel += AntigravityProto.encodeMessage(field: 4, usage) - if let createdAtSeconds { - let timestamp = AntigravityProto.encodeVarint(field: 1, createdAtSeconds) - let chatStart = AntigravityProto.encodeMessage(field: 4, timestamp) - chatModel += AntigravityProto.encodeMessage(field: 9, chatStart) - } + // Antigravity 2.0 still writes `chat_start_metadata`; it is `created_at` inside it that + // is gone. Dropping the whole message instead would let a future writer that puts + // something else at field 4 pass these tests unnoticed. + let chatStart = createdAtSeconds.map { + AntigravityProto.encodeMessage(field: 4, AntigravityProto.encodeVarint(field: 1, $0)) + } ?? AntigravityProto.encodeVarint(field: 2, UInt64.max) + chatModel += AntigravityProto.encodeMessage(field: 9, chatStart) chatModel += AntigravityProto.encodeString(field: 19, model) - return Data(AntigravityProto.encodeMessage(field: 1, chatModel)) + var record = AntigravityProto.encodeMessage(field: 1, chatModel) + if let execution { record += AntigravityProto.encodeString(field: 4, execution) } + return Data(record) } - private func writeConversation(_ name: String, at root: URL? = nil, records: [Data], walMode: Bool = false) throws { - try writeConversation(name, at: root, blobs: records, walMode: walMode) + private func writeConversation(_ name: String, at root: URL? = nil, records: [Data], + steps: [Data] = [], walMode: Bool = false) throws { + try writeConversation(name, at: root, blobs: records, steps: steps, walMode: walMode) } /// A store spanning several pages, so damaging the last one still leaves earlier rows @@ -725,7 +876,7 @@ final class AntigravityUsageTests: XCTestCase { defer { sqlite3_close_v2(handle) } var statement: OpaquePointer? XCTAssertEqual(sqlite3_prepare_v2( - handle, "SELECT idx, data FROM gen_metadata WHERE data IS NOT NULL", + handle, "SELECT idx, data FROM gen_metadata WHERE data IS NOT NULL ORDER BY idx", -1, &statement, nil), SQLITE_OK) defer { sqlite3_finalize(statement) } var rows = 0 @@ -736,7 +887,8 @@ final class AntigravityUsageTests: XCTestCase { } } - private func writeConversation(_ name: String, at root: URL? = nil, blobs: [Data], walMode: Bool = false, + private func writeConversation(_ name: String, at root: URL? = nil, blobs: [Data], + steps: [Data] = [], walMode: Bool = false, pageSize: Int? = nil) throws { let directory = root ?? temporaryDirectory! let database = directory.appendingPathComponent("\(name).db") @@ -747,6 +899,12 @@ final class AntigravityUsageTests: XCTestCase { for (index, blob) in blobs.enumerated() { sql += "INSERT INTO gen_metadata VALUES (\(index), X'\(blob.map { String(format: "%02x", $0) }.joined())', \(blob.count));\n" } + if !steps.isEmpty { + sql += "CREATE TABLE steps (idx integer, metadata blob, PRIMARY KEY (idx));\n" + for (index, blob) in steps.enumerated() { + sql += "INSERT INTO steps VALUES (\(index), X'\(blob.map { String(format: "%02x", $0) }.joined())');\n" + } + } try execute(database, sql: sql) } From cb164c139e94e840bf92d9926931f153ad8afe70 Mon Sep 17 00:00:00 2001 From: Justin Jeong Date: Thu, 20 Aug 2026 09:24:34 +0900 Subject: [PATCH 7/7] feat: correlate Antigravity timestamps by response_id with in-order execution fallback Correlate generation metadata timestamps directly with step metadata by response_id (1:1), falling back to in-order execution step timings and store modification time. Co-authored-by: Seo JeongSeop --- .../Core/LocalAntigravityUsageReader.swift | 138 +++++++----------- .../AntigravityUsageTests.swift | 78 +++++++++- docs/reference/defect-log.md | 5 + 3 files changed, 135 insertions(+), 86 deletions(-) diff --git a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift index 7756c23ac..652b1988e 100644 --- a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift @@ -283,17 +283,17 @@ enum LocalAntigravityUsageReader { } defer { sqlite3_finalize(statement) } - // Three sources, most specific first: the writer's own `created_at`, the timings of the - // execution the record belongs to, and — for a store that offers neither — the file's - // own mtime, which is the same date for every record it holds. + let stepDates = generationStepDates(handle) + if let status = stepDates.status { + return .incompleteScan(status: status, rows: 0) + } + let storeDate = signature(of: database)?.mtime ?? Date() - // Loads itself on the first record that has no `created_at`, so a store written before - // Antigravity 2.0 never reads `steps` at all. - let timeline = StepTimeline(handle: handle) let conversation = database.deletingPathExtension().lastPathComponent var entries: [LocalUsageReader.Entry] = [] var discardedCounters = 0 var rows = 0 + var takenByExecution: [String: Int] = [:] while true { let step = sqlite3_step(statement) if step == SQLITE_ROW { @@ -306,7 +306,17 @@ enum LocalAntigravityUsageReader { let blob = Data(bytes: pointer, count: count) let record = parseGenerationMetadata( blob, conversation: conversation, index: index, - fallbackDate: { timeline.next(for: $0) ?? storeDate }, + fallbackDate: { responseID, executionID in + if let responseID, let date = stepDates.byResponse[responseID] { + return date + } + if let executionID, let list = stepDates.byExecution[executionID], !list.isEmpty { + let ordinal = takenByExecution[executionID, default: 0] + takenByExecution[executionID] = ordinal + 1 + return list[min(ordinal, list.count - 1)] + } + return storeDate + }, formatter: formatter) discardedCounters += record.discardedCounters guard let entry = record.entry else { return } @@ -320,10 +330,6 @@ enum LocalAntigravityUsageReader { guard step == SQLITE_DONE else { return .incompleteScan(status: step, rows: rows) } break } - // A `steps` read that stopped early leaves some records dated from their own execution - // and the rest from the store's mtime. That mixture must not be cached as the whole of - // the conversation, for the same reason half its rows must not be. - if let status = timeline.failure { return .incompleteScan(status: status, rows: rows) } return .complete(entries: entries, discardedCounters: discardedCounters) } @@ -353,78 +359,43 @@ enum LocalAntigravityUsageReader { // MARK: Dating a record Antigravity 2.0 left undated - /// Step timings from the same store, grouped by the execution that produced them, handed out - /// in order to the generation records of that execution. - /// - /// Ordered rather than "the last step of the execution": both sequences run forward in time, - /// so pairing the nth generation with the nth step follows a long execution as it goes - /// instead of collapsing all of its records onto its final moment. Measured against the - /// stores that still carry `created_at`, ordered pairing lands a couple of minutes out where - /// taking the last step is four times that — and the further out it is, the more turns a day - /// boundary can catch on the wrong side. - /// - /// A `class` because it is loaded lazily from inside the row loop and every call must see the - /// same consumption. - final class StepTimeline { - private let handle: OpaquePointer - private var times: [String: [Date]] = [:] - private var taken: [String: Int] = [:] - private var loaded = false - - /// The status of a `steps` read that stopped before `SQLITE_DONE`. A store with no - /// `steps` table at all is not a failure — it is one this fallback does not apply to, - /// and the caller still has the store's mtime to date it with. - private(set) var failure: Int32? - - init(handle: OpaquePointer) { self.handle = handle } - - /// The next unclaimed step time of `execution`, or its last one once they run out — a - /// step whose `finished_at` has not been written yet still dates the turn it belongs to. - func next(for execution: String?) -> Date? { - guard let execution else { return nil } - if !loaded { - loaded = true - load() - } - guard let steps = times[execution], !steps.isEmpty else { return nil } - let ordinal = taken[execution, default: 0] - taken[execution] = ordinal + 1 - return steps[min(ordinal, steps.count - 1)] + /// Current Antigravity stores keep the generation timestamp in the corresponding `steps.metadata` + /// row rather than in `gen_metadata.data`. Records are correlated by `response_id` (1:1), falling + /// back to `execution_id` and file mtime. + private static func generationStepDates( + _ handle: OpaquePointer + ) -> (byResponse: [String: Date], byExecution: [String: [Date]], status: Int32?) { + var statement: OpaquePointer? + let sql = "SELECT metadata FROM steps WHERE metadata IS NOT NULL ORDER BY idx" + let prepared = sqlite3_prepare_v2(handle, sql, -1, &statement, nil) + guard prepared == SQLITE_OK, let statement else { + return ([:], [:], prepared == SQLITE_ERROR ? nil : prepared) } + defer { sqlite3_finalize(statement) } - private func load() { - var statement: OpaquePointer? - let prepared = sqlite3_prepare_v2( - handle, "SELECT metadata FROM steps WHERE metadata IS NOT NULL ORDER BY idx", - -1, &statement, nil) - guard prepared == SQLITE_OK, let statement else { - // `SQLITE_ERROR` is "no such table": a store that keeps generation metadata and - // no steps is one this fallback does not apply to, not one that failed to read. - if prepared != SQLITE_ERROR { failure = prepared } - return - } - defer { sqlite3_finalize(statement) } - - while true { - let step = sqlite3_step(statement) - if step == SQLITE_ROW { - autoreleasepool { - guard let pointer = sqlite3_column_blob(statement, 0) else { return } - let count = Int(sqlite3_column_bytes(statement, 0)) - guard count > 0 else { return } - let bytes = [UInt8](Data(bytes: pointer, count: count)) - guard let execution = AntigravityProto.string(bytes[...], field: 12), - // `finished_at` is when the tokens were actually spent; a step - // still running has only the moment it was queued. - let date = timestamp(bytes[...], field: 8) - ?? timestamp(bytes[...], field: 1) else { return } - times[execution, default: []].append(date) + var byResponse: [String: Date] = [:] + var byExecution: [String: [Date]] = [:] + while true { + let step = sqlite3_step(statement) + if step == SQLITE_ROW { + autoreleasepool { + guard let pointer = sqlite3_column_blob(statement, 0) else { return } + let count = Int(sqlite3_column_bytes(statement, 0)) + guard count > 0 else { return } + let bytes = [UInt8](Data(bytes: pointer, count: count)) + let date = timestamp(bytes[...], field: 8) ?? timestamp(bytes[...], field: 1) + guard let date else { return } + if let model = AntigravityProto.message(bytes[...], field: 9), + let responseID = AntigravityProto.string(model, field: 11) { + byResponse[responseID] = date + } + if let executionID = AntigravityProto.string(bytes[...], field: 12) { + byExecution[executionID, default: []].append(date) } - continue } - if step != SQLITE_DONE { failure = step } - return + continue } + return (byResponse, byExecution, step == SQLITE_DONE ? nil : step) } } @@ -442,21 +413,22 @@ enum LocalAntigravityUsageReader { _ blob: Data, conversation: String, index: Int64, - fallbackDate: (_ execution: String?) -> Date? = { _ in nil }, + fallbackDate: (_ responseID: String?, _ executionID: String?) -> Date? = { _, _ in nil }, formatter: DateFormatter ) -> Record { let bytes = [UInt8](blob) guard let chatModel = AntigravityProto.message(bytes[...], field: 1), let usage = AntigravityProto.message(chatModel, field: 4) else { return Record() } + let responseID = AntigravityProto.string(usage, field: 11) + let executionID = AntigravityProto.string(bytes[...], field: 4) + let date: Date switch createdAt(chatModel) { case .valid(let d): date = d case .absent: - // `execution_id` names the execution this record belongs to; the resolver turns it - // into a time from the same store's steps. - guard let inferred = fallbackDate(AntigravityProto.string(bytes[...], field: 4)) else { + guard let inferred = fallbackDate(responseID, executionID) else { return Record() } date = inferred @@ -466,7 +438,7 @@ enum LocalAntigravityUsageReader { // The turn's own id, not the file it happens to sit in — a copied conversation must // not read as fresh spend. `response_id` is populated on every recorded call. - let identity = AntigravityProto.string(usage, field: 11).map { "antigravity|\($0)" } + let identity = responseID.map { "antigravity|\($0)" } ?? "antigravity|\(conversation)|\(index)" // `response_model` names the model that answered; the rate lookup is short-circuited diff --git a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift index 8ef82c796..7b0e2aa25 100644 --- a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift +++ b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift @@ -220,6 +220,72 @@ final class AntigravityUsageTests: XCTestCase { XCTAssertEqual(Set(dates), [queued.timeIntervalSince1970]) } + /// New Antigravity stores omit `chat_start_metadata.created_at` from the generation blob. + /// The same response id and timestamp remain in the corresponding generation step metadata. + func testCurrentGenerationFormatUsesStepMetadataTimestamp() throws { + let firstID = "synthetic-new-format-call-1" + let secondID = "synthetic-new-format-call-2" + let firstDate = try date("2026-08-19T10:00:00Z") + let secondDate = try date("2026-08-19T10:01:00Z") + try writeConversation( + "c1", + records: [ + undatedRecord(responseID: firstID, model: "gemini-3.7-flash", execution: "e1", + input: 100, output: 20, cacheRead: 300), + undatedRecord(responseID: secondID, model: "gemini-3.7-flash", execution: "e1", + input: 200, output: 30, cacheRead: 400), + ], + // Deliberately reverse the rows: correlation must use response_id, not table order. + steps: [ + makeStep(execution: "e1", responseID: secondID, queued: secondDate, finished: secondDate), + makeStep(execution: "e1", responseID: firstID, queued: firstDate, finished: firstDate), + ]) + + let entries = readAll() + XCTAssertEqual(entries.count, 2) + let byID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) + let first = try XCTUnwrap(byID["antigravity|\(firstID)"]) + let second = try XCTUnwrap(byID["antigravity|\(secondID)"]) + XCTAssertEqual(first.date.timeIntervalSince1970, firstDate.timeIntervalSince1970, accuracy: 0.001) + XCTAssertEqual(second.date.timeIntervalSince1970, secondDate.timeIntervalSince1970, accuracy: 0.001) + let formatter = LocalUsageReader.localDayFormatter() + XCTAssertEqual(first.localDay, formatter.string(from: firstDate)) + XCTAssertEqual(second.localDay, formatter.string(from: secondDate)) + XCTAssertEqual(first.total, 420) + XCTAssertEqual(second.total, 630) + } + + /// Multiple generations in the same conversation sharing an execution_id are correlated + /// directly by response_id even across midnight. + func testMultipleGenerationsSharingExecutionIdAreCorrelatedByResponseId() throws { + let firstID = "synthetic-shared-exec-call-1" + let secondID = "synthetic-shared-exec-call-2" + let firstDate = try date("2026-08-18T10:00:00Z") + let secondDate = try date("2026-08-19T10:00:00Z") + let executionID = "shared-execution-uuid" + try writeConversation( + "c1", + records: [ + undatedRecord(responseID: firstID, model: "gemini-3.7-flash", execution: executionID, + input: 100, output: 20, cacheRead: 300), + undatedRecord(responseID: secondID, model: "gemini-3.7-flash", execution: executionID, + input: 200, output: 30, cacheRead: 400), + ], + steps: [ + makeStep(execution: executionID, responseID: secondID, queued: secondDate, finished: secondDate), + makeStep(execution: executionID, responseID: firstID, queued: firstDate, finished: firstDate), + ]) + + let entries = readAll() + let byID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) + let first = try XCTUnwrap(byID["antigravity|\(firstID)"]) + let second = try XCTUnwrap(byID["antigravity|\(secondID)"]) + let formatter = LocalUsageReader.localDayFormatter() + XCTAssertEqual(first.localDay, formatter.string(from: firstDate)) + XCTAssertEqual(second.localDay, formatter.string(from: secondDate)) + XCTAssertNotEqual(first.localDay, second.localDay) + } + /// The writer's own value where there is one — the step time is an inference, and an /// inference must never displace the thing it stands in for. func testWriterCreatedAtWinsOverTheStepTime() throws { @@ -777,8 +843,8 @@ final class AntigravityUsageTests: XCTestCase { input: input, output: output, cacheRead: cacheRead, execution: execution) } - /// `StepMetadata { 1 created_at, 8 finished_at, 12 execution_id }` - private func makeStep(execution: String, queued: Date, finished: Date?) -> Data { + /// `StepMetadata { 1 created_at, 8 finished_at, 9 { 11 response_id }, 12 execution_id }` + private func makeStep(execution: String? = nil, responseID: String? = nil, queued: Date, finished: Date?) -> Data { func stamp(_ field: Int, _ date: Date) -> [UInt8] { AntigravityProto.encodeMessage( field: field, @@ -786,7 +852,13 @@ final class AntigravityUsageTests: XCTestCase { } var metadata = stamp(1, queued) if let finished { metadata += stamp(8, finished) } - metadata += AntigravityProto.encodeString(field: 12, execution) + if let responseID { + metadata += AntigravityProto.encodeMessage( + field: 9, AntigravityProto.encodeString(field: 11, responseID)) + } + if let execution { + metadata += AntigravityProto.encodeString(field: 12, execution) + } return Data(metadata) } diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 34c05ae16..3c4b3bfb8 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -53,6 +53,11 @@ read_when: 계약 테스트)를 열어 키·타입·의미를 확정 ② 그 계약으로 픽스처 작성 ③ 가능하면 실파일 1건 캡처. 특히 **같은 스펠링이 표면마다 의미가 다를 수 있다**(Grok `inputTokens`=캐시 포함 durable wire vs `input_tokens`=캐시 제외 헤드리스 투영) — 별칭으로 합치면 캐시분을 두 번 빼거나 두 번 더한다. +- **Antigravity의 생성 시각은 `gen_metadata` 한 곳에 고정돼 있지 않다.** 구 포맷은 + `chat_start_metadata.created_at`에 시각을 넣지만, 현재 포맷은 같은 `response_id`를 가진 + `steps(step_type=15).metadata`에 타임스탬프를 둔다. 토큰 필드는 유지되므로 `gen_metadata`만 읽으면 + 오늘 사용량이 통째로 `nil`이 된다. 두 레코드의 행 순서가 같다는 가정 대신 `response_id`로 직접 연결하고, + 구 포맷의 직접 시각은 계속 우선한다. 회귀 가드는 `testCurrentGenerationFormatUsesStepMetadataTimestamp`다. - **사용량 소스의 "복사·재기록" 경로를 먼저 찾아라 (이중집계·재날짜화).** 세션 fork·재생·서브에이전트는 같은 지출을 여러 파일에 남기거나 시각을 다시 찍는다. 규칙: ① dedup 키는 *턴 자체* 의 전역 유일 id(파일·세션 경로를 섞지 마라 — 복사본이 별건이 된다) ② 시각은 *기록* 시각이 아니라 *턴* 시각(fork 는 봉투 timestamp 를 새로