Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ enum LocalAntigravityUsageReader {
}
defer { sqlite3_finalize(statement) }

let stepDates = generationStepDates(handle)
if let status = stepDates.status {
return .incompleteScan(status: status, rows: 0)
}

let conversation = database.deletingPathExtension().lastPathComponent
var entries: [LocalUsageReader.Entry] = []
var discardedCounters = 0
Expand All @@ -251,7 +256,8 @@ 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, formatter: formatter,
stepDates: stepDates.dates)
discardedCounters += record.discardedCounters
guard let entry = record.entry else { return }
entries.append(entry)
Expand All @@ -267,6 +273,45 @@ enum LocalAntigravityUsageReader {
return .complete(entries: entries, discardedCounters: discardedCounters)
}

/// Current Antigravity stores keep the generation's timestamp in the corresponding
/// `steps.metadata` row rather than in `gen_metadata.data`. The response id is present in
/// both records, so the lookup does not depend on either table's row numbering.
private static func generationStepDates(
_ handle: OpaquePointer
) -> (dates: [String: Date], status: Int32?) {
var statement: OpaquePointer?
let prepared = sqlite3_prepare_v2(
handle,
"SELECT metadata FROM steps WHERE step_type = 15 AND metadata IS NOT NULL",
-1,
&statement,
nil)
guard prepared == SQLITE_OK, let statement else {
// Older/minimal stores may not have a steps table. Direct timestamps in the
// generation blob still work in that case; a different SQLite error is retriable.
return ([:], prepared == SQLITE_ERROR ? nil : prepared)
}
defer { sqlite3_finalize(statement) }

var dates: [String: Date] = [:]
while true {
let step = sqlite3_step(statement)
if step == SQLITE_ROW {
guard let pointer = sqlite3_column_blob(statement, 0) else { continue }
let count = Int(sqlite3_column_bytes(statement, 0))
guard count > 0 else { continue }
let bytes = ArraySlice(Data(bytes: pointer, count: count))
guard let model = AntigravityProto.message(bytes, field: 9),
let responseID = AntigravityProto.string(model, field: 11),
let stamp = AntigravityProto.message(bytes, field: 1),
let date = timestampDate(stamp) else { continue }
dates[responseID] = date
continue
}
return (dates, step == SQLITE_DONE ? nil : step)
}
}

/// `mode=ro` cannot create the `-shm` file a WAL database needs, so it fails outright on
/// conversations that have no `-wal` sibling yet. `immutable=1` reads those, and is only
/// reached when there is no `-wal` — that is, when there is no uncommitted tail to miss.
Expand Down Expand Up @@ -305,16 +350,21 @@ enum LocalAntigravityUsageReader {
_ blob: Data,
conversation: String,
index: Int64,
formatter: DateFormatter
formatter: DateFormatter,
stepDates: [String: Date] = [:]
) -> 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)
guard let date = createdAt(chatModel) ?? responseID.flatMap({ stepDates[$0] }) 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.
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
Expand Down Expand Up @@ -342,8 +392,13 @@ enum LocalAntigravityUsageReader {
/// `chat_start_metadata.created_at`, a `google.protobuf.Timestamp`.
private static func createdAt(_ chatModel: ArraySlice<UInt8>) -> Date? {
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 }
let stamp = AntigravityProto.message(start, field: 4) else { return nil }
return timestampDate(stamp)
}

/// A `google.protobuf.Timestamp` with the same bounds used by the legacy generation path.
private static func timestampDate(_ stamp: ArraySlice<UInt8>) -> Date? {
guard 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 }
Expand Down
151 changes: 148 additions & 3 deletions Tests/PokeTokenBarTests/AntigravityUsageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,99 @@ final class AntigravityUsageTests: XCTestCase {
XCTAssertEqual(entry.localDay, LocalUsageReader.localDayFormatter().string(from: created))
}

