From 19c04219260e41350b7ff969833eca7a30068b48 Mon Sep 17 00:00:00 2001 From: "Robert E. Lee" Date: Wed, 29 Jul 2026 13:30:24 -0700 Subject: [PATCH] feat(usage): opencode transcript source for the usage scorecard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scorecard read claude/codex transcripts only; opencode sessions did not exist anywhere in usage/cost attribution — the biggest remaining 'second-class' remnant of the third host. opencode persists everything the index needs in ~/.local/share/opencode/opencode.db (SQLite): session rows with title/directory/parent/tokens/cost, message rows with role/tokens/cost/modelID/providerID, part rows with text/tool payloads. usage-opencode.mjs maps those into the index's per-session record shape (read-only via node:sqlite; malformed rows skipped, never fatal; absent db reads as no source). Two attribution rules, both grounded in the store: - COST: assistant rows carry opencode's OWN metered cost — used as observed truth (costObserved on usage rows), preferred over the pricing table in aggregate and in the transcript header. Rows where NO message carried a cost stay null so the table applies — never a fabricated $0 for kimi/openrouter/local rates ak must not invent. - PROVIDER: the assistant row's providerID is the inference provider when observed (provenance 'observed'), never the host. byProvider gains opencode/openrouter/local buckets alongside the existing ones. Integration: per-session incremental cache keys (latest message time + count — a warm refresh re-parses only sessions that gained messages); carry-forward keyed on row existence; roots.opencode test seam with the same hermeticity rule as the codex ledger (overridden roots never read the real store); readSession returns the meta+turns payload through a factored sessionPayload shared with the JSONL parsers; SCHEMA_VERSION 7. Subagent sessions (parent_id) keep their own metered usage — opencode child sessions are not parent replays, so the codex double-count rule does not apply. The dashboard session rows badge opencode correctly with no client change (sx.host was already preferred). Verified: 14 new tests (module + scan/aggregate/readSession integration); pnpm run check exit 0 (1061 kit + cjs); pnpm run test:surface exit 0 (25); doc citations remapped. Live against the real store: 35 opencode sessions indexed next to 1547 claude + 745 codex, byHost and byProvider buckets all populated, observed kimi metering per session, uncosted rows falling back to the pricing table. Refs #59 (the observability axis this plugs into; opencode-side ingestion is additive and leaves OpenRouter attribution to that issue). --- docs/TRANSCRIPTS.md | 12 +- docs/USAGE-SCORECARD-METRICS.md | 16 +- src/lib/usage-index.mjs | 85 +++++++-- src/lib/usage-opencode.mjs | 222 ++++++++++++++++++++++++ tests/kit/usage-index-opencode.test.mjs | 205 ++++++++++++++++++++++ tests/kit/usage-opencode.test.mjs | 217 +++++++++++++++++++++++ 6 files changed, 729 insertions(+), 28 deletions(-) create mode 100644 src/lib/usage-opencode.mjs create mode 100644 tests/kit/usage-index-opencode.test.mjs create mode 100644 tests/kit/usage-opencode.test.mjs diff --git a/docs/TRANSCRIPTS.md b/docs/TRANSCRIPTS.md index bfe325d..0b515bd 100644 --- a/docs/TRANSCRIPTS.md +++ b/docs/TRANSCRIPTS.md @@ -37,7 +37,7 @@ rewritten; rule 3 of the module header, `usage-index.mjs:22-29`): | Host | Store | Discovered by | |---|---|---| | Claude Code | `~/.claude/projects//.jsonl` | `listClaude` (`usage-index.mjs:684`) — exactly one level of project directories | -| Codex CLI | `~/.codex/sessions///
/rollout--.jsonl` | `listCodex` (`usage-index.mjs:663`) — the `yyyy/mm/dd` tree walk | +| Codex CLI | `~/.codex/sessions///
/rollout--.jsonl` | `listCodex` (`usage-index.mjs:705`) — the `yyyy/mm/dd` tree walk | Roots come from `defaultRoots()` (`usage-index.mjs:676`) and are injectable for tests. A malformed line is skipped, never fatal (`jsonLines`, @@ -96,7 +96,7 @@ The same parsers serve two very different callers, switched by `withTurns`: | Path | Entry point | `withTurns` | Message bodies | Cached? | |---|---|---|---|---| | **Scan** — the aggregate index behind the Scorecard/Findings/Sessions views | `buildIndex` → `parseFile` (`usage-index.mjs:693`) | `false` | never held — holding them would balloon memory across 3,000+ files (`usage-index.mjs:437-440`) | yes: per-file derived records in `~/.config/agentic-kit/usage-index.json`, keyed `(path, mtime, size)`, invalidated wholesale by `SCHEMA_VERSION` (`usage-index.mjs:51`) | -| **Reader** — one transcript for the Transcript view | `readSession` (`usage-index.mjs:1173`) | `true` | full turn list built | **never** — every call re-reads and re-parses the one file | +| **Reader** — one transcript for the Transcript view | `readSession` (`usage-index.mjs:1310`) | `true` | full turn list built | **never** — every call re-reads and re-parses the one file | ![Figure: one parser, two read paths — the scan path (withTurns false) caches per-file records keyed by path, mtime and size; the reader path (withTurns true) builds full turns and is never cached](assets/transcript-read-paths.svg) @@ -182,11 +182,11 @@ transcript content leaves the module, and every step is a gate: 1. **Id grammar before any filesystem access** — `VALID_ID` (`/^[A-Za-z0-9._-]{1,128}$/`, `usage-index.mjs:71`) rejects traversal - shapes with `ERR_INVALID_SESSION_ID` (`usage-index.mjs:1215`). -2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1222`), + shapes with `ERR_INVALID_SESSION_ID` (`usage-index.mjs:1260`). +2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1267`), consulting the scan cache when present but never requiring it — `readSession` works with no prior `buildIndex`. -3. **Realpath containment** (`usage-index.mjs:1180-1194`) — the resolved file +3. **Realpath containment** (`usage-index.mjs:1335-1349`) — the resolved file must live under a transcript root *after* `realpathSync` collapses symlinks; a symlink planted inside a root pointing at `/etc/anything` passes a lexical `startsWith` but fails this. Roots are realpath'd too so @@ -211,7 +211,7 @@ Every turn body is passed through `maskSecrets` (`usage-index.mjs:184` — the 23 secret shapes) **server-side, before serialization**, then length-capped at `MAX_TURN_CHARS` (40,000, `usage-index.mjs:65`) with the marker appended -(`usage-index.mjs:1339-1348`). Two invariants: +(`usage-index.mjs:1404-1414`). Two invariants: - **Presence is the signal.** `truncated`/`originalChars` are emitted only when the slice fired, so a complete turn cannot be misread as abridged. diff --git a/docs/USAGE-SCORECARD-METRICS.md b/docs/USAGE-SCORECARD-METRICS.md index eac670b..552cbc1 100644 --- a/docs/USAGE-SCORECARD-METRICS.md +++ b/docs/USAGE-SCORECARD-METRICS.md @@ -97,9 +97,9 @@ responses = Σ over included sessions of session.responses **Source:** - Filter: a parsed record with zero assistant turns is dropped entirely — "no - assistant turn → not a session" (`usage-index.mjs:877`) — and a record whose + assistant turn → not a session" (`usage-index.mjs:892`) — and a record whose last activity falls outside the requested window is dropped too - (`usage-index.mjs:878`). + (`usage-index.mjs:893`). - `responses` accumulation: Claude increments per assistant message (`usage-index.mjs:493`); Codex increments per `agent_message` event (`usage-index.mjs:631`). @@ -182,7 +182,7 @@ already in effect on the given day, comparing ISO date strings lexicographically so no `Date` parsing is involved and the module stays clock-free. -`aggregate()` passes each usage row's own `day` (`usage-index.mjs:877`), which +`aggregate()` passes each usage row's own `day` (`usage-index.mjs:892`), which it already has because rows are keyed by `(day, model)`. **This is the whole point:** tokens metered in August must still read as August's rate when the panel is opened in December. Pricing by *today's* date instead would restate a @@ -264,7 +264,7 @@ tokens = input + output + cacheRead + cacheWrite (summed across all rows in wi ``` **Source:** `t.tokens` from `totals`, accumulated per row at -`usage-index.mjs:824` (`rowTokens = row.input + row.output + row.cacheRead + +`usage-index.mjs:916` (`rowTokens = row.input + row.output + row.cacheRead + row.cacheWrite`) and rolled into `totals.tokens` via `addTo` (`usage-index.mjs:843-852`). Rendered with `fmtTok()` (`dashboard/client.mjs`): `≥1e9` → `"X.XB"`, `≥1e6` → `"X.XM"`, @@ -532,7 +532,7 @@ byModel[model].sessions = count of DISTINCT sessions whose s.models includes th ``` **Source:** cost/tokens/responses accumulate inside the usage-row loop -(`usage-index.mjs:814-831`); the `sessions` count is deliberately computed +(`usage-index.mjs:896-922`); the `sessions` count is deliberately computed **separately**, once per session over its `s.models` array (`usage-index.mjs:898-903`) rather than inside the cost loop, precisely **so that a model can appear in `byModel` — with a nonzero session count — @@ -544,7 +544,7 @@ excluded subagent-replay session still shows up as "used," at zero cost, rather than vanishing. `byModel[...].responses` is populated from `row.responses` -(`usage-index.mjs:829`), which in turn comes from the `responses` field +(`usage-index.mjs:921`), which in turn comes from the `responses` field passed into `addUsage()` at the call site — `1` per Claude assistant turn (`usage-index.mjs:484-490`), or `rec.responses` (the session's whole response count) once per Codex session, passed at the single point Codex calls @@ -880,7 +880,7 @@ Codex ≥0.140 maintains its own SQLite thread ledger (`~/.codex/state_N.sqlite` — the `N` is a migration generation, so `codexStateDb` (`codex-state.mjs:30`) globs and takes the newest). `readCodexState` (`:49`) reads per-thread `thread_source` (`user` vs `subagent`) plus `thread_spawn_edges`, and -`applyCodexLedger` (`usage-index.mjs:1179`) overlays that onto parsed +`applyCodexLedger` (`usage-index.mjs:1218`) overlays that onto parsed sessions: a ledger-identified subagent has its token usage stripped — its rollout replays the parent's entire token history (ccusage/ccusage#950 measured up to 91× inflation) — while the session record stays visible. The @@ -933,7 +933,7 @@ commit `540be18` on this branch. Claude's parser passes `responses: 1` per assistant turn (`usage-index.mjs:489`, as it existed before this fix), but Codex's call passed no such field at all. Because `byModel[model].responses` is summed -directly from each usage row's `responses` field (`usage-index.mjs:829`, +directly from each usage row's `responses` field (`usage-index.mjs:921`, `m.responses += row.responses`), **every** Codex model in §10's "Models in Play" list displayed `0 resp` regardless of real token/cost volume or actual `agent_message` count. **Fix:** `parseCodex` now passes `responses: diff --git a/src/lib/usage-index.mjs b/src/lib/usage-index.mjs index 29b8a99..71bc119 100644 --- a/src/lib/usage-index.mjs +++ b/src/lib/usage-index.mjs @@ -32,6 +32,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { configDir, claudeDir, codexDir } from './paths.mjs'; import { readCodexState } from './codex-state.mjs'; +import { defaultOpencodeDbPath, listSessions as listOpencodeSessions, parseSession as parseOpencodeSession, sessionExists as opencodeSessionExists } from './usage-opencode.mjs'; /** Bump to invalidate every cached entry wholesale. * v2: cached records carry `active` sub-intervals for the idle-gap split. @@ -52,8 +53,12 @@ import { readCodexState } from './codex-state.mjs'; * and `rateLimits` (the LAST rate-limit snapshot embedded in the * rollout's token_count events). A v5-cached Codex session carries * neither and must be re-derived, or the Limits history reads as empty - * for exactly the sessions that have data. */ -export const SCHEMA_VERSION = 6; + * for exactly the sessions that have data. + * v7: the opencode transcript source (usage-opencode.mjs) joins the index — + * SQLite-backed session/message/part rows mapped to the same record + * shape, with opencode's OWN metered cost carried as observed truth + * (`costObserved` on usage rows, preferred over the pricing table). */ +export const SCHEMA_VERSION = 7; /** Silence longer than this ends a stretch of engagement. A session is split * into active sub-intervals at gaps ABOVE this bound (exactly this much is not @@ -271,14 +276,15 @@ export function projectLabel(cwd, dirName) { return { project: parts.length ? parts[parts.length - 1] : 'unknown', worktree: null }; } -/** Sum a record's per-model usage rows into one API-equivalent cost. */ +/** Sum a record's per-model usage rows into one API-equivalent cost. Rows with + * an observed transcript cost (opencode) use it — same preference as aggregate. */ function sessionCost(rec, deps) { let cost = 0; for (const row of rec.usage ?? []) { - cost += deps.costOf({ + cost += row.costObserved != null ? row.costObserved : (deps.costOf({ model: row.model, provider: rec.provider, input: row.input, output: row.output, cacheRead: row.cacheRead, cacheWrite: row.cacheWrite, - }) || 0; + }) || 0); } return round(cost); } @@ -727,6 +733,15 @@ function codexIdFromName(name) { } function parseFile(entry) { + if (entry.provider === 'opencode') { + try { + const parsed = parseOpencodeSession({ dbFile: entry.dbFile, id: entry.id }); + // Title hygiene matches the JSONL parsers: the cached index lands on + // disk, so the same secrets mask applies here. + if (parsed?.session) parsed.session.title = maskSecrets(parsed.session.title); + return parsed; + } catch { return null; } // a parser bug must not cost the user their whole index + } let raw; try { raw = fs.readFileSync(entry.file, 'utf8'); } catch { return null; } try { @@ -886,10 +901,14 @@ function aggregate(records, { days, now, cutoff, deps }) { // period ending 2026-09-01) must not retroactively restate a finished // window — August's spend was metered at August's rate and has to keep // reading that way. Rows are already keyed by day, so this costs nothing. - const rowCost = deps.costOf({ + // costObserved (opencode): the transcript's OWN metered figure for the + // row, when present, outranks the pricing table — observed truth beats a + // rate ak must guess (kimi/openrouter/local). null means no observation + // and the table applies, never a fabricated $0. + const rowCost = row.costObserved != null ? row.costObserved : (deps.costOf({ model: row.model, provider: rec.provider, day: row.day, input: row.input, output: row.output, cacheRead: row.cacheRead, cacheWrite: row.cacheWrite, - }) || 0; + }) || 0); input += row.input; output += row.output; cacheRead += row.cacheRead; cacheWrite += row.cacheWrite; cost += rowCost; @@ -1069,7 +1088,7 @@ function notify(onProgress, payload) { * @property {number} [days] window size in days (default 14) * @property {boolean} [force] ignore cached per-file entries * @property {Function} [onProgress] called with { scanned, total, phase } - * @property {{claude?: string, codex?: string}} [roots] override transcript roots (tests) + * @property {{claude?: string, codex?: string, opencode?: string}} [roots] override transcript roots (tests; opencode = the SQLite store path) * @property {string} [cachePath] override the index cache location (tests) * @property {number} [now] override "now" (tests) * @property {number} [maxAgeMs] readIndex only: memo TTL @@ -1108,6 +1127,20 @@ async function scan(o = {}) { .map((e) => ({ ...e, stat: statSafe(e.file) })) .filter((e) => e.stat && e.stat.mtimeMs >= cutoff); + // opencode transcript source (one SQLite store, per-session cache keys). + // Same hermeticity rule as the codex ledger below: overridden roots imply + // the REAL store is the wrong one — only default-root scans (or an explicit + // roots.opencode path) read it. + const ocDb = o.roots === undefined ? defaultOpencodeDbPath() : (roots?.opencode ?? null); + if (ocDb && fs.existsSync(ocDb)) { + for (const e of listOpencodeSessions({ dbFile: ocDb, cutoffMs: cutoff })) { + candidates.push({ + file: `opencode://${e.id}`, provider: 'opencode', id: e.id, dbFile: ocDb, + stat: { mtimeMs: e.mtimeMs, size: e.size }, + }); + } + } + const cache = force ? null : readCache(cacheFile); const entries = {}; const records = []; @@ -1124,7 +1157,7 @@ async function scan(o = {}) { session = parsed ? parsed.session : null; } if (session) { - entries[c.file] = { ...key, session }; + entries[c.file] = { ...key, session, ...(c.dbFile ? { dbFile: c.dbFile } : {}) }; records.push(session); } scanned++; @@ -1143,7 +1176,13 @@ async function scan(o = {}) { const lastActivity = e.session.end ?? e.session.start; // No timestamp at all → can't judge age; keep it rather than guess. if (lastActivity != null && now - lastActivity > KEEP_MS) continue; - if (statSafe(file)) entries[file] = e; + // opencode pseudo-keys are not files: existence means "row still in the store". + if (file.startsWith('opencode://')) { + const dbFile = e.dbFile ?? ocDb; + if (dbFile && opencodeSessionExists({ dbFile, id: file.slice('opencode://'.length) })) { + entries[file] = { ...e, dbFile }; + } + } else if (statSafe(file)) entries[file] = e; } } writeCache(cacheFile, { schemaVersion: SCHEMA_VERSION, updatedAt: new Date(now).toISOString(), entries }); @@ -1271,6 +1310,19 @@ function locate(id, r, cacheFile) { export async function readSession(id, o = {}) { if (typeof id !== 'string' || !VALID_ID.test(id)) throw invalidId(id); const r = { ...defaultRoots(), ...(o.roots ?? {}) }; + + // opencode sessions live in the SQLite store, not a JSONL file — resolve + // them before the file-locating path (pseudo-key opencode://). + const ocDb = o.roots === undefined ? defaultOpencodeDbPath() : (o.roots?.opencode ?? null); + if (ocDb && fs.existsSync(ocDb) && opencodeSessionExists({ dbFile: ocDb, id })) { + const parsed = parseOpencodeSession({ dbFile: ocDb, id, withTurns: true }); + if (parsed) { + const rec = parsed.session; + rec.title = maskSecrets(rec.title); + return sessionPayload(rec, parsed.turns); + } + } + const found = locate(id, r, o.cachePath ?? defaultCachePath()); if (!found) return null; @@ -1308,8 +1360,13 @@ export async function readSession(id, o = {}) { : parseClaude(raw, { id, dirName: found.dirName, withTurns: true }); } catch { return null; } - const rec = parsed.session; - const usage = rec.usage.reduce((a, row) => ({ + return sessionPayload(parsed.session, parsed.turns, await loadDeps(o.deps)); +} + +/** The /api/session payload for any parsed record (claude, codex, opencode): + * meta with pricer-backed cost, and secret-masked, truncation-signalled turns. */ +function sessionPayload(rec, turns, deps) { + const usage = (rec.usage ?? []).reduce((a, row) => ({ input: a.input + row.input, output: a.output + row.output, cacheRead: a.cacheRead + row.cacheRead, cacheWrite: a.cacheWrite + row.cacheWrite, }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); @@ -1334,7 +1391,7 @@ export async function readSession(id, o = {}) { // left undefined: the transcript header rendered a hardcoded "$0.00" on a // panel whose whole subject is cost. `.filter(Boolean)` could not drop it // because fmtUsd(undefined) is the truthy string "$0.00". - cost: sessionCost(rec, await loadDeps(o.deps)), + cost: sessionCost(rec, deps), ...usage, tokens: usage.input + usage.output + usage.cacheRead + usage.cacheWrite, }, // ADR-0009 §8: truncation is the other way content is withheld, and it used @@ -1344,7 +1401,7 @@ export async function readSession(id, o = {}) { // turn cannot be misread as an abridged one. `originalChars` is measured // after `maskSecrets`, so it describes loss due to truncation alone — it is // not a raw-file length, and must not be rendered as one. - turns: parsed.turns.map((t) => { + turns: (turns ?? []).map((t) => { const text = maskSecrets(t.text); const originalChars = text.length; if (originalChars <= MAX_TURN_CHARS) return { ...t, text }; diff --git a/src/lib/usage-opencode.mjs b/src/lib/usage-opencode.mjs new file mode 100644 index 0000000..a0805b4 --- /dev/null +++ b/src/lib/usage-opencode.mjs @@ -0,0 +1,222 @@ +// usage-opencode.mjs — the opencode transcript source for the usage scorecard +// (ADR-0009's index, third source alongside the claude/codex JSONL roots). +// +// opencode persists sessions in a single SQLite store: +// ~/.local/share/opencode/opencode.db +// session(id, project_id, parent_id, slug, directory, title, cost, +// tokens_input/output/reasoning/cache_read/cache_write, time_*) +// message(id, session_id, time_created, data JSON) — role/tokens/cost/model +// part(id, message_id, data JSON) — text/tool/reasoning +// +// The store is opened READ-ONLY (node:sqlite via sqlite.mjs) and never +// rewritten; a malformed row is skipped, never fatal; and nothing here throws +// on bad input — an absent/corrupt db simply reads as "no opencode source". +// +// Two attribution rules, grounded in the store itself: +// - COST is opencode's own metered figure on each assistant message +// (data.cost). That is OBSERVED truth, so usage rows carry it as +// `costObserved` and the aggregate prefers it over the pricing table — +// never re-priced from a guessed rate (kimi/openrouter/local rates are +// exactly what ak does not know and must not invent). +// - INFERENCE PROVIDER is the assistant row's providerID when observed +// (provenance 'observed'), never the host. A bare `opencode` host id says +// nothing about who served the model. +// Subagent sessions (parent_id set) keep their tokens: opencode child sessions +// record their OWN messages, not a replay of the parent's — the codex +// double-count rule does not apply (different storage semantics). +import { withDb } from './sqlite.mjs'; + +/** The live opencode store. Overridable via roots in tests. */ +export function defaultOpencodeDbPath() { + const home = process.env.XDG_DATA_HOME ?? null; + return home + ? `${home}/opencode/opencode.db` + : `${process.env.HOME ?? process.env.USERPROFILE}/.local/share/opencode/opencode.db`; +} + +/** YYYY-MM-DD in LOCAL time (same convention as usage-index's localDay). */ +function localDay(ms) { + const d = new Date(ms); + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} + +/** Punchcard key: day-of-week (0=Mon) + hour (same convention as punchKey). */ +function punchKey(ms) { + const d = new Date(ms); + return `${(d.getDay() + 6) % 7}-${d.getHours()}`; +} + +const clip = (text, max = 100) => { + const t = String(text ?? '').replace(/\s+/g, ' ').trim(); + return t.length > max ? `${t.slice(0, max - 1)}…` : t; +}; + +/** Split activity timestamps into engaged intervals at the same 15-min gap + * the index uses (kept in lockstep with usage-index.IDLE_GAP_MS). */ +const IDLE_GAP_MS = 15 * 60 * 1000; +function activeIntervals(stamps) { + const ts = stamps.filter(Number.isFinite).sort((a, b) => a - b); + if (!ts.length) return []; + const out = []; + let start = ts[0]; + let prev = ts[0]; + for (let i = 1; i < ts.length; i++) { + if (ts[i] - prev > IDLE_GAP_MS) { out.push([start, prev]); start = ts[i]; } + prev = ts[i]; + } + out.push([start, prev]); + return out; +} + +const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0); +const parseJson = (raw) => { try { return JSON.parse(raw); } catch { return null; } }; + +/** Incremental candidates: sessions whose latest message lands at/after + * cutoffMs. `mtimeMs` (latest message time) + `size` (message count) are the + * cache key — a warm refresh re-parses only sessions that gained messages. + * @param {{ dbFile: string, cutoffMs?: number }} opts */ +export function listSessions({ dbFile, cutoffMs = 0 }) { + return withDb(dbFile, (db) => db.prepare(` + SELECT s.id AS id, COALESCE(MAX(m.time_created), s.time_created) AS mtime, + COUNT(m.id) AS messages + FROM session s LEFT JOIN message m ON m.session_id = s.id + GROUP BY s.id + HAVING mtime >= ? + ORDER BY mtime DESC + `).all(cutoffMs).map((r) => ({ id: r.id, mtimeMs: num(r.mtime), size: num(r.messages) })), []); +} + +/** Carry-forward existence probe (a session can be deleted between scans). */ +export function sessionExists({ dbFile, id }) { + return withDb(dbFile, (db) => !!db.prepare('SELECT 1 FROM session WHERE id = ?').get(id), false); +} + +/** Project label from the session's working directory: basename, with the + * worktree marker convention (…//worktrees/) preserved. */ +function projectFromDirectory(directory) { + const segs = String(directory ?? '').split(/[\\/]+/).filter(Boolean); + for (let i = 1; i < segs.length - 1; i++) { + if (['.git'].includes(segs[i]) && segs[i + 1] === 'worktrees') { + return { project: segs[i - 1] ?? 'unknown', worktree: segs.slice(i + 2).join('/') || null }; + } + } + const base = segs[segs.length - 1]; + return { project: base && base !== '.' ? base : 'unknown', worktree: null }; +} + +/** Parse ONE opencode session into the index's per-session record shape. + * Returns { session, turns } mirroring parseClaude/parseCodex exactly; + * null when the session is gone or unreadable. withTurns emits the + * transcript-view turn rows alongside the record. + * @param {{ dbFile: string, id: string, withTurns?: boolean }} opts */ +export function parseSession({ dbFile, id, withTurns = false }) { + return withDb(dbFile, (db) => { + const srow = db.prepare('SELECT * FROM session WHERE id = ?').get(id); + if (!srow) return null; + const msgRows = db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC').all(id); + const partRows = withTurns + ? db.prepare(` + SELECT p.message_id AS message_id, p.data AS data + FROM part p JOIN message m ON m.id = p.message_id + WHERE m.session_id = ? ORDER BY p.rowid ASC + `).all(id) + : []; + const partsByMessage = new Map(); + for (const p of partRows) { + const data = parseJson(p.data); + if (!data) continue; + if (!partsByMessage.has(p.message_id)) partsByMessage.set(p.message_id, []); + partsByMessage.get(p.message_id).push(data); + } + + const { project, worktree } = projectFromDirectory(srow.directory); + const rec = { + id: srow.id, provider: 'opencode', host: 'opencode', + inferenceProvider: null, providerProvenance: 'unknown', + title: clip(srow.title) || '(untitled)', project, start: null, end: null, + prompts: 0, responses: 0, exceptions: 0, sidechain: !!srow.parent_id, + threadSource: srow.parent_id ? 'subagent' : null, + models: [], tools: {}, skill: null, plugin: null, + worktree: worktree ?? null, usage: [], punchcard: {}, active: [], stamps: [], + reasoningOutput: 0, rateLimits: null, + }; + if (worktree) rec.worktree = worktree; + const turns = []; + let lastProviderId = null; + + for (const row of msgRows) { + const data = parseJson(row.data); + if (!data || typeof data.role !== 'string') continue; // malformed row: skipped, never fatal + const at = num(data.time?.created) || num(row.time_created); + if (at) { rec.stamps.push(at); if (rec.start === null || at < rec.start) rec.start = at; if (rec.end === null || at > rec.end) rec.end = at; } + + if (data.role === 'user') { + rec.prompts++; + if (withTurns) { + const text = (partsByMessage.get(row.id) ?? []) + .filter((p) => p.type === 'text' && typeof p.text === 'string') + .map((p) => p.text).join('\n'); + turns.push({ role: 'user', at: new Date(at).toISOString(), text, prompt: true, kind: 'prompt' }); + } + continue; + } + if (data.role !== 'assistant') continue; + + rec.responses++; + if (at) { const pk = punchKey(at); rec.punchcard[pk] = (rec.punchcard[pk] ?? 0) + 1; } + const model = typeof data.modelID === 'string' && data.modelID ? data.modelID : 'unknown'; + if (!rec.models.includes(model)) rec.models.push(model); + if (typeof data.providerID === 'string' && data.providerID) lastProviderId = data.providerID; + + const t = data.tokens ?? {}; + const cache = t.cache ?? {}; + const day = localDay(at || Date.now()); + let usageRow = rec.usage.find((r) => r.day === day && r.model === model); + if (!usageRow) { + usageRow = { day, model, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, responses: 0, costObserved: null }; + rec.usage.push(usageRow); + } + usageRow.input += num(t.input); + usageRow.output += num(t.output); + usageRow.cacheRead += num(cache.read); + usageRow.cacheWrite += num(cache.write); + usageRow.responses += 1; + // opencode's OWN metered cost for this message — observed truth, summed + // per (day, model) row. Rows where NO message carried a cost stay null, + // so the aggregate falls back to the pricing table rather than + // misreporting a fabricated $0. + if (Number.isFinite(Number(data.cost))) usageRow.costObserved = (usageRow.costObserved ?? 0) + Number(data.cost); + rec.reasoningOutput += num(t.reasoning); + + if (withTurns) { + const parts = partsByMessage.get(row.id) ?? []; + const tools = parts.filter((p) => p.type === 'tool' && typeof p.tool === 'string').map((p) => p.tool); + for (const name of tools) rec.tools[name] = (rec.tools[name] ?? 0) + 1; + const text = parts + .filter((p) => (p.type === 'text' || p.type === 'reasoning') && typeof p.text === 'string') + .map((p) => p.text).join('\n'); + turns.push({ role: 'assistant', at: new Date(at).toISOString(), model, text, tools }); + } + } + + // Tool counts without the full turn payload: one lean query on the scan path. + if (!withTurns) { + const toolRows = db.prepare(` + SELECT p.data AS data FROM part p JOIN message m ON m.id = p.message_id + WHERE m.session_id = ? AND json_extract(p.data, '$.type') = 'tool' + `).all(id); + for (const p of toolRows) { + const data = parseJson(p.data); + const name = typeof data?.tool === 'string' ? data.tool : null; + if (name) rec.tools[name] = (rec.tools[name] ?? 0) + 1; + } + } + + if (lastProviderId) { rec.inferenceProvider = lastProviderId; rec.providerProvenance = 'observed'; } + if (!rec.title) rec.title = '(untitled)'; + rec.active = activeIntervals(rec.stamps); + delete rec.stamps; + return { session: rec, turns }; + }, null); +} diff --git a/tests/kit/usage-index-opencode.test.mjs b/tests/kit/usage-index-opencode.test.mjs new file mode 100644 index 0000000..129e2c9 --- /dev/null +++ b/tests/kit/usage-index-opencode.test.mjs @@ -0,0 +1,205 @@ +// usage-index × opencode — the third transcript source through scan(), +// aggregate(), and readSession(). Hermetic: fixture claude/codex corpora and a +// fixture opencode.db, all in tmp; injected pricing/classification stubs so +// the arithmetic is exact. The real stores are never touched (the roots seam +// is also what is under test: overridden roots must NOT read the real db). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +const NOW = Date.parse('2026-07-29T12:00:00Z'); +const DAY = 86_400_000; +const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); +const rm = (d) => fs.rmSync(d, { recursive: true, force: true }); + +const { buildIndex, readSession, _resetForTest } = await import('../../src/lib/usage-index.mjs'); + +/** Pricing stub: prices EVERY token at 1/1000 — deliberately different from + * the fixture's observed costs so the preference is provable. */ +const deps = () => ({ + costOf: ({ input, output, cacheRead, cacheWrite }) => (input + output + cacheRead + cacheWrite) / 1000, + pricesAsOf: '2026-07-01', + classify: ({ title }) => (title + ? { category: 'Build', confidence: 0.9, basis: 'title+tools' } + : { category: 'Unclassified', confidence: 0, basis: 'no signal' }), + detectInsights: () => [], +}); + +const userMsg = (id, sessionId, at) => ({ + id, sessionId, at, data: { role: 'user', time: { created: at }, agent: 'build' }, +}); +const assistantMsg = (id, sessionId, at, { model = 'kimi-k3', provider = 'opencode', cost = null, tokens = {} } = {}) => ({ + id, sessionId, at, + data: { + role: 'assistant', agent: 'build', modelID: model, providerID: provider, + tokens: { input: 1000, output: 100, reasoning: 10, cache: { read: 200, write: 10 }, ...tokens }, + ...(cost != null ? { cost } : {}), + time: { created: at, completed: at + 1000 }, finish: 'stop', + }, +}); + +function buildDb(file, { sessions = [], messages = [] } = {}) { + const db = new DatabaseSync(file); + db.exec(` + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, + parent_id text, slug text NOT NULL, directory text NOT NULL, path text, title text NOT NULL, + version text NOT NULL, share_url text, summary_additions integer, summary_deletions integer, + summary_files integer, summary_diffs text, metadata text, cost real DEFAULT 0 NOT NULL, + tokens_input integer DEFAULT 0 NOT NULL, tokens_output integer DEFAULT 0 NOT NULL, + tokens_reasoning integer DEFAULT 0 NOT NULL, tokens_cache_read integer DEFAULT 0 NOT NULL, + tokens_cache_write integer DEFAULT 0 NOT NULL, revert text, permission text, agent text, + model text, time_created integer NOT NULL, time_updated integer NOT NULL, + time_compacting integer, time_archived integer); + CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + CREATE INDEX message_session_time_created_id_idx ON message (session_id, time_created, id); + CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `); + const insS = db.prepare('INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); + const insM = db.prepare('INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'); + for (const s of sessions) insS.run(s.id, 'proj-1', s.parentId ?? null, 'slug-x', s.directory, s.title, '1.18.8', s.timeCreated ?? NOW - DAY, s.timeUpdated ?? NOW - DAY); + for (const m of messages) insM.run(m.id, m.sessionId, m.at, m.at, JSON.stringify(m.data)); + db.close(); + return file; +} + +/** A sandbox: empty claude/codex corpora + a fixture opencode.db + cache path. */ +function sandbox({ sessions = [], messages = [] } = {}) { + const dir = tmp('ak-uio-'); + fs.mkdirSync(path.join(dir, 'corpus', 'claude'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'corpus', 'codex'), { recursive: true }); + const dbFile = buildDb(path.join(dir, 'corpus', 'opencode.db'), { sessions, messages }); + return { + dir, dbFile, + roots: { + claude: path.join(dir, 'corpus', 'claude'), + codex: path.join(dir, 'corpus', 'codex'), + opencode: dbFile, + }, + cachePath: path.join(dir, 'cache', 'usage-index.json'), + }; +} + +const opts = (sb, extra = {}) => ({ days: 14, now: NOW, roots: sb.roots, cachePath: sb.cachePath, deps: deps(), ...extra }); + +test('scan aggregates opencode sessions: host bucket, provider bucket, tokens, and OBSERVED cost preferred over the pricing stub', async () => { + const at = NOW - DAY; + const sb = sandbox({ + sessions: [{ id: 'ses_oc1', directory: '/home/me/oc-proj', title: 'opencode session', timeCreated: at }], + messages: [ + userMsg('u1', 'ses_oc1', at), + assistantMsg('a1', 'ses_oc1', at + 1000, { cost: 0.25 }), + assistantMsg('a2', 'ses_oc1', at + 2000, { cost: 0.25 }), + ], + }); + const agg = await buildIndex(opts(sb)); + const s = agg.sessions.find((x) => x.id === 'ses_oc1'); + assert.ok(s, 'opencode session in the index'); + assert.equal(s.host, 'opencode'); + assert.equal(s.provider, 'opencode', 'providerID observed from the assistant rows'); + assert.equal(s.providerProvenance, 'observed'); + assert.equal(s.project, 'oc-proj'); + assert.equal(s.responses, 2); + assert.equal(s.input, 2000); + assert.equal(s.output, 200); + assert.equal(s.cacheRead, 400); + assert.equal(s.cacheWrite, 20); + assert.equal(s.tokens, 2620); + assert.equal(s.cost, 0.5, 'the transcript\'s own metered cost, not the stub price (would be 2.62)'); + assert.ok(agg.byHost.opencode, 'byHost gains the opencode bucket'); + assert.equal(agg.byHost.opencode.cost, 0.5); + assert.ok(agg.byProvider.opencode, 'byProvider gains the observed provider bucket'); + assert.equal(agg.totals.cost, 0.5); + assert.equal(agg.byModel['kimi-k3'].cost, 0.5); + rm(sb.dir); +}); + +test('sessions with NO observed cost fall back to the pricing table (never a fabricated $0)', async () => { + const at = NOW - DAY; + const sb = sandbox({ + sessions: [{ id: 'ses_oc2', directory: '/x', title: 'uncosted', timeCreated: at }], + messages: [userMsg('u1', 'ses_oc2', at), assistantMsg('a1', 'ses_oc2', at + 1000)], // no cost + }); + const agg = await buildIndex(opts(sb)); + const s = agg.sessions.find((x) => x.id === 'ses_oc2'); + assert.equal(s.cost, (1000 + 100 + 200 + 10) / 1000, 'pricing stub applies when nothing was observed'); + rm(sb.dir); +}); + +test('the incremental cache: a warm scan reuses unchanged sessions and picks up new messages', async () => { + const at = NOW - DAY; + const sb = sandbox({ + sessions: [{ id: 'ses_oc3', directory: '/x', title: 'cached', timeCreated: at }], + messages: [userMsg('u1', 'ses_oc3', at), assistantMsg('a1', 'ses_oc3', at + 1000, { cost: 0.1 })], + }); + const first = await buildIndex(opts(sb)); + assert.equal(first.sessions.find((x) => x.id === 'ses_oc3').cost, 0.1); + + // a new message arrives in the store + const db = new DatabaseSync(sb.dbFile); + db.prepare('INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)') + .run('a2', 'ses_oc3', at + 2000, at + 2000, JSON.stringify(assistantMsg('a2', 'ses_oc3', at + 2000, { cost: 0.4 }).data)); + db.close(); + + const second = await buildIndex(opts(sb)); + const s = second.sessions.find((x) => x.id === 'ses_oc3'); + assert.equal(s.cost, 0.5, 'new message re-parses that session (mtime+count key changed)'); + assert.equal(s.responses, 2); + rm(sb.dir); +}); + +test('overridden roots WITHOUT an opencode key never read any opencode store (hermeticity)', async () => { + const at = NOW - DAY; + const sb = sandbox({ + sessions: [{ id: 'ses_oc4', directory: '/x', title: 'hidden', timeCreated: at }], + messages: [userMsg('u1', 'ses_oc4', at), assistantMsg('a1', 'ses_oc4', at + 1000, { cost: 9 })], + }); + const roots = { claude: sb.roots.claude, codex: sb.roots.codex }; // no opencode key + const agg = await buildIndex({ days: 14, now: NOW, roots, cachePath: sb.cachePath, deps: deps() }); + assert.equal(agg.sessions.find((x) => x.id === 'ses_oc4'), undefined, + 'a scan with overridden roots must not reach the opencode store implicitly'); + rm(sb.dir); +}); + +test('readSession returns the meta + turns payload for an opencode session', async () => { + const at = NOW - DAY; + const sb = sandbox({ + sessions: [{ id: 'ses_oc5', directory: '/x', title: 'transcript view', timeCreated: at }], + messages: [ + userMsg('u1', 'ses_oc5', at), + assistantMsg('a1', 'ses_oc5', at + 1000, { cost: 0.25 }), + ], + }); + const db = new DatabaseSync(sb.dbFile); + const insP = db.prepare('INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)'); + insP.run('p1', 'u1', 'ses_oc5', at, at, JSON.stringify({ type: 'text', text: 'build the widget' })); + insP.run('p2', 'a1', 'ses_oc5', at + 1000, at + 1000, JSON.stringify({ type: 'tool', tool: 'edit', callID: 'e1', state: {} })); + insP.run('p3', 'a1', 'ses_oc5', at + 1001, at + 1001, JSON.stringify({ type: 'text', text: 'widget built' })); + db.close(); + + const out = await readSession('ses_oc5', { roots: sb.roots }); + assert.ok(out, 'payload returned'); + assert.equal(out.meta.host, 'opencode'); + assert.equal(out.meta.title, 'transcript view'); + assert.equal(out.meta.cost, 0.25, 'transcript header prices from the observed row'); + assert.equal(out.meta.tools.edit, 1); + assert.equal(out.turns.length, 2); + assert.match(out.turns[0].text, /build the widget/); + assert.deepEqual(out.turns[1].tools, ['edit']); + rm(sb.dir); +}); + +test('an opencode session with zero assistant responses never reaches the aggregate', async () => { + const at = NOW - DAY; + const sb = sandbox({ + sessions: [{ id: 'ses_oc6', directory: '/x', title: 'empty', timeCreated: at }], + messages: [userMsg('u1', 'ses_oc6', at)], + }); + const agg = await buildIndex(opts(sb)); + assert.equal(agg.sessions.find((x) => x.id === 'ses_oc6'), undefined); + rm(sb.dir); +}); diff --git a/tests/kit/usage-opencode.test.mjs b/tests/kit/usage-opencode.test.mjs new file mode 100644 index 0000000..26cd649 --- /dev/null +++ b/tests/kit/usage-opencode.test.mjs @@ -0,0 +1,217 @@ +// usage-opencode — the opencode transcript source for the usage scorecard. +// Hermetic: a fixture opencode.db is built per test in a tmp dir via +// node:sqlite (same engine as production). The real ~/.local/share/opencode +// store is NEVER touched. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { listSessions, parseSession, sessionExists } from '../../src/lib/usage-opencode.mjs'; + +const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ak-uo-')); +const rm = (d) => fs.rmSync(d, { recursive: true, force: true }); +const T = 1_785_000_000_000; // fixture epoch base +const DAY = 86_400_000; + +function buildDb(file, { sessions = [], messages = [], parts = [] } = {}) { + const db = new DatabaseSync(file); + db.exec(` + CREATE TABLE session ( + id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, + parent_id text, slug text NOT NULL, directory text NOT NULL, path text, + title text NOT NULL, version text NOT NULL, share_url text, + summary_additions integer, summary_deletions integer, summary_files integer, + summary_diffs text, metadata text, cost real DEFAULT 0 NOT NULL, + tokens_input integer DEFAULT 0 NOT NULL, tokens_output integer DEFAULT 0 NOT NULL, + tokens_reasoning integer DEFAULT 0 NOT NULL, tokens_cache_read integer DEFAULT 0 NOT NULL, + tokens_cache_write integer DEFAULT 0 NOT NULL, revert text, permission text, + agent text, model text, time_created integer NOT NULL, time_updated integer NOT NULL, + time_compacting integer, time_archived integer + ); + CREATE TABLE message ( + id text PRIMARY KEY, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL + ); + CREATE INDEX message_session_time_created_id_idx ON message (session_id, time_created, id); + CREATE TABLE part ( + id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL + ); + `); + const insS = db.prepare('INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); + const insM = db.prepare('INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'); + const insP = db.prepare('INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)'); + for (const s of sessions) insS.run(s.id, 'proj-1', s.parentId ?? null, s.slug ?? 'eager-test', s.directory, s.title, '1.18.8', s.timeCreated ?? T, s.timeUpdated ?? T); + for (const m of messages) insM.run(m.id, m.sessionId, m.at, m.at, JSON.stringify(m.data)); + for (const p of parts) insP.run(p.id, p.messageId, p.sessionId, p.at, p.at, JSON.stringify(p.data)); + db.close(); + return file; +} + +const userMsg = (id, sessionId, at, text = null) => ({ + id, sessionId, at, + data: { role: 'user', time: { created: at }, agent: 'build', ...(text ? { text } : {}) }, +}); +const assistantMsg = (id, sessionId, at, { model = 'kimi-k3', provider = 'opencode', tokens = {}, cost = null } = {}) => ({ + id, sessionId, at, + data: { + role: 'assistant', agent: 'build', path: { cwd: '/x', root: '/' }, + modelID: model, providerID: provider, + tokens: { total: 0, input: 100, output: 20, reasoning: 5, cache: { read: 40, write: 3 }, ...tokens }, + ...(cost != null ? { cost } : {}), + time: { created: at, completed: at + 1000 }, finish: 'stop', + }, +}); + +test('listSessions filters by the latest message time and keys on mtime+count', () => { + const d = tmp(); + const dbFile = buildDb(path.join(d, 'opencode.db'), { + sessions: [ + { id: 'ses_old', directory: '/x', title: 'old', timeCreated: T - 30 * DAY, timeUpdated: T - 30 * DAY }, + { id: 'ses_new', directory: '/x', title: 'new', timeCreated: T, timeUpdated: T }, + ], + messages: [ + assistantMsg('m1', 'ses_old', T - 30 * DAY), + assistantMsg('m2', 'ses_new', T + 1000), + assistantMsg('m3', 'ses_new', T + 2000), + ], + }); + const all = listSessions({ dbFile }); + assert.deepEqual(all.map((s) => s.id), ['ses_new', 'ses_old'], 'latest first'); + const fresh = listSessions({ dbFile, cutoffMs: T - DAY }); + assert.deepEqual(fresh.map((s) => s.id), ['ses_new'], 'window cutoff applies to the latest message, not the session row'); + assert.equal(fresh[0].mtimeMs, T + 2000); + assert.equal(fresh[0].size, 2); + rm(d); +}); + +test('parseSession maps a session to the index record: identity, usage rows with observed cost, punchcard, active intervals', () => { + const d = tmp(); + const dbFile = buildDb(path.join(d, 'opencode.db'), { + sessions: [{ id: 'ses_1', directory: '/home/me/myrepo', title: 'Add a hello util', timeCreated: T }], + messages: [ + userMsg('u1', 'ses_1', T, 'add a hello util'), + assistantMsg('a1', 'ses_1', T + 60_000, { cost: 0.01 }), + assistantMsg('a2', 'ses_1', T + 120_000, { cost: 0.02 }), + // a second model on a later day, and an idle split (> 15 min) + assistantMsg('a3', 'ses_1', T + DAY, { model: 'moonshotai/kimi-k3', provider: 'openrouter', cost: 0.03, tokens: { input: 5, output: 1, cache: { read: 0, write: 0 } } }), + ], + }); + const { session: rec } = parseSession({ dbFile, id: 'ses_1' }); + assert.equal(rec.provider, 'opencode'); + assert.equal(rec.host, 'opencode'); + assert.equal(rec.title, 'Add a hello util'); + assert.equal(rec.project, 'myrepo'); + assert.equal(rec.prompts, 1); + assert.equal(rec.responses, 3); + assert.equal(rec.exceptions, 0); + assert.equal(rec.sidechain, false); + assert.equal(rec.threadSource, null); + // provider is the LAST observed assistant providerID — never the host + assert.equal(rec.inferenceProvider, 'openrouter'); + assert.equal(rec.providerProvenance, 'observed'); + // usage rows per (day, model) with summed observed cost + const day1 = rec.usage.find((r) => r.model === 'kimi-k3'); + assert.deepEqual( + { input: day1.input, output: day1.output, cacheRead: day1.cacheRead, cacheWrite: day1.cacheWrite, responses: day1.responses, costObserved: day1.costObserved }, + { input: 200, output: 40, cacheRead: 80, cacheWrite: 6, responses: 2, costObserved: 0.03 }, + ); + const day2 = rec.usage.find((r) => r.model === 'moonshotai/kimi-k3'); + assert.equal(day2.costObserved, 0.03); + assert.equal(day2.day !== day1.day, true, 'rows keyed by day'); + assert.deepEqual(rec.models, ['kimi-k3', 'moonshotai/kimi-k3']); + assert.equal(rec.reasoningOutput, 15); + // engaged-time: the >15-min gap splits active intervals into two + assert.equal(rec.active.length, 2); + rm(d); +}); + +test('a parent_id marks a subagent session WITHOUT stripping its own tokens', () => { + const d = tmp(); + const dbFile = buildDb(path.join(d, 'opencode.db'), { + sessions: [ + { id: 'ses_p', directory: '/x', title: 'parent', timeCreated: T }, + { id: 'ses_c', directory: '/x', title: 'child', parentId: 'ses_p', timeCreated: T }, + ], + messages: [assistantMsg('a1', 'ses_c', T, { cost: 0.5 })], + }); + const { session: rec } = parseSession({ dbFile, id: 'ses_c' }); + assert.equal(rec.sidechain, true); + assert.equal(rec.threadSource, 'subagent'); + assert.equal(rec.usage[0].costObserved, 0.5, 'child sessions keep their own metered usage (not a parent replay)'); + rm(d); +}); + +test('rows with NO observed cost stay null so the pricing table applies (never a fabricated $0)', () => { + const d = tmp(); + const dbFile = buildDb(path.join(d, 'opencode.db'), { + sessions: [{ id: 'ses_1', directory: '/x', title: 'uncosted', timeCreated: T }], + messages: [assistantMsg('a1', 'ses_1', T)], // no cost field + }); + const { session: rec } = parseSession({ dbFile, id: 'ses_1' }); + assert.equal(rec.usage[0].costObserved, null); + rm(d); +}); + +test('malformed rows are skipped, never fatal — the session still parses', () => { + const d = tmp(); + const file = path.join(d, 'opencode.db'); + buildDb(file, { sessions: [{ id: 'ses_1', directory: '/x', title: 't', timeCreated: T }] }); + const db = new DatabaseSync(file); + db.prepare('INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)') + .run('bad', 'ses_1', T, T, '{not json'); + db.prepare('INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)') + .run('good', 'ses_1', T + 1, T + 1, JSON.stringify({ role: 'assistant', modelID: 'kimi-k3', tokens: { input: 1, output: 1, cache: {} }, time: { created: T + 1 } })); + db.close(); + const { session: rec } = parseSession({ dbFile: file, id: 'ses_1' }); + assert.equal(rec.responses, 1, 'the good row survives the corrupt one'); + rm(d); +}); + +test('withTurns emits user/assistant turn rows with text and tool names; tool counts land without turns too', () => { + const d = tmp(); + const dbFile = buildDb(path.join(d, 'opencode.db'), { + sessions: [{ id: 'ses_1', directory: '/x', title: 't', timeCreated: T }], + messages: [ + userMsg('u1', 'ses_1', T, 'do the thing'), + assistantMsg('a1', 'ses_1', T + 1000), + ], + parts: [ + { id: 'p1', messageId: 'u1', sessionId: 'ses_1', at: T, data: { type: 'text', text: 'do the thing' } }, + { id: 'p2', messageId: 'a1', sessionId: 'ses_1', at: T + 1000, data: { type: 'tool', tool: 'bash', callID: 'b1', state: { status: 'completed' } } }, + { id: 'p3', messageId: 'a1', sessionId: 'ses_1', at: T + 1001, data: { type: 'text', text: 'done' } }, + ], + }); + const { turns, session: rec } = parseSession({ dbFile, id: 'ses_1', withTurns: true }); + assert.equal(turns.length, 2); + assert.equal(turns[0].role, 'user'); + assert.match(turns[0].text, /do the thing/); + assert.equal(turns[1].role, 'assistant'); + assert.deepEqual(turns[1].tools, ['bash']); + assert.equal(rec.tools.bash, 1, 'tool usage counted for classification'); + // and without turns, tool counts still land + const lean = parseSession({ dbFile, id: 'ses_1' }); + assert.equal(lean.session.tools.bash, 1); + rm(d); +}); + +test('sessionExists tracks row presence', () => { + const d = tmp(); + const dbFile = buildDb(path.join(d, 'opencode.db'), { + sessions: [{ id: 'ses_1', directory: '/x', title: 't', timeCreated: T }], + }); + assert.equal(sessionExists({ dbFile, id: 'ses_1' }), true); + assert.equal(sessionExists({ dbFile, id: 'ses_nope' }), false); + rm(d); +}); + +test('an absent db reads as no source, never a throw', () => { + const d = tmp(); + const missing = path.join(d, 'nope.db'); + assert.deepEqual(listSessions({ dbFile: missing }), []); + assert.equal(parseSession({ dbFile: missing, id: 'x' }), null); + assert.equal(sessionExists({ dbFile: missing, id: 'x' }), false); + rm(d); +});