Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142/** * UsageLog — append-only JSONL of per-call usage events (saga vr7kgx.20). * * The time-bucketed usage aggregate is a *pure function* over this log (see * `usage-aggregate.ts`), exactly as the session compactor is a pure function over the * session JSONL (ARCH §11). The log is the source of truth; the bucketing/grouping logic * can change across versions and old logs still aggregate correctly ("data outlives * behavior", §1.4). * * Invariants (mirror `SessionStore`, `src/core/session/store.ts`): * - Append-only: `append` only adds lines; existing lines are never rewritten. * - LF-only line terminator (PROTOCOL §2.1 framing rule) via `encodeJsonl`. * - Corrupt/unparseable lines are skipped on read (never throw) — a truncated final line * from a crash is dropped silently, not fatal. * - One `UsageRecord` per line. * * The log is daemon-owned and lives at `<home>/usage.jsonl`. It is global (cross-session / * cross-agent): the primary aggregation dimension is `(provider, model)`. `agent_id` is * retained per record for internal attribution (a future per-session breakdown) but is * never returned to a web client by the aggregate (only `provider` + `model` + counts). */import { mkdirSync, appendFileSync, readFileSync } from 'node:fs';import { encodeJsonl, splitJsonl } from '@/protocol/framing/jsonl';
/** * One usage event in the log. `ts` is the epoch-ms timestamp at `reportUsage` time — the * time-bucketing key. All token/cost fields are numbers; `provider`/`model` are the * aggregation key (the user-facing selection stamped by `AgentSession.emitUsage`). */export interface UsageRecord { ts: number; agent_id: string; input: number; output: number; cache_read?: number; cache_write?: number; cost?: number; provider?: string; model?: string;}
/** Options for {@link UsageLog.scan}. */export interface ScanOptions { /** Inclusive lower bound (epoch ms). Records with `ts < from` are dropped. */ from?: number; /** Exclusive upper bound (epoch ms). Records with `ts >= to` are dropped. */ to?: number;}
/** * Append-only JSONL log of per-call usage events. Construct with the daemon home * directory; resolves `<home>/usage.jsonl`. Tolerant of a missing file (fresh log). */export class UsageLog { private readonly path: string;
constructor(home: string) { this.path = `${home}/usage.jsonl`; }
/** The resolved log path (diagnostic). */ get location(): string { return this.path; }
/** * Append one usage record as a single LF-terminated JSONL line. Append-only — existing * lines are never rewritten. Synchronous: a tiny write per provider call, and durability * (don't lose usage on crash) matters more than throughput. The directory is ensured. */ append(record: UsageRecord): void { mkdirSync(this.directory(), { recursive: true }); appendFileSync(this.path, encodeJsonl(record), 'utf-8'); }
/** * Scan the log, returning parsed records within the optional `[from, to)` time range, in * file order. Corrupt/unparseable lines are skipped (logged to nowhere — a bad line never * loses the surrounding records). A missing file yields an empty array (fresh log). * * Pure-ish: reads the whole file and filters. The log is small (one line per provider * call — low thousands/day), so a full read is fine for v1; a rollup/compaction can be * introduced later without changing this interface. */ scan(opts: ScanOptions = {}): UsageRecord[] { let raw: string; try { raw = readFileSync(this.path, 'utf-8'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; } const records: UsageRecord[] = []; for (const line of splitJsonl(raw)) { let value: unknown; try { value = JSON.parse(line); } catch { continue; // corrupt line — skip, never throw } const rec = coerceRecord(value); if (rec === undefined) continue; if (opts.from !== undefined && rec.ts < opts.from) continue; if (opts.to !== undefined && rec.ts >= opts.to) continue; records.push(rec); } return records; }
private directory(): string { const slash = this.path.lastIndexOf('/'); return slash > 0 ? this.path.slice(0, slash) : '.'; }}
/** * Coerce a parsed JSON value into a {@link UsageRecord}, validating the required fields. * Returns `undefined` for anything that isn't a usable record (corrupt/partial line). The * log is append-only and self-describing; a future-version record with extra fields still * loads (extra keys are ignored) — only the required `ts`/`agent_id`/`input`/`output` * shape is enforced. */function coerceRecord(value: unknown): UsageRecord | undefined { if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; const v = value as Record<string, unknown>; const ts = v.ts; const agentId = v.agent_id; const input = v.input; const output = v.output; if (typeof ts !== 'number' || !Number.isFinite(ts)) return undefined; if (typeof agentId !== 'string' || agentId.length === 0) return undefined; if (typeof input !== 'number' || !Number.isFinite(input)) return undefined; if (typeof output !== 'number' || !Number.isFinite(output)) return undefined; const rec: UsageRecord = { ts, agent_id: agentId, input, output }; if (typeof v.cache_read === 'number' && Number.isFinite(v.cache_read)) rec.cache_read = v.cache_read; if (typeof v.cache_write === 'number' && Number.isFinite(v.cache_write)) rec.cache_write = v.cache_write; if (typeof v.cost === 'number' && Number.isFinite(v.cost)) rec.cost = v.cost; if (typeof v.provider === 'string' && v.provider.length > 0) rec.provider = v.provider; if (typeof v.model === 'string' && v.model.length > 0) rec.model = v.model; return rec;}