/// New Antigravity stores omit `chat_start_metadata.created_at` from the generation blob.
/// The generation still carries an `execution_id` at field 4, but that id is shared by
/// multiple generations. The response id remains the per-generation correlation key at
/// `field 1 → field 4 → field 11`; the step metadata mirrors it at `field 9 → field 11`.
func testCurrentGenerationFormatUsesStepMetadataTimestamp() throws {
let firstID = "synthetic-new-format-call-1"
let secondID = "synthetic-new-format-call-2"
let executionID = "00000000-0000-0000-0000-000000000001"
let firstDate = try date("2026-08-19T10:00:00Z")
let secondDate = try date("2026-08-19T10:01:00Z")
try writeConversation(
"c1",
blobs: [
currentFormatRecord(responseID: firstID, executionID: executionID, createdAt: firstDate,
input: 100, output: 20, cacheRead: 300),
currentFormatRecord(responseID: secondID, executionID: executionID, createdAt: secondDate,
input: 200, output: 30, cacheRead: 400),
],
// Deliberately reverse the rows: correlation must use response_id, not execution_id
// or table order.
stepMetadata: [
stepMetadata(responseID: secondID, executionID: executionID, createdAt: secondDate),
stepMetadata(responseID: firstID, executionID: executionID, createdAt: 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)
}

func testCurrentFormatPreservesEachRecordAcrossLocalMidnight() throws {
let firstID = "synthetic-midnight-call-1"
let secondID = "synthetic-midnight-call-2"
let executionID = "00000000-0000-0000-0000-000000000002"
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .current
let firstDate = try XCTUnwrap(calendar.date(from: DateComponents(
year: 2026, month: 8, day: 18, hour: 23, minute: 59)))
let secondDate = try XCTUnwrap(calendar.date(from: DateComponents(
year: 2026, month: 8, day: 19, hour: 0, minute: 1)))

try writeConversation(
"c1",
blobs: [
currentFormatRecord(responseID: firstID, executionID: executionID, createdAt: firstDate,
input: 100, output: 20, cacheRead: 300),
currentFormatRecord(responseID: secondID, executionID: executionID, createdAt: secondDate,
input: 200, output: 30, cacheRead: 400),
],
stepMetadata: [
stepMetadata(responseID: secondID, executionID: executionID, createdAt: secondDate),
stepMetadata(responseID: firstID, executionID: executionID, createdAt: 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)
}

func testLegacyCreatedAtRemainsPreferredOverStepMetadataTimestamp() throws {
let responseID = "synthetic-legacy-precedence"
let legacyDate = try date("2026-08-18T10:00:00Z")
let stepDate = try date("2026-08-19T10:00:00Z")
try writeConversation(
"c1",
records: [
record(responseID: responseID, model: "gemini-3.6-flash", createdAt: legacyDate,
input: 100, output: 20, cacheRead: 300),
],
stepMetadata: [
stepMetadata(responseID: responseID,
executionID: "00000000-0000-0000-0000-000000000003",
createdAt: stepDate),
])

let entry = try XCTUnwrap(readAll().first)
XCTAssertEqual(entry.date.timeIntervalSince1970, legacyDate.timeIntervalSince1970, accuracy: 0.001)
}

func testRecordsBeforeTheWindowAreExcluded() throws {
try writeConversation("c1", records: [
record(responseID: "old", model: "gemini-3.6-flash", createdAt: try date("2026-03-01T10:00:00Z"),
Expand Down Expand Up @@ -614,8 +707,54 @@ 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)
/// Minimal current-format record: the generation blob keeps usage/identity but no longer
/// carries `chat_start_metadata.created_at`.
private func currentFormatRecord(
responseID: String,
executionID: String,
createdAt: Date,
input: UInt64,
output: UInt64,
cacheRead: UInt64
) -> Data {
var usage = AntigravityProto.encodeVarint(field: 2, input)
usage += AntigravityProto.encodeVarint(field: 3, output)
usage += AntigravityProto.encodeVarint(field: 5, cacheRead)
usage += AntigravityProto.encodeString(field: 11, responseID)

// `chat_start_metadata` now contains other metadata at field 10, but not field 4
// (`created_at`). It is included only to keep the fixture on the new wire shape.
var start = AntigravityProto.encodeVarint(field: 2, 1)
start += AntigravityProto.encodeMessage(
field: 10,
AntigravityProto.encodeVarint(field: 1, 1)
+ AntigravityProto.encodeVarint(field: 4, 256_000))

var chatModel = AntigravityProto.encodeMessage(field: 4, usage)
chatModel += AntigravityProto.encodeMessage(field: 9, start)
chatModel += AntigravityProto.encodeString(field: 19, "gemini-3.6-flash")
var metadata = AntigravityProto.encodeMessage(field: 1, chatModel)
// Observed current writer shape: gen_metadata.data field 4 is the execution_id.
metadata += AntigravityProto.encodeString(field: 4, executionID)
return Data(metadata)
}

/// Minimal `steps.metadata` record used by the current writer. Field 12 repeats the
/// execution_id group key; field 9 → field 11 carries the per-generation response id used
/// to correlate the timestamp with a generation metadata row.
private func stepMetadata(responseID: String, executionID: String, createdAt: Date) -> Data {
let timestamp = AntigravityProto.encodeVarint(
field: 1, UInt64(createdAt.timeIntervalSince1970))
var metadata = AntigravityProto.encodeMessage(field: 1, timestamp)
metadata += AntigravityProto.encodeMessage(
field: 9, AntigravityProto.encodeString(field: 11, responseID))
metadata += AntigravityProto.encodeString(field: 12, executionID)
return Data(metadata)
}

private func writeConversation(_ name: String, records: [Data], walMode: Bool = false,
stepMetadata: [Data] = []) throws {
try writeConversation(name, blobs: records, walMode: walMode, stepMetadata: stepMetadata)
}

/// A store spanning several pages, so damaging the last one still leaves earlier rows
Expand Down Expand Up @@ -675,7 +814,7 @@ final class AntigravityUsageTests: XCTestCase {
}

private func writeConversation(_ name: String, blobs: [Data], walMode: Bool = false,
pageSize: Int? = nil) throws {
pageSize: Int? = nil, stepMetadata: [Data] = []) throws {
let database = temporaryDirectory.appendingPathComponent("\(name).db")
// `page_size` only takes effect before the first table exists.
var sql = pageSize.map { "PRAGMA page_size=\($0);\n" } ?? ""
Expand All @@ -684,6 +823,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 !stepMetadata.isEmpty {
sql += "CREATE TABLE steps (idx integer, step_type integer NOT NULL DEFAULT 0, metadata blob, PRIMARY KEY (idx));\n"
for (index, metadata) in stepMetadata.enumerated() {
sql += "INSERT INTO steps VALUES (\(index), 15, X'\(metadata.map { String(format: "%02x", $0) }.joined())');\n"
}
}
try execute(database, sql: sql)
}

Expand Down
5 changes: 5 additions & 0 deletions docs/reference/defect-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 를 새로
Expand Down