diff --git a/docs/assets/report-preview.png b/docs/assets/report-preview.png index 5511f64..6a67a1c 100644 Binary files a/docs/assets/report-preview.png and b/docs/assets/report-preview.png differ diff --git a/src/__tests__/large-dataset.test.ts b/src/__tests__/large-dataset.test.ts index 784c087..43081ba 100644 --- a/src/__tests__/large-dataset.test.ts +++ b/src/__tests__/large-dataset.test.ts @@ -221,6 +221,7 @@ describe("large dataset — opencode.ts", () => { CREATE TABLE message ( id TEXT NOT NULL, session_id TEXT NOT NULL, + time_created INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL ); CREATE TABLE part ( @@ -228,6 +229,19 @@ describe("large dataset — opencode.ts", () => { session_id TEXT NOT NULL, data TEXT NOT NULL ); + CREATE TABLE session ( + id TEXT NOT NULL, + model TEXT, + tokens_input INTEGER NOT NULL DEFAULT 0, + tokens_output INTEGER NOT NULL DEFAULT 0, + tokens_reasoning INTEGER NOT NULL DEFAULT 0, + tokens_cache_read INTEGER NOT NULL DEFAULT 0, + tokens_cache_write INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0, + time_created INTEGER NOT NULL DEFAULT 0, + agent TEXT, + time_compacting INTEGER + ); `); const rowData = JSON.stringify({ diff --git a/src/__tests__/opencode.test.ts b/src/__tests__/opencode.test.ts index 120ad5c..3ed6c6b 100644 --- a/src/__tests__/opencode.test.ts +++ b/src/__tests__/opencode.test.ts @@ -28,6 +28,7 @@ function createDb(): Database.Database { CREATE TABLE message ( id TEXT NOT NULL, session_id TEXT NOT NULL, + time_created INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL ); CREATE TABLE part ( @@ -35,10 +36,61 @@ function createDb(): Database.Database { session_id TEXT NOT NULL, data TEXT NOT NULL ); + CREATE TABLE session ( + id TEXT NOT NULL, + model TEXT, + tokens_input INTEGER NOT NULL DEFAULT 0, + tokens_output INTEGER NOT NULL DEFAULT 0, + tokens_reasoning INTEGER NOT NULL DEFAULT 0, + tokens_cache_read INTEGER NOT NULL DEFAULT 0, + tokens_cache_write INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0, + time_created INTEGER NOT NULL DEFAULT 0, + agent TEXT, + time_compacting INTEGER + ); `); return db; } +function insertSession( + db: Database.Database, + row: { + id: string; + modelProviderID?: string; + modelID?: string; + tokensInput?: number; + tokensOutput?: number; + tokensCacheRead?: number; + tokensCacheWrite?: number; + timeCreated?: number; + agent?: string; + timeCompacting?: number | null; + } +) { + const model = + row.modelProviderID || row.modelID + ? JSON.stringify({ + providerID: row.modelProviderID ?? "", + id: row.modelID ?? "", + }) + : null; + db.prepare( + `INSERT INTO session (id, model, tokens_input, tokens_output, tokens_cache_read, tokens_cache_write, time_created, agent, time_compacting) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + row.id, + model, + row.tokensInput ?? 0, + row.tokensOutput ?? 0, + row.tokensCacheRead ?? 0, + row.tokensCacheWrite ?? 0, + row.timeCreated ?? 1_000_000, + row.agent ?? null, + row.timeCompacting ?? null + ); +} + function insertMessage( db: Database.Database, msg: { @@ -471,3 +523,158 @@ describe("parseOpenCode — SQLITE_TOOBIG fallback", () => { expect(() => parseOpenCode(dbPath)).toThrow("unexpected chunk error"); }); }); + +// --------------------------------------------------------------------------- +// Session-level fallback (OpenCode 1.16.0+) +// --------------------------------------------------------------------------- + +describe("parseOpenCode — session-level fallback", () => { + it("creates records from session table when messages have zero tokens", () => { + const db = createDb(); + insertMessage(db, { + id: "msg-1", + sessionId: "ses-1", + providerID: "github-copilot", + modelID: "gpt-5-mini", + inputTokens: 0, + outputTokens: 0, + }); + insertSession(db, { + id: "ses-1", + modelProviderID: "github-copilot", + modelID: "gpt-5-mini", + tokensInput: 1000, + tokensOutput: 500, + tokensCacheRead: 200, + tokensCacheWrite: 50, + }); + db.close(); + + const { records } = parseOpenCode(dbPath); + expect(records).toHaveLength(1); + expect(records[0]!.sessionId).toBe("ses-1"); + expect(records[0]!.inputTokens).toBe(1000); + expect(records[0]!.outputTokens).toBe(500); + expect(records[0]!.cacheReadTokens).toBe(200); + expect(records[0]!.cacheWriteTokens).toBe(50); + expect(records[0]!.model).toBe("gpt-5-mini"); + }); + + it("skips session-level fallback when message records have non-zero tokens", () => { + const db = createDb(); + insertMessage(db, { + id: "msg-1", + sessionId: "ses-1", + providerID: "github-copilot", + modelID: "gpt-5-mini", + inputTokens: 500, + outputTokens: 300, + }); + insertSession(db, { + id: "ses-1", + modelProviderID: "github-copilot", + modelID: "gpt-5-mini", + tokensInput: 9999, + tokensOutput: 9999, + }); + db.close(); + + const { records } = parseOpenCode(dbPath); + expect(records).toHaveLength(1); + // Should prefer per-message granularity, not session aggregate + expect(records[0]!.inputTokens).toBe(500); + expect(records[0]!.outputTokens).toBe(300); + expect(records[0]!.messageId).toBe("msg-1"); + }); + + it("creates session-level records when no messages exist for a copilot session", () => { + const db = createDb(); + insertSession(db, { + id: "ses-1", + modelProviderID: "github-copilot", + modelID: "gpt-5-mini", + tokensInput: 2000, + tokensOutput: 1000, + }); + db.close(); + + const { records } = parseOpenCode(dbPath); + expect(records).toHaveLength(1); + expect(records[0]!.sessionId).toBe("ses-1"); + expect(records[0]!.inputTokens).toBe(2000); + expect(records[0]!.outputTokens).toBe(1000); + expect(records[0]!.messageId).toBeUndefined(); + expect(records[0]!.model).toBe("gpt-5-mini"); + }); + + it("skips non-copilot sessions", () => { + const db = createDb(); + insertSession(db, { + id: "ses-1", + modelProviderID: "openai", + modelID: "gpt-5.5", + tokensInput: 1000, + tokensOutput: 500, + }); + db.close(); + + const { records } = parseOpenCode(dbPath); + expect(records).toHaveLength(0); + }); + + it("skips sessions with zero tokens", () => { + const db = createDb(); + insertSession(db, { + id: "ses-1", + modelProviderID: "github-copilot", + modelID: "gpt-5-mini", + tokensInput: 0, + tokensOutput: 0, + }); + db.close(); + + const { records } = parseOpenCode(dbPath); + expect(records).toHaveLength(0); + }); + + it("filters session records by sinceMs", () => { + const db = createDb(); + insertSession(db, { + id: "ses-old", + modelProviderID: "github-copilot", + modelID: "gpt-5-mini", + tokensInput: 500, + tokensOutput: 300, + timeCreated: 1_000_000, + }); + insertSession(db, { + id: "ses-new", + modelProviderID: "github-copilot", + modelID: "gpt-5-mini", + tokensInput: 1000, + tokensOutput: 500, + timeCreated: 3_000_000, + }); + db.close(); + + const { records } = parseOpenCode(dbPath, 2_000_000); + expect(records).toHaveLength(1); + expect(records[0]!.sessionId).toBe("ses-new"); + }); + + it("marks session as compaction when time_compacting is set", () => { + const db = createDb(); + insertSession(db, { + id: "ses-1", + modelProviderID: "github-copilot", + modelID: "gpt-5-mini", + tokensInput: 500, + tokensOutput: 300, + timeCompacting: 2_000_000, + }); + db.close(); + + const { records } = parseOpenCode(dbPath); + expect(records[0]!.isCompaction).toBe(true); + }); +}); diff --git a/src/opencode.ts b/src/opencode.ts index 2553f74..72eeeea 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -4,10 +4,32 @@ import { opencodeDbPaths } from "./paths.js"; import type { SourceFinding, ToolFinding, UsageRecord } from "./types.js"; import type { SourceParseResult } from "./source.js"; +type SessionRow = { + id: string; + model: string | null; + tokens_input: number; + tokens_output: number; + tokens_reasoning: number; + tokens_cache_read: number; + tokens_cache_write: number; + cost: number; + time_created: number; + agent: string | null; + time_compacting: number | null; +}; + export function defaultOpenCodeDbPaths(): string[] { return opencodeDbPaths(); } +function safeJsonParse(input: string): Record | undefined { + try { + return JSON.parse(input) as Record; + } catch { + return undefined; + } +} + function isCopilotProvider(provider?: string, model?: string): boolean { return ( provider === "github-copilot" || @@ -195,8 +217,73 @@ export function parseOpenCode( }); } + // ── Session-level fallback (OpenCode 1.16.0+) ────────────────────────── + // OpenCode 1.16.0 may store token data at the session level rather than + // in per-message `data` JSON (GitHub Copilot token-based billing update). + // For sessions where per-message tokens sum to zero but the session table + // has non-zero token counts, replace the zero-token message records with + // a single session-level aggregate record. + const sessionTokenTotals = new Map(); + for (const r of records) { + const sid = r.sessionId; + if (!sid) continue; + const prev = sessionTokenTotals.get(sid) ?? 0; + sessionTokenTotals.set(sid, prev + r.inputTokens + r.outputTokens); + } + + const sessionStmt = db.prepare(` + SELECT id, model, tokens_input, tokens_output, tokens_cache_read, + tokens_cache_write, cost, time_created, agent, time_compacting + FROM session + WHERE (tokens_input > 0 OR tokens_output > 0) + ${sinceMs ? "AND time_created >= ?" : ""} + `); + const sessionRows = ( + sinceMs !== undefined ? sessionStmt.all(sinceMs) : sessionStmt.all() + ) as SessionRow[]; + + for (const row of sessionRows) { + const modelData: { providerID?: string; id?: string } | undefined = + typeof row.model === "string" && row.model.length > 0 + ? safeJsonParse(row.model) + : undefined; + const provider = modelData?.providerID; + const modelId = modelData?.id; + if (!isCopilotProvider(provider, modelId)) continue; + + const hasMsgTokens = (sessionTokenTotals.get(row.id) ?? 0) > 0; + if (hasMsgTokens) continue; + + // Remove any zero-token message-level records for this session + const before = records.length; + for (let i = records.length - 1; i >= 0; i--) { + if (records[i]!.sessionId === row.id) { + records.splice(i, 1); + } + } + + records.push({ + source: "opencode", + sourcePath: path, + sessionId: row.id, + messageId: undefined, + parentId: undefined, + timestamp: row.time_created ?? undefined, + provider: String(provider ?? modelId ?? ""), + model: String(modelId ?? "").replace(/^github-copilot\//, ""), + inputTokens: row.tokens_input ?? 0, + outputTokens: row.tokens_output ?? 0, + cacheReadTokens: row.tokens_cache_read ?? 0, + cacheWriteTokens: row.tokens_cache_write ?? 0, + calls: 1, + mode: undefined, + agent: row.agent ?? undefined, + isCompaction: row.time_compacting !== null, + }); + } + const copilotSessions = new Set( - records.map((record) => record.sessionId).filter(Boolean) + records.map((r) => r.sessionId).filter(Boolean) ); if (copilotSessions.size > 0) { type PartRow = { diff --git a/src/report.ts b/src/report.ts index 37eaec1..52b042d 100644 --- a/src/report.ts +++ b/src/report.ts @@ -253,21 +253,14 @@ export function renderConsole(summary: Summary): string { }, { header: "Remaining", - value: (row) => { - const remaining = row.includedCredits - row.usedCredits; - return remaining < 0 - ? fmtCredits(Math.abs(remaining)) - : fmtCredits(remaining); - }, + value: (row) => fmtCredits(row.includedCredits - row.usedCredits), align: "right", }, { - header: "%", + header: "Used %", value: (row) => { - const pct = - ((row.includedCredits - row.usedCredits) / row.includedCredits) * - 100; - return `${pct < 0 ? "-" : ""}${fmt(Math.abs(pct), 1)}%`; + const pct = (row.usedCredits / row.includedCredits) * 100; + return `${fmt(pct, 1)}%`; }, align: "right", },