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..652b1988e 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
@@ -24,28 +26,59 @@ 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.
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 +101,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 +140,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 +171,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
@@ -155,7 +201,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] {
@@ -227,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
@@ -236,10 +283,17 @@ enum LocalAntigravityUsageReader {
}
defer { sqlite3_finalize(statement) }
+ let stepDates = generationStepDates(handle)
+ if let status = stepDates.status {
+ return .incompleteScan(status: status, rows: 0)
+ }
+
+ let storeDate = signature(of: database)?.mtime ?? Date()
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 {
@@ -251,7 +305,19 @@ 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: { 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 }
entries.append(entry)
@@ -291,6 +357,48 @@ enum LocalAntigravityUsageReader {
return nil
}
+ // MARK: Dating a record Antigravity 2.0 left undated
+
+ /// 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) }
+
+ 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
+ }
+ return (byResponse, byExecution, step == SQLITE_DONE ? nil : step)
+ }
+ }
+
// MARK: Parsing one generation record
/// What one `gen_metadata` row yielded. `discardedCounters` travels with the entry because
@@ -305,16 +413,32 @@ enum LocalAntigravityUsageReader {
_ blob: Data,
conversation: String,
index: Int64,
+ 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),
- let date = createdAt(chatModel) else { return Record() }
+ 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:
+ guard let inferred = fallbackDate(responseID, executionID) else {
+ return Record()
+ }
+ date = inferred
+ 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.
- 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
@@ -339,14 +463,33 @@ 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 .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)
}
@@ -495,13 +638,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 +666,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/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건의 발화 지시(순수 판정 결과). 부수효과와 분리해 테스트 가능하게.
diff --git a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift
index 71aa5d3fb..7b0e2aa25 100644
--- a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift
+++ b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift
@@ -134,6 +134,191 @@ 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])
+ }
+
+ /// 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 {
+ 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
@@ -514,13 +699,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] {
@@ -583,16 +805,73 @@ 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 } }`
+ /// 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, 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,
+ AntigravityProto.encodeVarint(field: 1, UInt64(date.timeIntervalSince1970)))
+ }
+ var metadata = stamp(1, queued)
+ if let finished { metadata += stamp(8, finished) }
+ 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)
+ }
+
private func makeRecord(
responseID: String?,
model: String,
- createdAtSeconds: UInt64,
+ createdAtSeconds: UInt64? = nil,
input: UInt64,
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
@@ -603,19 +882,25 @@ 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)
+ // 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, records: [Data], walMode: Bool = false) throws {
- try writeConversation(name, 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
@@ -663,7 +948,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
@@ -674,9 +959,11 @@ 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],
+ steps: [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" : ""
@@ -684,6 +971,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)
}
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 를 새로