diff --git a/packages/cli/src/codex-cache.ts b/packages/cli/src/codex-cache.ts index cf599d10..c87007b4 100644 --- a/packages/cli/src/codex-cache.ts +++ b/packages/cli/src/codex-cache.ts @@ -26,7 +26,18 @@ import type { ParsedProviderCall } from './providers/types.js' // decodes only the new bytes instead of re-streaming the whole file. // This is lossless: Codex rollout files are durable (never auto-deleted), so the // one-time re-derive on first run under v8 rebuilds byte-identical data. -const CODEX_CACHE_VERSION = 8 +// v9: tool-excluded active timing — per-call activeDurationMs / +// activeGeneratedTokens / toolWaitMs (a6bf81f). Cached calls lack the fields; +// bump once so unchanged sessions re-decode and pick them up. +// v10: the threaded decoder state now carries the task-timing window +// (taskResultStart / taskGeneratedTokens / taskToolIntervals / taskStartedAt / +// openToolStarts), so a task whose task_complete lands in an appended region +// attributes the three timing fields to the calls emitted in the earlier +// region — a parse that ended mid-task no longer strands them unattributed. +// v9 states lack the window; bump once so unchanged sessions re-decode with it. +// (The session-cache PROVIDER_PARSE_VERSIONS marker is bumped in lockstep so +// cached turns re-derive too.) +const CODEX_CACHE_VERSION = 10 const CACHE_FILE = 'codex-results.json' type FileFingerprint = { mtimeMs: number; sizeBytes: number } diff --git a/packages/cli/src/codex-throughput.ts b/packages/cli/src/codex-throughput.ts new file mode 100644 index 00000000..4796206d --- /dev/null +++ b/packages/cli/src/codex-throughput.ts @@ -0,0 +1,521 @@ +import { open, stat } from 'node:fs/promises' +import { StringDecoder } from 'node:string_decoder' + +export type CodexThroughputPoint = { + timestamp: string + model?: string + outputTokens: number + reasoningTokens: number + generatedTokens: number + taskGeneratedTokens?: number + elapsedSeconds?: number + generatedTokensPerSecond?: number + activeDurationSeconds?: number + activeGeneratedTokensPerSecond?: number + toolWaitSeconds?: number +} + +type TokenUsage = { + output_tokens?: number + reasoning_output_tokens?: number + total_tokens?: number +} + +type RolloutLine = { + type?: string + timestamp?: string + payload?: { + type?: string + turn_id?: string + call_id?: string + started_at?: number + duration_ms?: number + duration?: { secs?: number; nanos?: number } | string + model?: string + forked_from_id?: string + info?: { + last_token_usage?: TokenUsage + total_token_usage?: TokenUsage + } + } +} + +const CHUNK_BYTES = 64 * 1024 +const MAX_PENDING_LINE_CHARS = 4 * 1024 * 1024 +const TRUNCATION_MARKER = '__CODEBURN_TRUNCATED_LINE__' + +function rawString(source: string, field: string): string | undefined { + const match = new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(source) + if (!match) return undefined + try { return JSON.parse(`"${match[1]}"`) as string } catch { return undefined } +} + +function rawNumber(source: string, field: string): number | undefined { + const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(source) + if (!match) return undefined + const value = Number(match[1]) + return Number.isFinite(value) ? value : undefined +} + +function compactUsage(source: string, field: 'last_token_usage' | 'total_token_usage'): TokenUsage | undefined { + const index = source.indexOf(`"${field}"`) + if (index < 0) return undefined + const body = source.slice(index, index + 4096) + return { + output_tokens: rawNumber(body, 'output_tokens'), + reasoning_output_tokens: rawNumber(body, 'reasoning_output_tokens'), + total_tokens: rawNumber(body, 'total_tokens'), + } +} + +function parseRawDurationValue(value: string): number | undefined { + const objectMatch = /^\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(value) + if (objectMatch) { + const seconds = Number(objectMatch[1]) + const nanos = Number(objectMatch[2]) + if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6 + } + const stringMatch = /^\s*"(\d+(?:\.\d+)?)(ms|s)?"/.exec(value) + if (stringMatch) { + const parsed = Number(stringMatch[1]) + if (Number.isFinite(parsed)) return parsed * (stringMatch[2] === 's' ? 1000 : 1) + } + const numberMatch = /^\s*(-?\d+(?:\.\d+)?)/.exec(value) + if (numberMatch) { + const parsed = Number(numberMatch[1]) + if (Number.isFinite(parsed)) return parsed + } + return undefined +} + +function durationMs(payload: RolloutLine['payload']): number | undefined { + if (!payload) return undefined + if (typeof payload.duration_ms === 'number' && Number.isFinite(payload.duration_ms)) return payload.duration_ms + if (typeof payload.duration === 'object' && payload.duration) { + const seconds = payload.duration.secs + const nanos = payload.duration.nanos + if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) { + return seconds * 1000 + nanos / 1e6 + } + } + if (typeof payload.duration === 'string') { + const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(payload.duration.trim()) + if (match) return Number(match[1]) * (match[2] === 's' ? 1000 : 1) + } + return undefined +} + +function mergeToolIntervals(intervals: Array<[number, number]>, durationMs: number, taskStartedAt?: number, taskCompletedAt?: number): number { + const windowStart = taskStartedAt ?? (taskCompletedAt !== undefined ? taskCompletedAt - durationMs : undefined) + const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined + const clipped = intervals.map(([start, end]) => [ + windowStart !== undefined ? Math.max(start, windowStart) : start, + windowEnd !== undefined ? Math.min(end, windowEnd) : end, + ] as [number, number]).filter(([start, end]) => end > start) + const merged = clipped.sort((a, b) => a[0] - b[0]).reduce>((result, interval) => { + const previous = result.at(-1) + if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1]) + else result.push([...interval]) + return result + }, []) + return Math.min(durationMs, merged.reduce((total, [start, end]) => total + end - start, 0)) +} + +function parseLine(line: string): RolloutLine | null { + const payloadStart = line.indexOf('"payload"') + const payloadHead = payloadStart >= 0 ? line.slice(payloadStart) : line + if (line.length > 256 * 1024 || line.startsWith(TRUNCATION_MARKER)) { + const payloadType = rawString(payloadHead, 'type') + const infoStart = payloadHead.indexOf('"info"') + const info = infoStart >= 0 ? payloadHead.slice(infoStart) : '' + return { + type: rawString(line, 'type'), + timestamp: rawString(line, 'timestamp'), + payload: { + type: payloadType, + turn_id: rawString(payloadHead, 'turn_id'), + call_id: rawString(payloadHead, 'call_id'), + started_at: rawNumber(payloadHead, 'started_at'), + duration_ms: rawNumber(payloadHead, 'duration_ms'), + duration: rawString(payloadHead, 'duration') ?? (rawNumber(payloadHead, 'secs') !== undefined + ? { secs: rawNumber(payloadHead, 'secs'), nanos: rawNumber(payloadHead, 'nanos') } + : undefined), + model: rawString(payloadHead, 'model'), + forked_from_id: rawString(payloadHead, 'forked_from_id'), + info: { + last_token_usage: compactUsage(info, 'last_token_usage'), + total_token_usage: compactUsage(info, 'total_token_usage'), + }, + }, + } + } + try { + return JSON.parse(line) as RolloutLine + } catch { + return null + } +} + +/** + * Estimate generated tokens/sec from a Codex rollout's persisted checkpoints. + * Codex JSONL has no per-token timestamps, so this is deliberately a + * checkpoint-to-checkpoint estimate, not live decode speed. + */ +type ThroughputState = { + model?: string + previousTotal?: number + previousOutput: number + previousReasoning: number + previousTimestamp?: number + currentTaskGenerated: number + currentTaskToolIntervals: Array<[number, number]> + currentTaskStartedAt?: number + toolStarts: Map + latestPoint?: CodexThroughputPoint + points: CodexThroughputPoint[] + forkCutoffMs?: number +} + +function newThroughputState(): ThroughputState { + return { + previousOutput: 0, + previousReasoning: 0, + currentTaskGenerated: 0, + currentTaskToolIntervals: [], + toolStarts: new Map(), + points: [], + } +} + +/** + * Incrementally parses a rollout. Watch mode feeds only newly appended bytes + * to this reader, so a growing JSONL file is not reparsed from byte zero. + */ +export class CodexThroughputReader { + private offset = 0 + private pending = '' + private decoder = new StringDecoder('utf8') + private pendingDurationMs: number | undefined + private scanDepth = 0 + private scanPayloadDepth: number | undefined + private scanInString = false + private scanEscape = false + private scanString = '' + private scanLastString = '' + private scanAwaitingColon = false + private scanCurrentKey: string | undefined + private scanCapture: { mode: 'string' | 'object' | 'primitive'; text: string; depth: number } | undefined + private state = newThroughputState() + + reset(): void { + this.offset = 0 + this.pending = '' + this.decoder = new StringDecoder('utf8') + this.pendingDurationMs = undefined + this.scanDepth = 0 + this.scanPayloadDepth = undefined + this.scanInString = false + this.scanEscape = false + this.scanString = '' + this.scanLastString = '' + this.scanAwaitingColon = false + this.scanCurrentKey = undefined + this.scanCapture = undefined + this.state = newThroughputState() + } + + private finishDurationCapture(): void { + if (!this.scanCapture) return + const value = this.scanCapture.mode === 'string' ? `"${this.scanCapture.text}"` : this.scanCapture.text + const parsed = parseRawDurationValue(value) + if (parsed !== undefined && this.pendingDurationMs === undefined) this.pendingDurationMs = parsed + this.scanCapture = undefined + } + + private scanDurationSegment(source: string): void { + for (let i = 0; i < source.length; i++) { + const char = source[i]! + if (this.scanInString) { + if (this.scanEscape) { + this.scanEscape = false + if (this.scanCapture?.mode === 'object') this.scanCapture.text += char + else if (this.scanCapture?.mode === 'string') this.scanCapture.text += char + else this.scanString += char + continue + } + if (char === '\\') { + this.scanEscape = true + if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char + continue + } + if (char === '"') { + if (this.scanCapture?.mode === 'object') this.scanCapture.text += char + this.scanInString = false + if (this.scanCapture?.mode === 'string') this.finishDurationCapture() + else if (this.scanCapture?.mode === 'object') { + this.scanAwaitingColon = false + this.scanCurrentKey = undefined + } else { + this.scanLastString = this.scanString + this.scanAwaitingColon = true + } + continue + } + if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char + else this.scanString += char + continue + } + + if (this.scanCapture?.mode === 'primitive') { + if (char === ',' || char === '}' || char === ']') this.finishDurationCapture() + else { this.scanCapture.text += char; continue } + } + if (this.scanAwaitingColon) { + if (/\s/.test(char)) continue + if (char === ':') { + this.scanCurrentKey = this.scanLastString + this.scanAwaitingColon = false + continue + } + this.scanAwaitingColon = false + } + if (char === '"') { + this.scanString = '' + if (this.scanCapture?.mode === 'object') this.scanCapture.text += char + if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) { + this.scanCapture = { mode: 'string', text: '', depth: this.scanDepth } + this.scanCurrentKey = undefined + } + this.scanInString = true + continue + } + if (char === '{' || char === '[') { + if (this.scanCurrentKey === 'payload' && char === '{' && this.scanPayloadDepth === undefined) { + this.scanPayloadDepth = this.scanDepth + 1 + } + if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) { + this.scanCapture = { mode: 'object', text: char, depth: this.scanDepth + 1 } + this.scanCurrentKey = undefined + } else if (this.scanCapture?.mode === 'object') { + this.scanCapture.text += char + } + this.scanDepth++ + continue + } + if (char === '}' || char === ']') { + if (this.scanCapture?.mode === 'object') this.scanCapture.text += char + this.scanDepth = Math.max(0, this.scanDepth - 1) + if (this.scanCapture?.mode === 'object' && this.scanDepth < this.scanCapture.depth) this.finishDurationCapture() + continue + } + if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth && !/\s/.test(char)) { + this.scanCapture = { mode: 'primitive', text: char, depth: this.scanDepth } + this.scanCurrentKey = undefined + continue + } + if (this.scanCapture?.mode === 'object') this.scanCapture.text += char + } + } + + private processLine(line: string, durationOverride?: number): void { + const entry = parseLine(line) + if (!entry) return + if (durationOverride !== undefined && (line.startsWith(TRUNCATION_MARKER) || line.length > 256 * 1024) && entry.type === 'event_msg' && (entry.payload?.type === 'mcp_tool_call_end' || entry.payload?.type === 'task_complete')) { + entry.payload = { ...entry.payload, duration_ms: durationOverride } + } + const state = this.state + if (entry.type === 'session_meta') { + if (entry.payload?.model) state.model = entry.payload.model + if (entry.payload?.forked_from_id && entry.timestamp) { + const timestamp = Date.parse(entry.timestamp) + if (Number.isFinite(timestamp)) state.forkCutoffMs = timestamp + 5000 + } + return + } + if (entry.type === 'turn_context' && entry.payload?.model) state.model = entry.payload.model + const entryTimestamp = entry.timestamp ? Date.parse(entry.timestamp) : NaN + const isForkReplay = state.forkCutoffMs !== undefined && Number.isFinite(entryTimestamp) && entryTimestamp < state.forkCutoffMs + if (isForkReplay && ( + entry.payload?.type === 'task_started' || + entry.payload?.type === 'task_complete' || + entry.payload?.type === 'function_call' || + entry.payload?.type === 'function_call_output' || + entry.payload?.type === 'custom_tool_call' || + entry.payload?.type === 'custom_tool_call_output' || + entry.payload?.type === 'mcp_tool_call_end' || + entry.payload?.type === 'patch_apply_end' || + entry.payload?.type === 'token_count' + )) return + if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') { + state.currentTaskGenerated = 0 + state.currentTaskToolIntervals = [] + const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN + state.currentTaskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined + state.toolStarts.clear() + } + if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call') && entry.payload.call_id && entry.timestamp) { + const started = Date.parse(entry.timestamp) + if (Number.isFinite(started)) state.toolStarts.set(entry.payload.call_id, started) + } + if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output') && entry.payload.call_id && entry.timestamp) { + const ended = Date.parse(entry.timestamp) + const started = state.toolStarts.get(entry.payload.call_id) + if (started !== undefined && Number.isFinite(ended) && ended > started) state.currentTaskToolIntervals.push([started, ended]) + state.toolStarts.delete(entry.payload.call_id) + } + if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end' && entry.timestamp) { + const ended = Date.parse(entry.timestamp) + const elapsed = durationMs(entry.payload) + if (Number.isFinite(ended) && elapsed !== undefined && elapsed > 0) state.currentTaskToolIntervals.push([ended - elapsed, ended]) + } + if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') { + const taskDurationMs = durationMs(entry.payload) + if (state.latestPoint && typeof taskDurationMs === 'number' && taskDurationMs > 0 && state.currentTaskGenerated > 0) { + state.latestPoint.taskGeneratedTokens = state.currentTaskGenerated + const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : undefined + const toolWaitMs = mergeToolIntervals(state.currentTaskToolIntervals, taskDurationMs, state.currentTaskStartedAt, Number.isFinite(completedAt) ? completedAt : undefined) + const activeMs = taskDurationMs - toolWaitMs + if (activeMs > 0) { + state.latestPoint.activeDurationSeconds = activeMs / 1000 + state.latestPoint.toolWaitSeconds = toolWaitMs / 1000 + state.latestPoint.activeGeneratedTokensPerSecond = state.currentTaskGenerated / (activeMs / 1000) + } + } + } + if (entry.type !== 'event_msg' || entry.payload?.type !== 'token_count') return + const info = entry.payload.info + if (!info || !entry.timestamp) return + const last = info.last_token_usage + const total = info.total_token_usage + const cumulative = total?.total_tokens + if (cumulative !== undefined && cumulative === state.previousTotal) return + let outputTokens = last?.output_tokens ?? 0 + let reasoningTokens = last?.reasoning_output_tokens ?? 0 + if (!last && total && cumulative !== undefined && state.previousTotal !== undefined) { + outputTokens = Math.max(0, (total.output_tokens ?? 0) - state.previousOutput) + reasoningTokens = Math.max(0, (total.reasoning_output_tokens ?? 0) - state.previousReasoning) + } + if (cumulative !== undefined) { + state.previousTotal = cumulative + state.previousOutput = total?.output_tokens ?? state.previousOutput + state.previousReasoning = total?.reasoning_output_tokens ?? state.previousReasoning + } + const generatedTokens = outputTokens + reasoningTokens + if (generatedTokens <= 0) return + const timestampMs = Date.parse(entry.timestamp) + if (!Number.isFinite(timestampMs)) return + const point: CodexThroughputPoint = { + timestamp: entry.timestamp, + model: state.model, + outputTokens, + reasoningTokens, + generatedTokens, + } + state.currentTaskGenerated += generatedTokens + state.latestPoint = point + if (state.previousTimestamp !== undefined && timestampMs > state.previousTimestamp) { + const elapsedSeconds = (timestampMs - state.previousTimestamp) / 1000 + point.elapsedSeconds = elapsedSeconds + point.generatedTokensPerSecond = generatedTokens / elapsedSeconds + } + state.previousTimestamp = timestampMs + state.points.push(point) + if (state.points.length > 10000) state.points.splice(0, state.points.length - 10000) + } + + async update(filePath: string, limit = 10, finalize = false): Promise { + const info = await stat(filePath) + if (info.size < this.offset) this.reset() + const bytesToRead = info.size - this.offset + if (bytesToRead > 0) { + const file = await open(filePath, 'r') + try { + let position = this.offset + while (position < info.size) { + const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, info.size - position)) + const { bytesRead } = await file.read(buffer, 0, buffer.length, position) + if (bytesRead === 0) break + position += bytesRead + this.offset = position + let chunk = this.decoder.write(buffer.subarray(0, bytesRead)) + while (chunk.length > 0) { + const newlineIndex = chunk.search(/\r?\n/) + const segment = newlineIndex >= 0 ? chunk.slice(0, newlineIndex) : chunk + this.pending += segment + this.scanDurationSegment(segment) + if (newlineIndex < 0) break + const newlineLength = chunk[newlineIndex] === '\r' ? 2 : 1 + const line = this.pending + const durationOverride = this.pendingDurationMs + this.pending = '' + this.pendingDurationMs = undefined + this.scanDepth = 0 + this.scanPayloadDepth = undefined + this.scanInString = false + this.scanEscape = false + this.scanString = '' + this.scanLastString = '' + this.scanAwaitingColon = false + this.scanCurrentKey = undefined + this.scanCapture = undefined + this.processLine(line, durationOverride) + chunk = chunk.slice(newlineIndex + newlineLength) + } + if (this.pending.length > MAX_PENDING_LINE_CHARS) { + const body = this.pending.startsWith(TRUNCATION_MARKER) + ? this.pending.slice(TRUNCATION_MARKER.length) + : this.pending + this.pending = TRUNCATION_MARKER + body.slice(0, 256 * 1024) + body.slice(-256 * 1024) + } + } + } finally { + await file.close() + } + } + if (finalize && this.pending) { + this.processLine(this.pending, this.pendingDurationMs) + this.pending = '' + this.pendingDurationMs = undefined + } + return limit > 0 ? this.state.points.slice(-limit) : this.state.points.slice() + } +} + +export async function readCodexThroughput(filePath: string, limit = 10): Promise { + return new CodexThroughputReader().update(filePath, limit, true) +} + +export async function newestCodexSession(sessions: Array<{ path: string }>): Promise { + let newest: { path: string; mtimeMs: number } | undefined + for (const session of sessions) { + try { + const info = await stat(session.path) + if (!newest || info.mtimeMs > newest.mtimeMs) newest = { path: session.path, mtimeMs: info.mtimeMs } + } catch { + // A session can disappear while Codex rotates or archives it. + } + } + return newest?.path +} + +export function renderCodexThroughput(points: CodexThroughputPoint[], filePath: string): string { + const latest = points.at(-1) + if (!latest) return `No token_count checkpoints found in ${filePath}.` + const lines = [ + 'CodeBurn Codex throughput estimate', + `Session: ${filePath}`, + `Latest checkpoint: ${latest.timestamp}`, + `Latest checkpoint tokens: ${latest.generatedTokens.toLocaleString()} (${latest.outputTokens.toLocaleString()} output + ${latest.reasoningTokens.toLocaleString()} reasoning)`, + ] + if (latest.taskGeneratedTokens !== undefined) lines.push(`Completed task total: ${latest.taskGeneratedTokens.toLocaleString()} generated tokens`) + if (latest.activeGeneratedTokensPerSecond !== undefined) { + lines.push(`Active throughput: ${latest.activeGeneratedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.activeDurationSeconds!.toFixed(1)}s`) + lines.push(`Excluded tool wait: ${latest.toolWaitSeconds!.toFixed(1)}s`) + } else if (latest.generatedTokensPerSecond !== undefined) { + lines.push(`Checkpoint estimate: ${latest.generatedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.elapsedSeconds!.toFixed(1)}s`) + } else { + lines.push('Throughput: unavailable (waiting for a completed turn)') + } + lines.push('Note: offline JSONL estimate; tool intervals are removed, but server/prompt latency may remain.') + return lines.join('\n') +} diff --git a/packages/cli/src/daily-cache.ts b/packages/cli/src/daily-cache.ts index c5439c34..734b7481 100644 --- a/packages/cli/src/daily-cache.ts +++ b/packages/cli/src/daily-cache.ts @@ -5,7 +5,28 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' -// Bumped to 15: per-project daily rollups. Days and provider slices now carry +// Bumped to 17: dedup-key hygiene (#931, this PR). The codebuff, zerostack, +// pi/omp and grok decoders now thread a FINGERPRINT of the source path into +// their dedup keys (and lingtai-tui normalizes the model component) instead of +// the raw path / raw ledger text, because dedupKey ships on the observation +// envelope. Unchanged files are served from the session cache, whose dedup +// sets are seeded from the CACHED keys — so a warm cache built by the pre-fix +// binary keeps the raw-path keys, the same records re-ingest under the new +// key shape, totals go inconsistent, and the raw path stays on disk forever. +// The per-provider parse versions (session-cache.ts PROVIDER_PARSE_VERSIONS) +// force the session-cache re-parse that drops those keys; this bump forces the +// daily cache to re-derive every day whose sources survive instead of serving +// the pre-fix rollups. Raising MIN_SUPPORTED_VERSION to 17 makes a v16 file +// load as an old-version file rather than the trusted current cache. +// +// v16 is SKIPPED: main already spent it on the codex structural-discovery fix +// (eece4cf, #873/#626). A user who has ever run a main build owns a v16 cache +// containing only the codex fix; claiming 16 here too would load that file as +// CURRENT and COMPLETE, so the invalidation would never fire — the exact +// failure this bump exists to prevent. Claiming 17 instead sends that v16 file +// through the old-version adoption/re-derive path. +// +// v15: per-project daily rollups. Days and provider slices now carry // a `projects` breakdown (cost/calls/savings/sessions per project) so project // history outlives the session files, like models and categories already do. // This bump is the first to ride the v14 carry-forward: the old cache is @@ -57,8 +78,8 @@ import type { DateRange, ProjectSummary } from './types.js' // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 15 -const MIN_SUPPORTED_VERSION = 15 +export const DAILY_CACHE_VERSION = 17 +const MIN_SUPPORTED_VERSION = 17 // Version-suffixed so different binaries each own a distinct file and never // clobber an incompatible schema. Bumping the version mints a fresh filename; // adoptOlderDailyCaches then unions days out of every previous file (including diff --git a/packages/cli/src/dashboard.tsx b/packages/cli/src/dashboard.tsx index 1b68a222..ece37592 100644 --- a/packages/cli/src/dashboard.tsx +++ b/packages/cli/src/dashboard.tsx @@ -46,7 +46,10 @@ export function showEmptyState(projectCount: number, scrollableHistory: boolean, return historyProjectCount === 0 && !historyLoading } -const MIN_WIDE = 90 +// The By Model panel now carries six numeric columns. Keep panels stacked until +// each half has enough room for those columns instead of truncating Tok/s at +// ordinary 100–120 column terminals. +const MIN_WIDE = 130 const ORANGE = '#FF8C42' const DIM = '#555555' const GOLD = '#FFD700' @@ -440,6 +443,7 @@ const MODEL_COL_COST = 8 const MODEL_COL_CACHE = 7 const MODEL_COL_CALLS = 7 const MODEL_COL_ONESHOT = 7 +const MODEL_COL_TPS = 7 const MODEL_NAME_WIDTH = 14 const MIN_EDIT_TURNS_FOR_RATE = 5 @@ -449,6 +453,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: const modelTotals = aggregateModelTotals(projects) const modelEfficiency = aggregateModelEfficiency(projects) const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0) + const anyActiveTiming = Object.values(modelTotals).some(d => d.activeDurationMs > 0 && d.activeGeneratedTokens > 0) const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) const maxCost = sorted[0]?.[1]?.costUSD ?? 0 const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({ @@ -460,7 +465,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: return ( - {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)} + {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{'Tok/s'.padStart(MODEL_COL_TPS)} {sorted.map(([model, data], i) => { const totalInput = data.freshInput + data.cacheRead + data.cacheWrite const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0 @@ -469,6 +474,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null ? `${efficiency.oneShotRate.toFixed(1)}%` : '-' + const tpsLabel = data.activeDurationMs > 0 && data.activeGeneratedTokens > 0 + ? (data.activeGeneratedTokens / (data.activeDurationMs / 1000)).toFixed(1) + : '-' return ( @@ -477,6 +485,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {cacheLabel.padStart(MODEL_COL_CACHE)} {String(data.calls).padStart(MODEL_COL_CALLS)} {oneShotLabel.padStart(MODEL_COL_ONESHOT)} + {tpsLabel.padStart(MODEL_COL_TPS)} ) })} @@ -488,6 +497,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {anyEstimated && ( ~ estimated cost (priced from estimated tokens) )} + {anyActiveTiming && ( + ~ Tok/s: generated tokens / active time; tool wait excluded + )} ) } diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index ae6867cf..36e4beaa 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -4,7 +4,7 @@ import { installMenubarApp } from './menubar-installer.js' import { exportCsv, exportJson, type PeriodExport } from './export.js' import { findUnpricedModels, loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js' import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache, setInteractiveScanUI } from './parser.js' -import { allProviderNames, getAllProviders } from './providers/index.js' +import { allProviderNames, getAllProviders, getProvider } from './providers/index.js' import { convertCost, formatCost } from './currency.js' import { renderStatusBar } from './format.js' import { toDateString } from './daily-cache.js' @@ -46,6 +46,7 @@ import { createRequire } from 'node:module' const require = createRequire(import.meta.url) const { version } = require('../package.json') import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' +import { CodexThroughputReader, newestCodexSession, renderCodexThroughput } from './codex-throughput.js' // A downstream reader that closes the pipe early (`| head`, quitting `less`, or // a missing command) makes stdout writes fail with EPIPE. Exit cleanly rather @@ -68,6 +69,22 @@ function parseInteger(value: string): number { return parseInt(value, 10) } +function parseCodexTpsLimit(value: string): number { + const parsed = Number(value) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > 10000) { + throw new Error('limit must be an integer from 1 to 10000') + } + return parsed +} + +function parseCodexTpsWatch(value: string): number { + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed < 0 || (parsed > 0 && parsed < 1) || parsed > 3600) { + throw new Error('watch must be 0 or at least 1 second (up to 3600 seconds)') + } + return parsed +} + type PriceOverrideConfig = NonNullable[string] type PriceOverrideOptions = { @@ -1843,6 +1860,94 @@ program await runContextCommand(session, opts) }) +program + .command('codex-tps [session]') + .description('Retrospective Codex generated-tokens/sec estimate from rollout checkpoints (not live decode speed)') + .option('--json', 'JSON output') + .option('--limit ', 'Number of recent checkpoints to scan', parseCodexTpsLimit, 10) + .option('--watch ', 'Refresh continuously while Codex writes checkpoints', parseCodexTpsWatch, 0) + .action(async (session: string | undefined, opts: { json?: boolean; limit: number; watch: number }) => { + const intervalMs = Math.max(0, opts.watch) * 1000 + if (opts.json && intervalMs > 0) { + process.stderr.write('codeburn codex-tps: --json cannot be combined with --watch; use text watch output or one-shot JSON.\n') + process.exitCode = 2 + return + } + const provider = await getProvider('codex') + if (!provider) { + process.stderr.write('codeburn codex-tps: Codex provider is unavailable.\n') + process.exitCode = 1 + return + } + let cachedPath: string | undefined = session + let throughputReader: CodexThroughputReader | undefined + let lastFileState: { size: number; mtimeMs: number } | undefined + let lastDiscoveryMs = 0 + let refreshInFlight = false + const render = async (): Promise => { + if (refreshInFlight) return + refreshInFlight = true + try { + let filePath = session ?? cachedPath + // Keep an idle watcher on its chosen rollout. A full active+archive + // discovery can be hundreds of milliseconds on large histories, so + // only re-scan slowly to notice rotation; disappearance still triggers + // an immediate discovery on the next tick. + if (!session && (!filePath || Date.now() - lastDiscoveryMs >= 60_000)) { + lastDiscoveryMs = Date.now() + filePath = await newestCodexSession(await provider.discoverSessions()) + } + if (!filePath) { + process.stderr.write('codeburn codex-tps: no Codex rollout sessions found.\n') + if (intervalMs === 0) process.exitCode = 1 + return + } + const previousPath = cachedPath + cachedPath = filePath + if (previousPath !== filePath || !throughputReader) throughputReader = new CodexThroughputReader() + const fileInfo = await import('node:fs/promises').then(fs => fs.stat(filePath)).catch(() => null) + if (!fileInfo) { + process.stderr.write(`codeburn codex-tps: session file not found: ${filePath}\n`) + if (intervalMs === 0) process.exitCode = 1 + if (!session) cachedPath = undefined + return + } + if (intervalMs > 0 && lastFileState && fileInfo.size === lastFileState.size && fileInfo.mtimeMs === lastFileState.mtimeMs) return + lastFileState = { size: fileInfo.size, mtimeMs: fileInfo.mtimeMs } + const points = await throughputReader!.update(filePath, opts.limit, intervalMs === 0) + if (opts.json) { + process.stdout.write(JSON.stringify({ session: filePath, points, live: intervalMs > 0 }, null, 2) + '\n') + } else { + if (intervalMs > 0) process.stdout.write('\x1b[2J\x1b[H') + process.stdout.write(renderCodexThroughput(points, filePath) + (intervalMs > 0 ? '\nWatching for new Codex checkpoints... (Ctrl-C to stop)\n' : '\n')) + } + } finally { + refreshInFlight = false + } + } + try { + await render() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`) + if (intervalMs === 0) { + process.exitCode = 1 + return + } + } + if (intervalMs > 0) { + await new Promise((resolve) => { + const timer = setInterval(() => { + void render().catch(error => { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`) + }) + }, intervalMs) + process.once('SIGINT', () => { clearInterval(timer); resolve() }) + }) + } + }) + program .command('compare') .description('Compare two AI models side-by-side') diff --git a/packages/cli/src/model-breakdown.ts b/packages/cli/src/model-breakdown.ts index 5801be80..799338e8 100644 --- a/packages/cli/src/model-breakdown.ts +++ b/packages/cli/src/model-breakdown.ts @@ -8,6 +8,8 @@ export interface ModelTotals { freshInput: number cacheRead: number cacheWrite: number + activeDurationMs: number + activeGeneratedTokens: number } /// Aggregate per-model usage across every session, keyed by the friendly display @@ -24,6 +26,7 @@ export function aggregateModelTotals(projects: ProjectSummary[]): Record(spec: BridgedProviderSpec): async *parse(): AsyncGenerator { const records = await spec.readRecords(source) if (records === null) return - // The CLI holds the rich decode only; minimization / fingerprinting - // happens on the sync path, so an empty privacy key is correct here - // (the rich decoder never consumes it), matching claude/codex. - const context: DecodeContext = { privacyKey: '', providerId: spec.name, sourceRef: source.path } + // The host privacy key, threaded into the rich decode (D1). An empty + // key was correct when the bridge was written — the comment then said + // the rich decoder never consumes it, because minimization / + // fingerprinting happened later on the sync path. That intent is + // OVERTAKEN by the sourceRef-fingerprint work (#931): the rich + // decoders now derive their dedup keys from sourceRefFingerprint, + // and dedupKey ships on the observation envelope, so the rich decode + // DOES consume the key. On an empty key core's fingerprint module + // throws (it never degrades to an unkeyed digest), so the bridge has + // to supply the real one. getHostPrivacyKey() is per-install stable + // (persisted, like the optimize detectors use), so dedup keys stay + // stable across runs and the session-cache re-parse / dedup + // semantics are unchanged; it only falls back to a per-process key + // when the config dir is unwritable, in which case the session cache + // cannot persist either. + const context: DecodeContext = { privacyKey: getHostPrivacyKey(), providerId: spec.name, sourceRef: source.path } const { calls } = spec.decode({ records, context, seenKeys }) for (const rich of calls) { yield spec.toProviderCall(rich) diff --git a/packages/cli/src/providers/cline-cli.ts b/packages/cli/src/providers/cline-cli.ts new file mode 100644 index 00000000..831117fe --- /dev/null +++ b/packages/cli/src/providers/cline-cli.ts @@ -0,0 +1,173 @@ +import { readdir } from 'fs/promises' +import { homedir } from 'os' +import { basename, join } from 'path' + +import { decodeClineCli, clineCliToolNameMap } from '@codeburn/core/providers/cline-cli' +import type { ClineCliDecodedCall, ClineCliSessionRecords } from '@codeburn/core/providers/cline-cli' + +import { extractBashCommands } from '../bash-utils.js' +import { readSessionFile } from '../fs-utils.js' +import { getShortModelName } from '../models.js' +import { createBridgedProvider } from './bridge.js' +import type { Provider, ProbeRoot, SessionSource, ParsedProviderCall } from './types.js' + +const PROVIDER_NAME = 'cline-cli' +const DISPLAY_NAME = 'Cline CLI' + +// Mirrors the CLI's own resolution chain, each level individually overridable: +// sessions := CLINE_SESSION_DATA_DIR ?? /sessions +// data := CLINE_DATA_DIR ?? /data +// root := CLINE_DIR ?? ~/.cline +function clineRootDir(): string { + return process.env['CLINE_DIR']?.trim() || join(homedir(), '.cline') +} + +function clineDataDir(): string { + return process.env['CLINE_DATA_DIR']?.trim() || join(clineRootDir(), 'data') +} + +export function getClineCliSessionsDir(): string { + return process.env['CLINE_SESSION_DATA_DIR']?.trim() || join(clineDataDir(), 'sessions') +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function projectName(workspace: string | undefined): string { + if (!workspace) return DISPLAY_NAME + const parts = workspace.replace(/[\\/]+$/, '').split(/[\\/]/).filter(Boolean) + return parts.at(-1) ?? DISPLAY_NAME +} + +async function readJson(path: string): Promise { + const raw = await readSessionFile(path) + if (raw === null) return null + try { + return JSON.parse(raw) as unknown + } catch { + return null + } +} + +// Map one rich, cost-free decoder call into the host's ParsedProviderCall. Cost +// re-enters here: a CLI-reported meter figure (present only when actually +// metered, a metered $0 included) is carried as `costBasis: 'measured'`; a +// missing/negative cost falls back to `costBasis: 'estimated'` so the parser.ts +// pricing pass fills `costUSD` from the token buckets — byte-identical to the +// pre-migration in-decoder `calculateCost` (Phase 0, Pattern B). Bash base-name +// extraction (and its `strip-ansi` dependency) stays CLI-side: the core decoder +// carries the raw command strings; the host reduces them to base names here. +function toProviderCall(rich: ClineCliDecodedCall): ParsedProviderCall { + const measured = rich.reportedCost !== undefined + return { + provider: 'cline-cli', + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + ...(measured + ? { costUSD: rich.reportedCost, costBasis: 'measured' as const } + : { costBasis: 'estimated' as const }), + costIsEstimated: !measured, + tools: rich.tools, + // Same flat list the pre-migration decode produced (no Set): per-command + // counts keep matching upstream behavior. + bashCommands: rich.rawBashCommands.flatMap(c => extractBashCommands(c)), + skills: rich.skills.length > 0 ? rich.skills : undefined, + subagentTypes: rich.subagentTypes.length > 0 ? rich.subagentTypes : undefined, + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + turnId: rich.turnId, + toolSequence: rich.toolSequence, + userMessage: rich.userMessage, + sessionId: rich.sessionId, + project: rich.project, + ...(rich.projectPath ? { projectPath: rich.projectPath } : {}), + ...(rich.workingDirectory ? { workingDirectory: rich.workingDirectory } : {}), + } +} + +export function createClineCliProvider(overrideDir?: string): Provider { + const sessionsDir = (): string => overrideDir ?? getClineCliSessionsDir() + + return createBridgedProvider({ + name: PROVIDER_NAME, + displayName: DISPLAY_NAME, + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return clineCliToolNameMap[rawTool] ?? rawTool + }, + + async probeRoots(): Promise { + return [{ path: sessionsDir(), label: 'Cline CLI sessions' }] + }, + + async discoverSessions(): Promise { + const dir = sessionsDir() + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) + const sources: SessionSource[] = [] + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory()) continue + const sessionId = entry.name + const metaPath = join(dir, sessionId, `${sessionId}.json`) + const meta = await readJson(metaPath) + if (!isRecord(meta)) continue + + const workspace = nonEmptyString(meta['workspace_root']) ?? nonEmptyString(meta['cwd']) + sources.push({ + path: metaPath, + project: projectName(workspace), + provider: PROVIDER_NAME, + }) + } + + return sources + }, + + // I/O adapter: read + JSON-parse the session metadata file and its + // co-located messages file (falling back to the recorded absolute path, + // which is stale once a session directory is copied between machines), then + // hand the core decoder ONE composite { meta, messages } record. The + // decoder stays path-free: the session-id basename fallback and the + // discovered project label are injected here. + async readRecords(source: SessionSource): Promise { + const meta = await readJson(source.path) + if (!isRecord(meta)) return null + if (nonEmptyString(meta['session_id']) === undefined) { + meta['session_id'] = basename(source.path).replace(/\.json$/, '') + } + meta['project'] = source.project + + const sibling = join(source.path.replace(/\.json$/, '') + '.messages.json') + let doc = await readJson(sibling) + if (!isRecord(doc)) { + const recorded = nonEmptyString(meta['messages_path']) + if (recorded) doc = await readJson(recorded) + } + + const messages = isRecord(doc) && Array.isArray(doc['messages']) ? doc['messages'] : [] + const record: ClineCliSessionRecords = { meta, messages } + return [record] + }, + + decode: decodeClineCli, + toProviderCall, + }) +} + +export const clineCli = createClineCliProvider() diff --git a/packages/cli/src/providers/codex.ts b/packages/cli/src/providers/codex.ts index 11f593ab..ae05fb2c 100644 --- a/packages/cli/src/providers/codex.ts +++ b/packages/cli/src/providers/codex.ts @@ -4,7 +4,7 @@ import { createInterface } from 'readline' import { basename, join } from 'path' import { homedir } from 'os' -import { decodeCodex, codexToolNameMap, countUnifiedDiffLoc } from '@codeburn/core/providers/codex' +import { decodeCodex, codexToolNameMap, countUnifiedDiffLoc, applyCodexTimingPatches } from '@codeburn/core/providers/codex' import type { CodexDecodedCall, CodexDecodeState, CodexEntry } from '@codeburn/core/providers/codex' import { readSessionLines } from '../fs-utils.js' @@ -96,26 +96,48 @@ async function isValidCodexSession(filePath: string): Promise<{ valid: boolean; return { valid, meta: valid ? entry : undefined } } -async function discoverSessionFile(filePath: string): Promise { +type DiscoveredCodexSession = { + source: SessionSource + sessionId?: string +} + +async function discoverSessionFile(filePath: string): Promise { const s = await stat(filePath).catch(() => null) if (!s?.isFile()) return null const cachedProject = await getCachedCodexProject(filePath) + const { valid, meta } = await isValidCodexSession(filePath) if (cachedProject) { - return { path: filePath, project: cachedProject, provider: 'codex' } + return { + source: { path: filePath, project: cachedProject, provider: 'codex' }, + sessionId: valid ? meta?.payload?.session_id : undefined, + } } - const { valid, meta } = await isValidCodexSession(filePath) if (!valid || !meta) return null const cwd = meta.payload?.cwd ?? 'unknown' - return { path: filePath, project: sanitizeProject(cwd), provider: 'codex' } + return { + source: { path: filePath, project: sanitizeProject(cwd), provider: 'codex' }, + sessionId: meta.payload?.session_id, + } } async function discoverSessionsInDir(codexDir: string): Promise { const sources: SessionSource[] = [] + // A rollout can exist in both roots during/after archiving. The active root + // is scanned first, and session_id keeps the archived copy from resurfacing. + const seenSessionIds = new Set() const sessionsDir = join(codexDir, 'sessions') + const addSession = (discovered: DiscoveredCodexSession | null): void => { + if (!discovered) return + const sessionId = discovered.sessionId?.trim() + if (sessionId && seenSessionIds.has(sessionId)) return + if (sessionId) seenSessionIds.add(sessionId) + sources.push(discovered.source) + } + const years = await readdir(sessionsDir).catch(() => [] as string[]) for (const year of years) { @@ -136,8 +158,7 @@ async function discoverSessionsInDir(codexDir: string): Promise for (const file of files) { if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue const filePath = join(dayDir, file) - const source = await discoverSessionFile(filePath) - if (source) sources.push(source) + addSession(await discoverSessionFile(filePath)) } } } @@ -149,8 +170,7 @@ async function discoverSessionsInDir(codexDir: string): Promise const archivedFiles = await readdir(archivedDir).catch(() => [] as string[]) for (const file of archivedFiles) { if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue - const source = await discoverSessionFile(join(archivedDir, file)) - if (source) sources.push(source) + addSession(await discoverSessionFile(join(archivedDir, file))) } return sources @@ -187,6 +207,9 @@ function toPricedProviderCall(rich: CodexDecodedCall): ParsedProviderCall { ...(rich.locRemoved !== undefined ? { locRemoved: rich.locRemoved } : {}), ...(rich.editFailed !== undefined ? { editFailed: rich.editFailed } : {}), ...(rich.costIsEstimated ? { costIsEstimated: rich.costIsEstimated } : {}), + ...(rich.activeDurationMs !== undefined ? { activeDurationMs: rich.activeDurationMs } : {}), + ...(rich.activeGeneratedTokens !== undefined ? { activeGeneratedTokens: rich.activeGeneratedTokens } : {}), + ...(rich.toolWaitMs !== undefined ? { toolWaitMs: rich.toolWaitMs } : {}), } return priceProviderCall(call) } @@ -244,10 +267,14 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars // pin an empty result set (mirrors the pre-phase-4 sawAnyLine guard). if (!sawAnyLine && !resume) return - const { calls: richCalls, state: newState } = decodeCodex({ + const { calls: richCalls, state: newState, timingPatches } = decodeCodex({ records, context: { privacyKey: '', providerId: 'codex', sourceRef: source.path }, state: initialState, + // The decoder's task-timing window addresses the CONCATENATED call list + // (prior cached calls + this pass's calls), so it must know how many + // calls precede this pass. + priorCallCount: resume ? priorCalls.length : 0, // Live shared dedup set (mutated in place); the decoder leaves // state.seenKeys empty when a live set is supplied. seenKeys, @@ -257,6 +284,11 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const newPriced = richCalls.map(toPricedProviderCall) const allCalls = resume ? [...priorCalls, ...newPriced] : newPriced + // A task straddling the append boundary: the decoder attributed its + // in-pass calls and returned patches for the earlier-pass calls (which + // live in `priorCalls`), addressed absolutely into the concatenated list. + if (timingPatches && timingPatches.length > 0) applyCodexTimingPatches(allCalls, timingPatches) + // Persist the state blob + host-priced calls + resume offset. seenKeys is // stripped from the stored state (cross-file dedup is reconstructed each // run from the session cache, as the pre-phase-4 shared set was). diff --git a/packages/cli/src/providers/index.ts b/packages/cli/src/providers/index.ts index 07dc3903..8c538019 100644 --- a/packages/cli/src/providers/index.ts +++ b/packages/cli/src/providers/index.ts @@ -1,5 +1,6 @@ import { claude } from './claude.js' import { cline } from './cline.js' +import { clineCli } from './cline-cli.js' import { codewhale } from './codewhale.js' import { codebuff } from './codebuff.js' import { codex } from './codex.js' @@ -190,7 +191,7 @@ async function loadZed(): Promise { } } -const coreProviders: Provider[] = [claude, cline, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] +const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] // Lazily loaded providers, listed by name so --provider validation works even // when an optional module fails to load. Must stay in sync with getAllProviders. diff --git a/packages/cli/src/providers/types.ts b/packages/cli/src/providers/types.ts index 0cb3a6f0..e3e15e71 100644 --- a/packages/cli/src/providers/types.ts +++ b/packages/cli/src/providers/types.ts @@ -76,6 +76,14 @@ export type ParsedProviderCall = { // Exact provider-recorded cwd, kept separately because projectPath may later // canonicalize a linked worktree to its main repository. workingDirectory?: string + // Tool-excluded active throughput: `activeDurationMs` is the task duration + // minus recorded tool-wait intervals, `activeGeneratedTokens` the task's + // generated tokens, both attributed to this call proportionally (Codex only). + // `toolWaitMs` is the excluded wait share. Present only when the enclosing + // task recorded both timing and generated tokens. + activeDurationMs?: number + activeGeneratedTokens?: number + toolWaitMs?: number } // A directory or database file that a provider's discoverSessions() scans. diff --git a/packages/cli/src/session-cache.ts b/packages/cli/src/session-cache.ts index 461dd461..3c01811b 100644 --- a/packages/cli/src/session-cache.ts +++ b/packages/cli/src/session-cache.ts @@ -51,6 +51,10 @@ export type CachedCall = { toolErrors?: number // Codex: count of this call's patch applications with success === false. editFailed?: number + // Tool-excluded active throughput (Codex only), attributed per call. + activeDurationMs?: number + activeGeneratedTokens?: number + toolWaitMs?: number } export type CachedTurn = { @@ -170,6 +174,7 @@ const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000 export const PROVIDER_ENV_VARS: Record = { claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR'], + 'cline-cli': ['CLINE_SESSION_DATA_DIR', 'CLINE_DATA_DIR', 'CLINE_DIR'], codewhale: ['CODEWHALE_HOME'], codex: ['CODEX_HOME'], hermes: ['HERMES_HOME'], @@ -198,6 +203,13 @@ export const DURABLE_PROVIDER_NAMES: ReadonlySet = new Set(['copilot']) // needs no suffix: the cli-shutdown-cost-v1 bump below already forces its one // re-parse, which lands the flag too, and durable orphans now survive // fingerprint changes (the carry-forward in getOrCreateProviderSection). +// Dedup-key hygiene (#931): codebuff, zerostack, pi/omp and grok now thread a +// FINGERPRINT of the source path into their dedup keys (and lingtai-tui +// normalizes the model component) instead of the raw path / raw ledger text. +// The session cache seeds its dedup sets from the CACHED keys, so a pre-fix +// cache keeps the raw-path keys and the same records re-ingest under the new +// key shape. Each entry/suffix below changes the provider's env fingerprint, +// which forces the one-time re-parse that drops the raw-path keys from disk. export const PROVIDER_PARSE_VERSIONS: Record = { // rich-session-capture-v1: parse-time capture of per-turn gitBranch, per-call // LOC deltas / interruptions / userModified / toolErrors, and session-level @@ -205,6 +217,10 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // the new optional fields. claude: 'advisor-usage-v1-skills-rich-capture-v1-cross-provider-pr-v1', cline: 'worktree-project-grouping-v1', + // reported-cost-v1: the CLI reports its own per-message cost, so entries + // cached before cline-cli joined the reported-cost allowlist in parser.ts + // hold costUSD: undefined and get re-priced from tokens on every read. + 'cline-cli': 'reported-cost-v1', codewhale: 'aggregate-session-v1-est-cost', // Bump when the Codex parser changes attribution so unchanged, already-cached // session files re-parse (session-cache.json serves them without invoking the @@ -213,13 +229,24 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // rich-session-capture-v1: per-call LOC deltas + editFailed from // patch_apply_end. (The codex-results.json CODEX_CACHE_VERSION is bumped in // lockstep so the pre-session-cache layer re-parses too.) - codex: 'mcp-attribution-v2-est-cost-rich-capture-v1-cross-provider-pr-v1', + // task-window-v10: the codex-results state now carries the task-timing window + // (see codex-cache.ts v10), so sessions cached without it — including any + // whose mid-task calls never got activeDurationMs/activeGeneratedTokens/ + // toolWaitMs — re-parse once and pick the fields up. + codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-task-window-v10', cursor: 'composer-anchored-crediting-v1-est-cost', 'cursor-agent': 'workspaceless-transcript-v1', copilot: 'cli-shutdown-cost-v1-skills', - grok: 'estimated-cost-v1', + // source-ref-fingerprint-v1: the dedup key now threads a fingerprint of the + // source path (chat dir) instead of the raw path, which ships on the + // envelope. Forces one re-parse so cached raw-path keys are dropped. + codebuff: 'source-ref-fingerprint-v1', + zerostack: 'source-ref-fingerprint-v1', + pi: 'source-ref-fingerprint-v1', + omp: 'source-ref-fingerprint-v1', + grok: 'estimated-cost-v1-source-ref-fingerprint-v1', hermes: 'reasoning-output-accounting-v1-est-cost', - 'lingtai-tui': 'token-ledger-registry-activity-v3', + 'lingtai-tui': 'token-ledger-registry-activity-v3-source-ref-fp-v1-model-normalized-v1', 'ibm-bob': 'worktree-project-grouping-v1', kiro: 'ide-parsing-v1-est-cost', quickdesk: 'emf-sqlite-v2-est-cost', @@ -337,6 +364,9 @@ function validateCall(c: unknown): c is CachedCall { && (o['speed'] === 'standard' || o['speed'] === 'fast') && isOptionalNum(o['costUSD']) && isOptionalBool(o['isEstimated']) + && isOptionalNum(o['activeDurationMs']) + && isOptionalNum(o['activeGeneratedTokens']) + && isOptionalNum(o['toolWaitMs']) && isStringArray(o['tools']) && isStringArray(o['bashCommands']) && isStringArray(o['skills']) diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 84727dce..fe7a87f3 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -82,6 +82,13 @@ export type ParsedApiCall = { /// Count of this call's tool results flagged `is_error` (Claude tool_result /// blocks). Bash stderr alone is NOT counted (warnings go there). Omitted at 0. toolErrors?: number + /// Tool-excluded active throughput (Codex only): task duration minus recorded + /// tool-wait intervals, the task's generated tokens, and the excluded wait + /// share, attributed to this call proportionally. Omitted when the task + /// recorded no timing. + activeDurationMs?: number + activeGeneratedTokens?: number + toolWaitMs?: number } export type TaskCategory = @@ -191,7 +198,7 @@ export type SessionSummary = { /// from a provider that never captures branches (→ contributes nothing). /// Claude only; absent otherwise. everHadBranch?: boolean - modelBreakdown: Record + modelBreakdown: Record toolBreakdown: Record mcpBreakdown: Record bashBreakdown: Record diff --git a/packages/cli/tests/cli-codex-tps.test.ts b/packages/cli/tests/cli-codex-tps.test.ts new file mode 100644 index 00000000..4b39027a --- /dev/null +++ b/packages/cli/tests/cli-codex-tps.test.ts @@ -0,0 +1,46 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it } from 'vitest' + +const homes: string[] = [] + +afterEach(async () => { + while (homes.length) await rm(homes.pop()!, { recursive: true, force: true }) +}) + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { ...process.env, HOME: home, CODEX_HOME: join(home, '.codex'), TZ: 'UTC' }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +describe('codex-tps CLI validation', () => { + it('rejects sub-second watch intervals', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-')) + homes.push(home) + const result = runCli(['codex-tps', '--watch', '0.1'], home) + expect(result.status).toBe(1) + expect(result.stderr).toContain('watch must be 0 or at least 1 second') + }) + + it('rejects JSON watch output instead of concatenating invalid JSON documents', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-')) + homes.push(home) + const result = runCli(['codex-tps', '--json', '--watch', '1'], home) + expect(result.status).toBe(2) + expect(result.stderr).toContain('--json cannot be combined with --watch') + }) + + it('returns a nonzero status for a missing explicit rollout', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-')) + homes.push(home) + const result = runCli(['codex-tps', join(home, 'missing.jsonl')], home) + expect(result.status).toBe(1) + expect(result.stderr).toContain('session file not found') + }) +}) diff --git a/packages/cli/tests/codex-resume.test.ts b/packages/cli/tests/codex-resume.test.ts index a5f90dbb..7aafe50a 100644 --- a/packages/cli/tests/codex-resume.test.ts +++ b/packages/cli/tests/codex-resume.test.ts @@ -116,4 +116,44 @@ describe('codex append-resume through the CLI cache', () => { expect(v2.map(c => c.model)).toContain('SENTINEL-MODEL') }) + + it('attributes active timing across the append boundary (mid-task cut then task_complete)', async () => { + // A live-session cut: run 1 parses a rollout that ends mid-task (token_count + // emitted, task_complete not yet written), so its call has no timing. Run 2 + // appends the task_complete and resumes from the persisted state + byte + // offset; the carried task window must attribute activeDurationMs / + // activeGeneratedTokens / toolWaitMs to the EARLIER-pass call — the exact + // path the mapper write-hop test does not exercise. + const TIMING_PREFIX = [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-04-14T10:00:00Z', payload: { session_id: 'sess-timing', model: 'gpt-5.5', cwd: '/Users/t/p', originator: 'codex-cli' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started', turn_id: 'turn-1' } }), + userMessage('run the tool', '2026-04-14T10:00:01Z'), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:02Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:05Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:01:10Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 }, total_token_usage: { total_tokens: 220 } } } }), + ] + const TIMING_COMPLETE = [ + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:01:11Z', payload: { type: 'task_complete', duration_ms: 10_000 } }), + ] + + const filePath = await writeAt(tmpDir, 'rollout-timing-grow.jsonl', TIMING_PREFIX) + + // Run 1: cold decode, ends mid-task — no timing yet. + const v1 = await parseFile(filePath) + expect(v1).toHaveLength(1) + expect(v1[0]!.activeDurationMs).toBeUndefined() + + // Run 2: the file grows by the task_complete; the codex-results cache + // resumes from the persisted state + byte offset. + await appendFile(filePath, TIMING_COMPLETE.join('\n') + '\n') + const v2 = await parseFile(filePath) + + // Cold decode of the full grown file: the resumed output must equal it. + const coldPath = await writeAt(tmpDir, 'rollout-timing-cold.jsonl', [...TIMING_PREFIX, ...TIMING_COMPLETE]) + const cold = await parseFile(coldPath) + expect(cold).toHaveLength(1) + expect(cold[0]).toMatchObject({ activeDurationMs: 7000, activeGeneratedTokens: 120, toolWaitMs: 3000 }) + + expect(v2).toEqual(cold) + }) }) diff --git a/packages/cli/tests/codex-throughput-cache-roundtrip.test.ts b/packages/cli/tests/codex-throughput-cache-roundtrip.test.ts new file mode 100644 index 00000000..fd81e376 --- /dev/null +++ b/packages/cli/tests/codex-throughput-cache-roundtrip.test.ts @@ -0,0 +1,160 @@ +// End-to-end regression for the dashboard Tok/s column (activeDurationMs / +// activeGeneratedTokens / toolWaitMs). providerCallToCachedCall used to drop +// the three throughput fields when converting a parsed codex call into a +// cached turn, so a mapper-level unit test passed while the aggregated +// modelBreakdown (and with it the dashboard column) stayed empty — the exact +// failure this test guards against. It drives the full parseAllSessions +// pipeline twice: +// +// 1. cold: the rollout is parsed and written to session-cache.json through +// providerCallToCachedCall (the write hop); +// 2. warm: the file is byte-identical, so runParse serves the unchanged +// file's turns from the on-disk cache via cachedCallToApiCall (the read +// hop) without ever invoking the provider parser again. +// +// The fields must survive both hops to show up in modelBreakdown, which is the +// shape the dashboard aggregates (aggregateModelTotals) into its Tok/s column. + +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest' +import { mkdir, rm, writeFile, appendFile } from 'fs/promises' +import { join } from 'path' + +import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { aggregateModelTotals } from '../src/model-breakdown.js' + +const testRoot = vi.hoisted(() => { + const root = `${process.env['TMPDIR'] || '/tmp'}/codex-tps-roundtrip-${process.pid}-${Date.now()}` + process.env['HOME'] = `${root}/home` + process.env['USERPROFILE'] = `${root}/home` + process.env['CODEX_HOME'] = `${root}/codex` + return root +}) + +const CODEX_HOME = join(testRoot, 'codex') +const CACHE_DIR = join(testRoot, 'cache') + +beforeEach(() => { + process.env['HOME'] = join(testRoot, 'home') + process.env['USERPROFILE'] = join(testRoot, 'home') + process.env['CODEX_HOME'] = CODEX_HOME + process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR +}) + +afterAll(async () => { + await rm(testRoot, { recursive: true, force: true }) +}) + +// The single codex session in a parseAllSessions result, and its only +// modelBreakdown entry (keyed by the friendly short name, whatever it resolves +// to for the fixture model). +function firstModelEntry(projects: Awaited>) { + expect(projects).toHaveLength(1) + const sessions = projects[0]!.sessions + expect(sessions).toHaveLength(1) + const entries = Object.entries(sessions[0]!.modelBreakdown) + expect(entries).toHaveLength(1) + return entries[0]![1] +} + +describe('codex active-throughput fields survive the session-cache round trip', () => { + it('reaches modelBreakdown on a cold parse AND a warm cache read', async () => { + const sessionDir = join(CODEX_HOME, 'sessions', '2026', '04', '14') + await mkdir(sessionDir, { recursive: true }) + await mkdir(CACHE_DIR, { recursive: true }) + + // Same shape as the fixture in tests/providers/codex.test.ts that yields + // activeDurationMs 7000 / activeGeneratedTokens 120 / toolWaitMs 3000: + // task_started -> 3s custom tool call (excluded as tool wait) -> + // token_count (100 output + 20 reasoning) -> task_complete duration_ms 10s. + const lines = [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-04-14T10:00:00Z', payload: { session_id: 'sess-tps', model: 'gpt-5.5', cwd: '/Users/test/proj', originator: 'codex-cli' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started', turn_id: 'turn-1' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'run the tool' }] } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:02Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:05Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:01:10Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 }, total_token_usage: { total_tokens: 220 } } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:01:11Z', payload: { type: 'task_complete', duration_ms: 10_000 } }), + ] + await writeFile(join(sessionDir, 'rollout-tps.jsonl'), lines.join('\n') + '\n') + + // Run 1: cold cache. The fresh parse is immediately converted to cached + // turns, so even this run crosses the providerCallToCachedCall write hop + // before the query-time aggregation reads the cached turns back. + clearSessionCache() + const fresh = await parseAllSessions(undefined, 'codex') + expect(firstModelEntry(fresh)).toMatchObject({ + activeDurationMs: 7000, + activeGeneratedTokens: 120, + toolWaitMs: 3000, + }) + + // Dashboard shape: aggregateModelTotals feeds the Tok/s column + // (activeGeneratedTokens / (activeDurationMs / 1000)). + const totals = aggregateModelTotals(fresh) + expect(Object.values(totals)).toHaveLength(1) + expect(Object.values(totals)[0]!).toMatchObject({ activeDurationMs: 7000, activeGeneratedTokens: 120 }) + + // Run 2: warm cache. The rollout is byte-identical, so the unchanged file + // is served straight from session-cache.json — the provider parser never + // runs. These fields only exist if run 1 actually wrote them. + clearSessionCache() + const warm = await parseAllSessions(undefined, 'codex') + expect(firstModelEntry(warm)).toMatchObject({ + activeDurationMs: 7000, + activeGeneratedTokens: 120, + toolWaitMs: 3000, + }) + }) + + it('reaches modelBreakdown on the append-resume path (mid-task cut then task_complete)', async () => { + // The live-session case the cold/warm round trip does NOT cover: run 1 + // parses while a task is still running (token_count emitted, task_complete + // not yet written), so the call is cached without timing. Run 2 re-parses + // the GROWN file incrementally — the codex-results cache resumes from its + // persisted state + byte offset and the carried task window attributes the + // three throughput fields to the earlier-pass call. Without that window, + // the fields stay missing exactly like they did before the mapper repair. + const sessionDir = join(CODEX_HOME, 'sessions', '2026', '04', '15') + // Hermetic: this `it` runs against whatever the previous one left behind + // (its rollout file, both caches, and the in-memory result cache). + await rm(join(CODEX_HOME, 'sessions'), { recursive: true, force: true }) + await mkdir(sessionDir, { recursive: true }) + await rm(join(CACHE_DIR, 'session-cache.v7.json'), { force: true }) + await rm(join(CACHE_DIR, 'codex-results.json'), { force: true }) + + const midTaskLines = [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-04-14T10:00:00Z', payload: { session_id: 'sess-append', model: 'gpt-5.5', cwd: '/Users/test/proj', originator: 'codex-cli' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started', turn_id: 'turn-1' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'run the tool' }] } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:02Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:05Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }), + // NOTE: no task_complete yet — the rollout ends mid-task. + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:01:10Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 }, total_token_usage: { total_tokens: 220 } } } }), + ] + const filePath = join(sessionDir, 'rollout-append.jsonl') + await writeFile(filePath, midTaskLines.join('\n') + '\n') + + // Run 1: mid-task parse — the call exists but has no timing yet. + clearSessionCache() + const midTask = await parseAllSessions(undefined, 'codex') + expect(firstModelEntry(midTask)).not.toHaveProperty('activeDurationMs') + + // The task completes: Codex appends the task_complete line. + await appendFile(filePath, JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:01:11Z', payload: { type: 'task_complete', duration_ms: 10_000 } }) + '\n') + + // Run 2: the grown file re-parses through the append-resume path; the + // fields must now survive into modelBreakdown. + clearSessionCache() + const appended = await parseAllSessions(undefined, 'codex') + expect(firstModelEntry(appended)).toMatchObject({ + activeDurationMs: 7000, + activeGeneratedTokens: 120, + toolWaitMs: 3000, + }) + + // Dashboard shape: the Tok/s column aggregates the same fields. + const totals = aggregateModelTotals(appended) + expect(Object.values(totals)).toHaveLength(1) + expect(Object.values(totals)[0]!).toMatchObject({ activeDurationMs: 7000, activeGeneratedTokens: 120 }) + }) +}) diff --git a/packages/cli/tests/codex-throughput.test.ts b/packages/cli/tests/codex-throughput.test.ts new file mode 100644 index 00000000..1e0fd4da --- /dev/null +++ b/packages/cli/tests/codex-throughput.test.ts @@ -0,0 +1,124 @@ +import { appendFile, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { CodexThroughputReader, readCodexThroughput, renderCodexThroughput } from '../src/codex-throughput.js' + +describe('Codex throughput prototype', () => { + it('estimates generated tokens/sec between token_count checkpoints', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-')) + const path = join(dir, 'rollout.jsonl') + await writeFile(path, [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-07-25T00:00:02.000Z', payload: { type: 'function_call', call_id: 'tool-1' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-07-25T00:00:05.000Z', payload: { type: 'function_call_output', call_id: 'tool-1' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z', payload: { type: 'mcp_tool_call_end', duration: { secs: 3, nanos: 0 } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 80, reasoning_output_tokens: 20 }, total_token_usage: { total_tokens: 100, output_tokens: 80, reasoning_output_tokens: 20 } } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:15.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 40, reasoning_output_tokens: 10 }, total_token_usage: { total_tokens: 150, output_tokens: 120, reasoning_output_tokens: 30 } } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:16.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }), + ].join('\n')) + + const points = await readCodexThroughput(path) + expect(points).toHaveLength(2) + expect(points[1]).toMatchObject({ generatedTokens: 50, elapsedSeconds: 5, generatedTokensPerSecond: 10, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 21.428571428571427, toolWaitSeconds: 3, model: 'gpt-5.6-sol' }) + expect(renderCodexThroughput(points, path)).toContain('21.4 generated tokens/sec') + }) + + it('parses only appended complete lines while watching a growing rollout', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-watch-')) + const path = join(dir, 'rollout.jsonl') + const first = JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 8, reasoning_output_tokens: 2 }, total_token_usage: { total_tokens: 10, output_tokens: 8, reasoning_output_tokens: 2 } } } }) + const second = JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:01.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 4, reasoning_output_tokens: 1 }, total_token_usage: { total_tokens: 15, output_tokens: 12, reasoning_output_tokens: 3 } } } }) + await writeFile(path, first.slice(0, 40)) + const reader = new CodexThroughputReader() + expect(await reader.update(path)).toEqual([]) + await appendFile(path, first.slice(40) + '\n' + second + '\n') + const points = await reader.update(path) + expect(points).toHaveLength(2) + expect(points[1]).toMatchObject({ generatedTokens: 5, generatedTokensPerSecond: 5 }) + }) + + it('ignores replayed pre-fork checkpoints before estimating new work', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-fork-')) + const path = join(dir, 'rollout.jsonl') + const line = (timestamp: string, payload: Record) => JSON.stringify({ type: 'event_msg', timestamp, payload }) + await writeFile(path, [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol', forked_from_id: 'parent' } }), + line('2026-07-25T00:00:01.000Z', { type: 'task_started' }), + line('2026-07-25T00:00:02.000Z', { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } }), + line('2026-07-25T00:00:03.000Z', { type: 'task_complete', duration_ms: 1000 }), + line('2026-07-25T00:00:06.000Z', { type: 'task_started' }), + line('2026-07-25T00:00:08.000Z', { type: 'token_count', info: { last_token_usage: { output_tokens: 20 }, total_token_usage: { total_tokens: 20, output_tokens: 20 } } }), + line('2026-07-25T00:00:10.000Z', { type: 'task_complete', duration_ms: 4000 }), + ].join('\n')) + + const points = await readCodexThroughput(path) + expect(points).toHaveLength(1) + expect(points[0]).toMatchObject({ generatedTokens: 20, activeGeneratedTokensPerSecond: 5 }) + }) + + it('keeps oversized rollout lines bounded while extracting token usage', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-large-')) + const path = join(dir, 'rollout.jsonl') + const largeResult = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-07-25T00:00:01.000Z', + payload: { + type: 'token_count', + info: { last_token_usage: { output_tokens: 12 }, total_token_usage: { total_tokens: 12, output_tokens: 12 } }, + result: 'x'.repeat(5 * 1024 * 1024), + }, + }) + await writeFile(path, largeResult) + const points = await readCodexThroughput(path) + expect(points).toHaveLength(1) + expect(points[0]?.generatedTokens).toBe(12) + }) + + it('keeps MCP duration when arguments and result surround the middle field', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-mcp-large-')) + const path = join(dir, 'rollout.jsonl') + const mcp = JSON.stringify({ + type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z', + payload: { + type: 'mcp_tool_call_end', + invocation: { server: 'github', tool: 'get_issue', arguments: { body: 'x'.repeat(5 * 1024 * 1024) } }, + duration: { secs: 3, nanos: 0 }, + result: { duration: '9s', text: 'x'.repeat(5 * 1024 * 1024) }, + }, + }) + await writeFile(path, [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }), + mcp, + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:08.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }), + ].join('\n')) + const points = await readCodexThroughput(path) + expect(points[0]).toMatchObject({ toolWaitSeconds: 3, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 100 / 7 }) + }) + + it('keeps a streamed string MCP duration when arguments and result surround the middle field', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-mcp-string-large-')) + const path = join(dir, 'rollout.jsonl') + const mcp = JSON.stringify({ + type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z', + payload: { + type: 'mcp_tool_call_end', + invocation: { server: 'github', tool: 'get_issue', arguments: { body: 'x'.repeat(5 * 1024 * 1024) } }, + duration: '3s', + result: { duration: '9s', text: 'x'.repeat(5 * 1024 * 1024) }, + }, + }) + await writeFile(path, [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }), + mcp, + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:08.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }), + ].join('\n')) + const points = await readCodexThroughput(path) + expect(points[0]).toMatchObject({ toolWaitSeconds: 3, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 100 / 7 }) + }) +}) diff --git a/packages/cli/tests/daily-cache-carry-forward.test.ts b/packages/cli/tests/daily-cache-carry-forward.test.ts index 62197fae..1ca75888 100644 --- a/packages/cli/tests/daily-cache-carry-forward.test.ts +++ b/packages/cli/tests/daily-cache-carry-forward.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdir, readFile, rename, rm, writeFile } from 'fs/promises' import { existsSync } from 'fs' import { tmpdir } from 'os' @@ -304,6 +304,48 @@ describe('never-lose invariant: invalidations with vanished sources', () => { expect(out.days[0]).toMatchObject({ date: d.date, cost: d.cost, calls: d.calls, carried: true }) }) + it('a version bump forces a re-derive that drops the raw-path dedup keys', async () => { + // The pre-fix binary shipped daily-cache v15. THIS LITERAL IS THE POINT: + // it must stay pinned to the version the pre-fix binary wrote, so a warm + // complete cache from that binary sits at daily-cache.v15.json with dedup + // keys threaded on RAW source paths (codebuff/zerostack/pi/omp/grok) and + // raw ledger model text (lingtai-tui). Those keys ship on the observation + // envelope, and the session cache seeds its dedup sets from the cached + // keys, so the pre-fix cache re-ingests the same records under the new + // key shapes — the pre-fix day below carries the inflated double count. + // Only the MIN_SUPPORTED_VERSION bump decides whether that file loads as + // the trusted CURRENT cache — freezing the inflated rollup forever — or as + // an old-version file that forces the one-time re-derive, which drops the + // raw-path keys and lands the corrected single count. When the next bump + // lands, move this literal to the version the current binary shipped. + const PRE_FIX_CACHE_VERSION = 15 + const preFixDay = day(daysAgoStr(30), { codebuff: slice(10.0, 2) }) + const preFixCache: DailyCache = { + version: PRE_FIX_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [preFixDay], + complete: true, + } + await writeFile(join(TMP_CACHE_ROOT, `daily-cache.v${PRE_FIX_CACHE_VERSION}.json`), JSON.stringify(preFixCache), 'utf-8') + + // The re-derive under the fingerprint-shaped keys sees ONE record per + // session (the raw-path keys no longer collide with the new keys, so the + // re-ingestion is gone): corrected 5.0 / 1 call. + const aggregate = vi.fn(() => [day(daysAgoStr(30), { codebuff: slice(5.0, 1) })]) + const out = await ensureCacheHydrated(noSessions, aggregate, 'cfg-A') + + // The bump forced a full re-derivation: the fresh parse was consulted. + expect(aggregate).toHaveBeenCalled() + expect(out.version).toBe(DAILY_CACHE_VERSION) + expect(out.complete).toBe(true) + // The corrected single count wins; the inflated pre-fix slice is gone. + expect(out.days[0]!.providers['codebuff']!.cost).toBe(5.0) + expect(out.days[0]!.providers['codebuff']!.calls).toBe(1) + expect(out.days[0]!.cost).toBe(5.0) + }) + it('a same-version file found under an old name is trusted as-is (no spurious rebuild)', async () => { const d = await seed() await rename(dailyCachePath(), join(TMP_CACHE_ROOT, 'daily-cache.json')) diff --git a/packages/cli/tests/provider-registry.test.ts b/packages/cli/tests/provider-registry.test.ts index 515efa7e..4ec70764 100644 --- a/packages/cli/tests/provider-registry.test.ts +++ b/packages/cli/tests/provider-registry.test.ts @@ -14,7 +14,7 @@ function fakeProvider(name: string, discover: Provider['discoverSessions']): Pro describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) }) it('codebuff tool display names normalize codebuff-native names to canonical set', () => { diff --git a/packages/cli/tests/provider-turn-grouping.test.ts b/packages/cli/tests/provider-turn-grouping.test.ts index bee7585d..2127aa9a 100644 --- a/packages/cli/tests/provider-turn-grouping.test.ts +++ b/packages/cli/tests/provider-turn-grouping.test.ts @@ -196,4 +196,55 @@ describe('provider turn grouping', () => { delete process.env['KIRO_HOME'] } }) + + it('preserves Cline CLI reported cost through cache conversion instead of re-pricing from tokens', async () => { + const sessionsDir = join(home, '.cline', 'data', 'sessions') + const sessionId = '1785701058566_vnwtz' + const dir = join(sessionsDir, sessionId) + await mkdir(dir, { recursive: true }) + process.env['CLINE_SESSION_DATA_DIR'] = sessionsDir + + // A large token count paired with a deliberately tiny reported cost: any + // token-based re-pricing would land orders of magnitude above $0.0123, + // so passing means the CLI's own per-message cost survived the round trip. + await writeFile(join(dir, `${sessionId}.json`), JSON.stringify({ + version: 1, + session_id: sessionId, + source: 'cli', + status: 'completed', + provider: 'cline-pass', + model: 'z-ai/glm-5.2', + cwd: '/Users/test/project-a', + workspace_root: '/Users/test/project-a', + started_at: '2026-05-16T10:00:00.000Z', + ended_at: '2026-05-16T10:01:00.000Z', + metadata: {}, + })) + await writeFile(join(dir, `${sessionId}.messages.json`), JSON.stringify({ + version: 1, + agent: 'lead', + sessionId, + messages: [ + { id: 'u1', role: 'user', content: [{ type: 'text', text: 'do the thing' }], ts: Date.parse('2026-05-16T10:00:00.000Z') }, + { + id: 'a1', + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + ts: Date.parse('2026-05-16T10:00:30.000Z'), + modelInfo: { id: 'z-ai/glm-5.2', provider: 'cline-pass' }, + metrics: { inputTokens: 500000, outputTokens: 20000, cacheReadTokens: 100000, cacheWriteTokens: 0, cost: 0.0123 }, + }, + ], + })) + + try { + const parseAllSessions = await loadParser() + const projects = await parseAllSessions(dayRange(), 'cline-cli') + const session = projects[0]!.sessions[0]! + + expect(session.totalCostUSD).toBeCloseTo(0.0123, 8) + } finally { + delete process.env['CLINE_SESSION_DATA_DIR'] + } + }) }) diff --git a/packages/cli/tests/providers/cline-cli.test.ts b/packages/cli/tests/providers/cline-cli.test.ts new file mode 100644 index 00000000..e53a4264 --- /dev/null +++ b/packages/cli/tests/providers/cline-cli.test.ts @@ -0,0 +1,517 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { clineCli, createClineCliProvider, getClineCliSessionsDir } from '../../src/providers/cline-cli.js' +import { priceProviderCall } from '../../src/pricing-pass.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +type MessageSpec = { + role: 'user' | 'assistant' + text?: string + metrics?: Record + model?: string + ts?: number + toolUse?: { name: string; input: Record } +} + +async function writeSession(sessionsDir: string, sessionId: string, opts?: { + messages?: MessageSpec[] + usage?: Record + totalCost?: number + model?: string + workspaceRoot?: string + cwd?: string + startedAt?: string + endedAt?: string + messagesPath?: string + omitMeta?: boolean + omitMessagesFile?: boolean +}): Promise { + const dir = join(sessionsDir, sessionId) + await mkdir(dir, { recursive: true }) + const metaPath = join(dir, `${sessionId}.json`) + const messagesPath = join(dir, `${sessionId}.messages.json`) + + if (!opts?.omitMeta) { + const metadata: Record = {} + if (opts?.usage) metadata['usage'] = opts.usage + if (opts?.totalCost !== undefined) metadata['totalCost'] = opts.totalCost + + await writeFile(metaPath, JSON.stringify({ + version: 1, + session_id: sessionId, + source: 'cli', + status: 'completed', + provider: 'cline-pass', + model: opts?.model ?? 'z-ai/glm-5.2', + cwd: opts?.cwd ?? '/Users/dev/work/my-repo', + workspace_root: opts?.workspaceRoot ?? opts?.cwd ?? '/Users/dev/work/my-repo', + started_at: opts?.startedAt ?? '2026-08-02T20:04:18.628Z', + ended_at: opts?.endedAt ?? '2026-08-02T20:08:27.768Z', + metadata, + messages_path: opts?.messagesPath ?? messagesPath, + })) + } + + if (!opts?.omitMessagesFile) { + const messages = (opts?.messages ?? []).map((spec, index) => { + const content: unknown[] = [] + if (spec.text) content.push({ type: 'text', text: spec.text }) + if (spec.toolUse) content.push({ type: 'tool_use', id: `call_${index}`, ...spec.toolUse }) + + const message: Record = { + id: `msg_${index}`, + role: spec.role, + content, + ts: spec.ts ?? 1785701064304 + index * 1000, + } + if (spec.metrics) message['metrics'] = spec.metrics + if (spec.model) message['modelInfo'] = { id: spec.model, provider: 'cline-pass' } + return message + }) + + await writeFile(messagesPath, JSON.stringify({ + version: 1, updated_at: opts?.endedAt, agent: 'lead', sessionId, messages, system_prompt: 'sp', + })) + } + + return dir +} + +async function collect(sessionsDir: string): Promise { + const provider = createClineCliProvider(sessionsDir) + const sources = await provider.discoverSessions() + const seenKeys = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(priceProviderCall(call)) + } + return calls +} + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cline-cli-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +describe('cline-cli provider - identity', () => { + it('registers under its own provider name', () => { + expect(clineCli.name).toBe('cline-cli') + expect(clineCli.displayName).toBe('Cline CLI') + }) + + it('maps CLI tool names onto codeburn canonical names', () => { + expect(clineCli.toolDisplayName('run_commands')).toBe('Bash') + expect(clineCli.toolDisplayName('read_files')).toBe('Read') + expect(clineCli.toolDisplayName('search_codebase')).toBe('Grep') + expect(clineCli.toolDisplayName('apply_patch')).toBe('Edit') + expect(clineCli.toolDisplayName('spawn_agent')).toBe('Agent') + // Unknown tools pass through rather than being dropped. + expect(clineCli.toolDisplayName('team_mission_log')).toBe('team_mission_log') + }) +}) + +describe('cline-cli provider - sessions dir resolution', () => { + beforeEach(() => { + delete process.env['CLINE_DIR'] + delete process.env['CLINE_DATA_DIR'] + delete process.env['CLINE_SESSION_DATA_DIR'] + }) + + it('defaults to ~/.cline/data/sessions', () => { + expect(getClineCliSessionsDir()).toBe(join(process.env['HOME'] ?? '', '.cline', 'data', 'sessions')) + }) + + it('honors CLINE_DIR', () => { + process.env['CLINE_DIR'] = '/custom/root' + expect(getClineCliSessionsDir()).toBe(join('/custom/root', 'data', 'sessions')) + }) + + it('honors CLINE_DATA_DIR over CLINE_DIR', () => { + process.env['CLINE_DIR'] = '/custom/root' + process.env['CLINE_DATA_DIR'] = '/custom/data' + expect(getClineCliSessionsDir()).toBe(join('/custom/data', 'sessions')) + }) + + it('honors CLINE_SESSION_DATA_DIR over everything else', () => { + process.env['CLINE_DIR'] = '/custom/root' + process.env['CLINE_DATA_DIR'] = '/custom/data' + process.env['CLINE_SESSION_DATA_DIR'] = '/custom/sessions' + expect(getClineCliSessionsDir()).toBe('/custom/sessions') + }) + + it('reports the resolved root for doctor', async () => { + process.env['CLINE_SESSION_DATA_DIR'] = '/custom/sessions' + expect(await clineCli.probeRoots?.()).toEqual([{ path: '/custom/sessions', label: 'Cline CLI sessions' }]) + }) +}) + +describe('cline-cli provider - discovery', () => { + it('discovers one source per session directory', async () => { + await writeSession(tmpDir, 'sess-a') + await writeSession(tmpDir, 'sess-b') + + const sources = await createClineCliProvider(tmpDir).discoverSessions() + + expect(sources).toHaveLength(2) + expect(sources.map(s => s.provider)).toEqual(['cline-cli', 'cline-cli']) + expect(sources[0]?.path).toBe(join(tmpDir, 'sess-a', 'sess-a.json')) + }) + + it('names the project from the workspace root', async () => { + await writeSession(tmpDir, 'sess-a', { workspaceRoot: '/Users/dev/work/awesome-repo' }) + + const [source] = await createClineCliProvider(tmpDir).discoverSessions() + + expect(source?.project).toBe('awesome-repo') + }) + + it('skips directories without a session metadata file', async () => { + await mkdir(join(tmpDir, 'not-a-session'), { recursive: true }) + await writeSession(tmpDir, 'sess-a') + + const sources = await createClineCliProvider(tmpDir).discoverSessions() + + expect(sources).toHaveLength(1) + }) + + it('skips a session whose metadata file is corrupt', async () => { + const dir = join(tmpDir, 'sess-bad') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'sess-bad.json'), '{ not json') + + expect(await createClineCliProvider(tmpDir).discoverSessions()).toHaveLength(0) + }) + + it('returns nothing when the sessions dir does not exist', async () => { + expect(await createClineCliProvider(join(tmpDir, 'missing')).discoverSessions()).toHaveLength(0) + }) +}) + +describe('cline-cli provider - parsing', () => { + it('emits one call per assistant message carrying metrics', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [ + { role: 'user', text: 'do the thing' }, + { role: 'assistant', text: 'ok', metrics: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 2, cost: 0.01 } }, + { role: 'user', text: '' }, + { role: 'assistant', text: 'done', metrics: { inputTokens: 200, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, cost: 0.02 } }, + ], + }) + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(2) + expect(calls.map(c => c.inputTokens)).toEqual([100, 200]) + expect(calls.map(c => c.outputTokens)).toEqual([10, 20]) + expect(calls[0]?.cacheReadInputTokens).toBe(5) + expect(calls[0]?.cacheCreationInputTokens).toBe(2) + expect(calls.map(c => c.costUSD)).toEqual([0.01, 0.02]) + expect(calls.every(c => c.costIsEstimated === false)).toBe(true) + expect(calls.every(c => c.provider === 'cline-cli')).toBe(true) + }) + + it('carries session identity, project and timestamps onto each call', async () => { + await writeSession(tmpDir, 'sess-a', { + workspaceRoot: '/Users/dev/work/awesome-repo', + cwd: '/Users/dev/work/awesome-repo/sub', + messages: [{ role: 'assistant', text: 'hi', ts: 1785701064304, metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.sessionId).toBe('sess-a') + expect(call?.project).toBe('awesome-repo') + expect(call?.projectPath).toBe('/Users/dev/work/awesome-repo') + expect(call?.workingDirectory).toBe('/Users/dev/work/awesome-repo/sub') + expect(call?.timestamp).toBe(new Date(1785701064304).toISOString()) + }) + + it('prefers the per-message model over the session model', async () => { + await writeSession(tmpDir, 'sess-a', { + model: 'session-model', + messages: [ + { role: 'assistant', text: 'a', model: 'z-ai/glm-5.2', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }, + { role: 'assistant', text: 'b', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }, + ], + }) + + const calls = await collect(tmpDir) + + expect(calls.map(c => c.model)).toEqual(['z-ai/glm-5.2', 'session-model']) + }) + + it('extracts tools and bash commands from tool_use blocks', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [ + { + role: 'assistant', text: 'running', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 }, + toolUse: { name: 'run_commands', input: { commands: JSON.stringify(['git status', 'ls -la']) } }, + }, + { + role: 'assistant', text: 'reading', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 }, + toolUse: { name: 'read_files', input: { path: '/tmp/a.ts' } }, + }, + ], + }) + + const calls = await collect(tmpDir) + + expect(calls[0]?.tools).toEqual(['Bash']) + expect(calls[0]?.bashCommands).toContain('git') + expect(calls[0]?.bashCommands).toContain('ls') + expect(calls[1]?.tools).toEqual(['Read']) + expect(calls[1]?.toolSequence?.[0]?.[0]).toEqual({ tool: 'Read', file: '/tmp/a.ts' }) + }) + + it('treats a non-JSON commands string as a single command', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [{ + role: 'assistant', text: 'x', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 }, + toolUse: { name: 'run_commands', input: { commands: 'git status' } }, + }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.bashCommands).toContain('git') + }) + + it('uses the first user text as the session user message, skipping tool results', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [ + { role: 'user', text: 'the real prompt' }, + { role: 'assistant', text: 'ok', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }, + ], + }) + + const [call] = await collect(tmpDir) + + expect(call?.userMessage).toBe('the real prompt') + }) + + it('deduplicates repeated parses via the shared seenKeys set', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [{ role: 'assistant', text: 'a', metrics: { inputTokens: 5, outputTokens: 1, cost: 0.1 } }], + }) + + const provider = createClineCliProvider(tmpDir) + const [source] = await provider.discoverSessions() + const seenKeys = new Set() + + const first: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source!, seenKeys).parse()) first.push(call) + const second: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source!, seenKeys).parse()) second.push(call) + + expect(first).toHaveLength(1) + expect(second).toHaveLength(0) + }) + + it('estimates cost when the message reports none', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [{ role: 'assistant', text: 'a', metrics: { inputTokens: 1000, outputTokens: 100 } }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.costIsEstimated).toBe(true) + expect(call?.costUSD).toBeGreaterThan(0) + }) + + it('keeps a metered $0 cost reported instead of re-estimating it', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [{ role: 'assistant', text: 'a', metrics: { inputTokens: 1000, outputTokens: 100, cost: 0 } }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.costUSD).toBe(0) + expect(call?.costIsEstimated).toBe(false) + }) + + it('treats a negative cost as absent rather than reporting a clamped $0', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [{ role: 'assistant', text: 'a', metrics: { inputTokens: 1000, outputTokens: 100, cost: -5 } }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.costIsEstimated).toBe(true) + expect(call?.costUSD).toBeGreaterThan(0) + }) + + it('promotes a seconds-resolution timestamp instead of landing in 1970', async () => { + const seconds = Math.floor(Date.parse('2026-08-02T20:04:18.000Z') / 1000) + await writeSession(tmpDir, 'sess-a', { + messages: [{ role: 'assistant', text: 'a', ts: seconds, metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.timestamp).toBe('2026-08-02T20:04:18.000Z') + }) + + it('falls back to the session start when a message carries no timestamp', async () => { + await writeSession(tmpDir, 'sess-a', { + startedAt: '2026-08-02T20:04:18.628Z', + messages: [{ role: 'assistant', text: 'a', ts: 0, metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.timestamp).toBe('2026-08-02T20:04:18.628Z') + }) + + it('survives a messages file whose messages field is not an array', async () => { + const dir = join(tmpDir, 'sess-a') + await writeSession(tmpDir, 'sess-a', { messages: [] }) + await writeFile(join(dir, 'sess-a.messages.json'), JSON.stringify({ version: 1, messages: { nope: true } })) + + expect(await collect(tmpDir)).toHaveLength(0) + }) + + it('survives a corrupt messages file without dropping the session rollup', async () => { + const dir = join(tmpDir, 'sess-a') + await writeSession(tmpDir, 'sess-a', { + usage: { inputTokens: 100, outputTokens: 10, totalCost: 0.05 }, + messages: [], + }) + await writeFile(join(dir, 'sess-a.messages.json'), '{ not json') + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(1) + expect(calls[0]?.inputTokens).toBe(100) + }) + + it('ignores assistant messages with no usage at all', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [ + { role: 'assistant', text: 'no metrics here' }, + { role: 'assistant', text: 'zeroed', metrics: { inputTokens: 0, outputTokens: 0, cost: 0 } }, + ], + }) + + expect(await collect(tmpDir)).toHaveLength(0) + }) + + it('reads the co-located messages file when messages_path is stale', async () => { + await writeSession(tmpDir, 'sess-a', { + messagesPath: '/nonexistent/other-machine/sess-a.messages.json', + messages: [{ role: 'assistant', text: 'a', metrics: { inputTokens: 7, outputTokens: 1, cost: 0.1 } }], + }) + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(1) + expect(calls[0]?.inputTokens).toBe(7) + }) +}) + +describe('cline-cli provider - rollup fallback', () => { + it('falls back to the session rollup when no message carries metrics', async () => { + await writeSession(tmpDir, 'sess-a', { + omitMessagesFile: true, + usage: { inputTokens: 5483, outputTokens: 133, cacheReadTokens: 50, cacheWriteTokens: 0, totalCost: 0.0081984 }, + }) + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(1) + expect(calls[0]?.inputTokens).toBe(5483) + expect(calls[0]?.outputTokens).toBe(133) + expect(calls[0]?.cacheReadInputTokens).toBe(50) + expect(calls[0]?.costUSD).toBeCloseTo(0.0081984, 7) + expect(calls[0]?.costIsEstimated).toBe(false) + }) + + it('does not double count when per-message metrics already covered the session', async () => { + await writeSession(tmpDir, 'sess-a', { + usage: { inputTokens: 300, outputTokens: 30, totalCost: 0.03 }, + messages: [ + { role: 'assistant', text: 'a', metrics: { inputTokens: 100, outputTokens: 10, cost: 0.01 } }, + { role: 'assistant', text: 'b', metrics: { inputTokens: 200, outputTokens: 20, cost: 0.02 } }, + ], + }) + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(2) + expect(calls.reduce((sum, c) => sum + c.inputTokens, 0)).toBe(300) + }) + + it('does not fire the rollup when a duplicated session_id deduped every per-message call', async () => { + // A session directory copied on disk: two dirs sharing the same internal + // session_id and message ids, the second also carrying a metadata.usage + // rollup. The shared dedup suppresses the copy's per-message calls; the + // rollup must not then fire and re-count the session. Regression for #894. + for (const [dirName, withRollup] of [['aaa', false], ['bbb', true]] as const) { + const dir = join(tmpDir, dirName) + await mkdir(dir, { recursive: true }) + const metadata: Record = {} + if (withRollup) metadata['usage'] = { inputTokens: 100, outputTokens: 10, totalCost: 0.01 } + await writeFile(join(dir, `${dirName}.json`), JSON.stringify({ + version: 1, session_id: 'shared', source: 'cli', status: 'completed', + provider: 'cline-pass', model: 'z-ai/glm-5.2', + cwd: '/Users/dev/work/my-repo', workspace_root: '/Users/dev/work/my-repo', + started_at: '2026-08-02T20:04:18.628Z', ended_at: '2026-08-02T20:08:27.768Z', + metadata, messages_path: join(dir, `${dirName}.messages.json`), + })) + await writeFile(join(dir, `${dirName}.messages.json`), JSON.stringify({ + version: 1, sessionId: 'shared', messages: [{ + id: 'msg_0', role: 'assistant', content: [{ type: 'text', text: 'a' }], + ts: 1785701064304, metrics: { inputTokens: 100, outputTokens: 10, cost: 0.01 }, + modelInfo: { id: 'z-ai/glm-5.2', provider: 'cline-pass' }, + }], + })) + } + + const calls = await collect(tmpDir) + + // Exactly one call (the first copy's msg_0); the copy is deduped and its + // rollup declined, so cost stays $0.01 rather than doubling to $0.02. + expect(calls).toHaveLength(1) + expect(calls.some(c => c.deduplicationKey === 'cline-cli:shared:rollup')).toBe(false) + expect(calls.reduce((sum, c) => sum + c.costUSD, 0)).toBeCloseTo(0.01, 7) + }) + + it('keeps a metered $0 rollup reported instead of re-estimating it', async () => { + await writeSession(tmpDir, 'sess-a', { + omitMessagesFile: true, + usage: { inputTokens: 1000, outputTokens: 100, totalCost: 0 }, + }) + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(1) + expect(calls[0]?.costUSD).toBe(0) + expect(calls[0]?.costIsEstimated).toBe(false) + }) + + it('estimates a rollup that reports no cost at all', async () => { + await writeSession(tmpDir, 'sess-a', { + omitMessagesFile: true, + usage: { inputTokens: 1000, outputTokens: 100 }, + }) + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(1) + expect(calls[0]?.costIsEstimated).toBe(true) + expect(calls[0]?.costUSD).toBeGreaterThan(0) + }) + + it('emits nothing for a session with neither message metrics nor a rollup', async () => { + await writeSession(tmpDir, 'sess-a', { omitMessagesFile: true }) + + expect(await collect(tmpDir)).toHaveLength(0) + }) +}) diff --git a/packages/cli/tests/providers/codebuff-bridge.test.ts b/packages/cli/tests/providers/codebuff-bridge.test.ts index bdfafa91..9375033a 100644 --- a/packages/cli/tests/providers/codebuff-bridge.test.ts +++ b/packages/cli/tests/providers/codebuff-bridge.test.ts @@ -5,19 +5,29 @@ import { describe, it, expect } from 'vitest' import { createCodebuffProvider } from '../../src/providers/codebuff.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { getHostPrivacyKey } from '../../src/privacy-key.js' +import { sourceRefFingerprint } from '@codeburn/core' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the codebuff bridge migration. The GOLDEN below // was captured from the legacy in-CLI decode before the migration; the bridged // provider (discovery + I/O CLI-side, pure decode in @codeburn/core/providers/codebuff) -// must reproduce it exactly. Dedup keys contain absolute source paths, so they are -// computed from the discovered source at runtime rather than hard-coded. +// must reproduce it exactly. Dedup keys thread a FINGERPRINT of the source path +// (dedupKey ships on the envelope, so the raw path must never appear there), so +// the expected values are DERIVED from the source at runtime via the same +// sourceRefFingerprint the decoder uses — never the raw path, and never a +// hard-coded literal. The bridge threads the HOST privacy key (getHostPrivacyKey, +// per-install stable), so the golden derives with the same key. const here = dirname(fileURLToPath(import.meta.url)) const FIXTURE_DIR = resolve(here, '../fixtures/codebuff-parity/manicode') function expectedGolden(sourcePath: string): ParsedProviderCall[] { const chatDir = sourcePath + // The CLI bridge threads the host privacy key into the rich decode, so the + // decoder keys the source ref under getHostPrivacyKey() — derive the + // expected key the same way instead of pasting what the code emits. + const chatRef = sourceRefFingerprint(getHostPrivacyKey(), chatDir) return [ { provider: 'codebuff', @@ -35,7 +45,7 @@ function expectedGolden(sourcePath: string): ParsedProviderCall[] { bashCommands: ['npm', 'npm'], timestamp: '2026-04-14T10:00:30.000Z', speed: 'standard', - deduplicationKey: `codebuff:${chatDir}:a1`, + deduplicationKey: `codebuff:${chatRef}:a1`, userMessage: 'implement the feature', sessionId: 'manicode/2026-04-14T10-00-00.000Z', }, @@ -55,7 +65,7 @@ function expectedGolden(sourcePath: string): ParsedProviderCall[] { bashCommands: [], timestamp: '2026-04-14T10:01:30.000Z', speed: 'standard', - deduplicationKey: `codebuff:${chatDir}:a2`, + deduplicationKey: `codebuff:${chatRef}:a2`, userMessage: 'fix the bug', sessionId: 'manicode/2026-04-14T10-00-00.000Z', }, @@ -75,7 +85,7 @@ function expectedGolden(sourcePath: string): ParsedProviderCall[] { bashCommands: [], timestamp: '2026-04-14T10:02:00.000Z', speed: 'standard', - deduplicationKey: `codebuff:${chatDir}:a3`, + deduplicationKey: `codebuff:${chatRef}:a3`, userMessage: '', sessionId: 'manicode/2026-04-14T10-00-00.000Z', }, diff --git a/packages/cli/tests/providers/codex.test.ts b/packages/cli/tests/providers/codex.test.ts index 9595e356..0b59ecbb 100644 --- a/packages/cli/tests/providers/codex.test.ts +++ b/packages/cli/tests/providers/codex.test.ts @@ -160,6 +160,56 @@ describe('codex provider - session discovery', () => { }]) }) + it('deduplicates the same session_id across active and archived roots', async () => { + const sharedLines = [ + sessionMeta({ cwd: '/Users/test/shared', session_id: 'sess-shared' }), + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ] + const activePath = await writeSession(tmpDir, '2026-04-14', 'rollout-shared.jsonl', sharedLines) + const archivedCopyPath = await writeArchivedSession(tmpDir, 'rollout-shared.jsonl', sharedLines) + const distinctPath = await writeArchivedSession(tmpDir, 'rollout-distinct.jsonl', [ + sessionMeta({ cwd: '/Users/test/distinct', session_id: 'sess-distinct' }), + tokenCount({ last: { input: 200, output: 50 }, total: { total: 250 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + const paths = sessions.map(session => session.path) + + expect(sessions).toHaveLength(2) + expect(paths).toEqual(expect.arrayContaining([activePath, distinctPath])) + expect(paths).not.toContain(archivedCopyPath) + }) + + it('does not double-count usage for an archived copy while counting distinct sessions', async () => { + const sharedLines = [ + sessionMeta({ session_id: 'sess-shared' }), + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ] + await writeSession(tmpDir, '2026-04-14', 'rollout-shared.jsonl', sharedLines) + await writeArchivedSession(tmpDir, 'rollout-shared-copy.jsonl', sharedLines) + await writeArchivedSession(tmpDir, 'rollout-distinct.jsonl', [ + sessionMeta({ session_id: 'sess-distinct' }), + tokenCount({ last: { input: 200, output: 50 }, total: { total: 250 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + const seenKeys = new Set() + const calls: ParsedProviderCall[] = [] + for (const session of sessions) { + for await (const call of provider.createSessionParser(session, seenKeys).parse()) { + calls.push(call) + } + } + + expect(calls.map(call => call.sessionId).sort()).toEqual(['sess-distinct', 'sess-shared']) + expect(calls.reduce( + (total, call) => total + call.inputTokens + call.cachedInputTokens + call.outputTokens + call.reasoningTokens, + 0, + )).toBe(400) + }) + it('returns empty for non-existent directory', async () => { const provider = createCodexProvider('/nonexistent/path/that/does/not/exist') const sessions = await provider.discoverSessions() @@ -332,6 +382,89 @@ describe('codex provider - JSONL parsing', () => { expect(call.deduplicationKey).toContain('codex:') }) + it('parses large rollout lines and computes active timing for custom tool calls', async () => { + const largeTokenLine = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T10:01:10Z', + payload: { + type: 'token_count', + info: { + last_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 }, + total_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 }, + }, + rate_limits: { filler: 'x'.repeat(40_000) }, + }, + }) + const largeCompleteLine = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T10:01:11Z', + payload: { type: 'task_complete', last_agent_message: 'x'.repeat(40_000), duration_ms: 10_000 }, + }) + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-timing.jsonl', [ + sessionMeta({ session_id: 'sess-timing', model: 'gpt-5.5' }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started', turn_id: 'turn-1' } }), + userMessage('run the tool'), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:02Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:05Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }), + largeTokenLine, + largeCompleteLine, + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + outputTokens: 100, + reasoningTokens: 20, + tools: ['Bash'], + activeDurationMs: 7000, + activeGeneratedTokens: 120, + toolWaitMs: 3000, + }) + }) + + it('keeps estimated output parsing for large token lines without usage info', async () => { + // Some rollout variants put token_count metadata beyond the compact head + // or omit `info` entirely. The line must still reach the character-based + // estimate path rather than being interpreted as an empty usage object. + const largeTokenLine = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T10:01:10Z', + payload: { type: 'token_count' }, + filler: 'x'.repeat(40_000), + }) + const assistantLine = JSON.stringify({ + type: 'response_item', + timestamp: '2026-04-14T10:01:05Z', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'generated response '.repeat(100) }], + }, + }) + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-estimated-large.jsonl', [ + sessionMeta({ session_id: 'sess-estimated-large', model: 'gpt-5.5' }), + userMessage('summarize the result'), + assistantLine, + largeTokenLine, + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + model: 'gpt-5.5', + costIsEstimated: true, + }) + expect(calls[0]!.outputTokens).toBeGreaterThan(0) + }) + it('attributes MCP calls emitted as event_msg/mcp_tool_call_end', async () => { const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp.jsonl', [ sessionMeta({ session_id: 'sess-mcp', model: 'gpt-5.5' }), @@ -356,6 +489,89 @@ describe('codex provider - JSONL parsing', () => { expect(calls[0]!.tools).toEqual(['mcp__github__get_issue']) }) + it('subtracts native MCP wait time from active timing', async () => { + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-timing.jsonl', [ + sessionMeta({ session_id: 'sess-mcp-timing', model: 'gpt-5.5' }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }), + userMessage('look up the issue'), + JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T10:00:05Z', + payload: { + type: 'mcp_tool_call_end', + call_id: 'mcp-1', + invocation: { server: 'github', tool: 'get_issue', arguments: {} }, + duration: { secs: 3, nanos: 0 }, + }, + }), + tokenCount({ + timestamp: '2026-04-14T10:00:08Z', + last: { input: 300, output: 100 }, + total: { total: 400 }, + }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ activeDurationMs: 7000, toolWaitMs: 3000 }) + }) + + it('keeps MCP attribution on large result lines', async () => { + const largeMcpLine = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T10:00:05Z', + payload: { + type: 'mcp_tool_call_end', + call_id: 'mcp-large', + invocation: { server: 'github', tool: 'get_issue', arguments: { duration: '1s', body: 'x'.repeat(100_000) } }, + duration: { secs: 3, nanos: 0 }, + result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } }, + }, + }) + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-large.jsonl', [ + sessionMeta({ session_id: 'sess-mcp-large', model: 'gpt-5.5' }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }), + userMessage('look up the issue'), + largeMcpLine, + tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 }) + }) + + it('omits active timing when recorded tool wait consumes the task duration', async () => { + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-degenerate-timing.jsonl', [ + sessionMeta({ session_id: 'sess-degenerate-timing', model: 'gpt-5.5' }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }), + userMessage('wait for the tool'), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }), + tokenCount({ timestamp: '2026-04-14T10:00:12Z', last: { input: 300, output: 100 }, total: { total: 400 } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:13Z', payload: { type: 'task_complete', duration_ms: 10_000 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.activeDurationMs).toBeUndefined() + expect(calls[0]!.toolWaitMs).toBeUndefined() + }) + it('attributes CLI-wrapped MCP calls (mcp-cli call server tool) to MCP + Bash', async () => { const execStr = (command: string) => JSON.stringify({ type: 'response_item', diff --git a/packages/cli/tests/providers/grok-bridge.test.ts b/packages/cli/tests/providers/grok-bridge.test.ts index 65a298fc..4a750982 100644 --- a/packages/cli/tests/providers/grok-bridge.test.ts +++ b/packages/cli/tests/providers/grok-bridge.test.ts @@ -5,6 +5,8 @@ import { describe, it, expect } from 'vitest' import { createGrokProvider } from '../../src/providers/grok.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { getHostPrivacyKey } from '../../src/privacy-key.js' +import { sourceRefFingerprint } from '@codeburn/core' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the grok bridge migration (phase 8). The @@ -12,6 +14,13 @@ import type { ParsedProviderCall, SessionSource } from '../../src/providers/type const here = dirname(fileURLToPath(import.meta.url)) const FIXTURE_DIR = resolve(here, '../fixtures/grok-parity') +// The dedup key threads a FINGERPRINT of the session dir — the raw path is the +// defect and must never appear (dedupKey ships on the envelope), so the +// expected value is DERIVED via the same sourceRefFingerprint the decoder +// uses. The bridge threads the HOST privacy key (getHostPrivacyKey, per-install +// stable), so the golden derives with the same key. +const SESSION_DIR = resolve(FIXTURE_DIR, '%2FUsers%2Ftest/019edf9c-0000-7000-8000-000000000001') +const SESSION_REF = sourceRefFingerprint(getHostPrivacyKey(), SESSION_DIR) const GOLDEN: ParsedProviderCall[] = [ { @@ -33,9 +42,10 @@ const GOLDEN: ParsedProviderCall[] = [ subagentTypes: ['general-purpose'], timestamp: '2026-06-19T11:31:12.282793Z', speed: 'standard', - // The key embeds the session dir's absolute path — compute it from - // FIXTURE_DIR so the golden is portable across checkouts. - deduplicationKey: `grok:${resolve(FIXTURE_DIR, '%2FUsers%2Ftest/019edf9c-0000-7000-8000-000000000001')}:2026-06-19T11:31:12.282793Z:019edf9c-0000-7000-8000-000000000001`, + // The key embeds a fingerprint of the session dir's absolute path — + // derived from FIXTURE_DIR so the golden is portable across checkouts, and + // never the raw path itself. + deduplicationKey: `grok:${SESSION_REF}:2026-06-19T11:31:12.282793Z:019edf9c-0000-7000-8000-000000000001`, userMessage: 'User asks about the repo', sessionId: '019edf9c-0000-7000-8000-000000000001', project: 'myproject', diff --git a/packages/cli/tests/providers/lingtai-tui-bridge.test.ts b/packages/cli/tests/providers/lingtai-tui-bridge.test.ts index 2632ee3f..78ddefd5 100644 --- a/packages/cli/tests/providers/lingtai-tui-bridge.test.ts +++ b/packages/cli/tests/providers/lingtai-tui-bridge.test.ts @@ -4,6 +4,8 @@ import { describe, it, expect } from 'vitest' import { createLingTaiTuiProvider } from '../../src/providers/lingtai-tui.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { getHostPrivacyKey } from '../../src/privacy-key.js' +import { sourceRefFingerprint } from '@codeburn/core' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the lingtai-tui bridge migration (phase 8). @@ -14,8 +16,10 @@ import type { ParsedProviderCall, SessionSource } from '../../src/providers/type // per-source-label activity synthesis (main / tc_wake / daemon => userMessage + // tools + subagentTypes), model/endpoint fallback from the manifest when a // ledger row omits them, run_id vs `${agentId}:${label}` session ids, the -// composite dedup key threaded on the SOURCE PATH (not the agent dir), turnId, -// and the manifest-derived project / projectPath carried onto the call. +// composite dedup key threaded on a FINGERPRINT of the source path (never the +// raw path — dedupKey ships on the envelope; the raw ledger model is normalized +// in the key), turnId, and the manifest-derived project / projectPath carried +// onto the call. const here = dirname(fileURLToPath(import.meta.url)) const FIXTURE_DIR = resolve(here, '../fixtures/lingtai-parity') @@ -33,7 +37,11 @@ function dedup( thinking: number, cached: number, ): string { - return ['lingtai-tui', sourcePath, lineNo, ts, model, endpoint, label, emId, runId, input, output, thinking, cached].join(':') + // The bridge threads the HOST privacy key into the rich decode + // (getHostPrivacyKey, per-install stable), so the decoder keys the source ref + // under that key — derive the expected key the same way instead of pasting + // what the code emits. + return ['lingtai-tui', sourceRefFingerprint(getHostPrivacyKey(), sourcePath), lineNo, ts, model, endpoint, label, emId, runId, input, output, thinking, cached].join(':') } function golden(sourcePath: string, agentDir: string): ParsedProviderCall[] { diff --git a/packages/cli/tests/providers/pi-bridge.test.ts b/packages/cli/tests/providers/pi-bridge.test.ts index af932955..74948a16 100644 --- a/packages/cli/tests/providers/pi-bridge.test.ts +++ b/packages/cli/tests/providers/pi-bridge.test.ts @@ -5,14 +5,18 @@ import { describe, it, expect } from 'vitest' import { createPiProvider, createOmpProvider } from '../../src/providers/pi.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { getHostPrivacyKey } from '../../src/privacy-key.js' +import { sourceRefFingerprint } from '@codeburn/core' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the pi/omp bridge migration (phase 8). One core // decode serves both providers; the GOLDENs were captured from the legacy in-CLI // decode (git show origin/feat/core-extraction:packages/cli/src/providers/pi.ts) -// run over the committed fixtures. Covers: the `::` dedup -// key — anchored to the SESSION FILE PATH, not the sessionId, and computed from -// FIXTURE_DIR so the golden is checkout-portable — plus its +// run over the committed fixtures. Covers: the +// `::` dedup key — the raw session file +// path is the defect and must never appear (dedupKey ships on the envelope), so +// the expected values are DERIVED from the session file path via the same +// sourceRefFingerprint the decoder uses — plus its // responseId||entryId||timestamp||lineIdx fallback chain; sessionId from the // session entry `id` vs the basename-of-path fallback (omp entry omits id -> // 'ofile'); SKILL.md and skill:// reads reclassified as the `Skill` tool with @@ -26,6 +30,12 @@ const OMP_DIR = resolve(here, '../fixtures/pi-parity/omp-sessions') const PI_PATH = resolve(PI_DIR, 'proj1/sess-file.jsonl') const OMP_PATH = resolve(OMP_DIR, 'projO/ofile.jsonl') +// The bridge threads the HOST privacy key into the rich decode +// (getHostPrivacyKey, per-install stable), so the decoder keys the source ref +// under that key — derive the expected keys the same way instead of pasting +// what the code emits. +const PI_REF = sourceRefFingerprint(getHostPrivacyKey(), PI_PATH) +const OMP_REF = sourceRefFingerprint(getHostPrivacyKey(), OMP_PATH) async function collect(provider: { discoverSessions: () => Promise @@ -58,7 +68,7 @@ const PI_GOLDEN: ParsedProviderCall[] = [ skills: ['my-skill', 'web-search'], timestamp: '2026-06-10T10:00:02.000Z', speed: 'standard', - deduplicationKey: `pi:${PI_PATH}:resp-1`, + deduplicationKey: `pi:${PI_REF}:resp-1`, userMessage: 'do stuff', sessionId: 'pi-sess-1', }, @@ -82,8 +92,9 @@ const OMP_GOLDEN: ParsedProviderCall[] = [ timestamp: '2026-06-11T10:00:00.000Z', speed: 'standard', // responseId '' -> entry.id absent -> entry.timestamp; sessionId falls back - // to basename-of-path because the session entry carries no id. - deduplicationKey: `omp:${OMP_PATH}:2026-06-11T10:00:00.000Z`, + // to basename-of-path because the session entry carries no id. The dedup key + // threads a fingerprint of the file path (never the raw path). + deduplicationKey: `omp:${OMP_REF}:2026-06-11T10:00:00.000Z`, userMessage: '', sessionId: 'ofile', }, diff --git a/packages/cli/tests/providers/zerostack-bridge.test.ts b/packages/cli/tests/providers/zerostack-bridge.test.ts index 0c2a9c34..8976bb30 100644 --- a/packages/cli/tests/providers/zerostack-bridge.test.ts +++ b/packages/cli/tests/providers/zerostack-bridge.test.ts @@ -4,25 +4,36 @@ import { describe, it, expect } from 'vitest' import { createZerostackProvider } from '../../src/providers/zerostack.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { getHostPrivacyKey } from '../../src/privacy-key.js' +import { sourceRefFingerprint } from '@codeburn/core' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the zerostack bridge migration (phase 8). // Zerostack is not in the frozen corpus, so a committed fixture golden is THE // parity gate: the bridged provider (discovery + JSON I/O CLI-side, pure decode // delegated to @codeburn/core/providers/zerostack) must reproduce exactly what -// the pre-migration in-CLI decode produced. The dedup key threads the absolute -// source path (`zerostack:::`), so it is built from -// the discovered source rather than hard-coded. Covers: cumulative session -// totals, the zero-token skip (elsewhere), the OpenRouter model passing through -// raw, the empty-model + string-array userMessage + `basename(path)` sessionId -// fallbacks, updated_at-then-created_at timestamp precedence, and the discovered -// project / recorded working_dir carried onto the call. +// the pre-migration in-CLI decode produced. The dedup key threads a FINGERPRINT +// of the source path (`zerostack:::`) — dedupKey +// ships on the envelope, so the raw path is the defect and must never appear; +// the expected value is DERIVED from the discovered source via the same +// sourceRefFingerprint the decoder uses rather than hard-coded. Covers: +// cumulative session totals, the zero-token skip (elsewhere), the OpenRouter +// model passing through raw, the empty-model + string-array userMessage + +// `basename(path)` sessionId fallbacks, updated_at-then-created_at timestamp +// precedence, and the discovered project / recorded working_dir carried onto +// the call. const here = dirname(fileURLToPath(import.meta.url)) const FIXTURE_DIR = resolve(here, '../fixtures/zerostack') function golden(dir: string): ParsedProviderCall[] { const abcPath = join(dir, 'sess-abc.json') const arrayPath = join(dir, 'sess-array.json') + // The bridge threads the HOST privacy key into the rich decode + // (getHostPrivacyKey, per-install stable), so the decoder keys the source ref + // under that key — derive the expected keys the same way instead of pasting + // what the code emits. + const abcRef = sourceRefFingerprint(getHostPrivacyKey(), abcPath) + const arrayRef = sourceRefFingerprint(getHostPrivacyKey(), arrayPath) return [ { provider: 'zerostack', @@ -39,7 +50,7 @@ function golden(dir: string): ParsedProviderCall[] { bashCommands: [], timestamp: '2026-06-19T11:34:14.140631+00:00', speed: 'standard', - deduplicationKey: `zerostack:${abcPath}:2026-06-19T11:34:14.140631+00:00:sess-abc`, + deduplicationKey: `zerostack:${abcRef}:2026-06-19T11:34:14.140631+00:00:sess-abc`, userMessage: 'hello, what is this repo about?', sessionId: 'sess-abc', project: 'myproject', @@ -60,7 +71,7 @@ function golden(dir: string): ParsedProviderCall[] { bashCommands: [], timestamp: '2026-06-20T09:00:00.000000+00:00', speed: 'standard', - deduplicationKey: `zerostack:${arrayPath}:2026-06-20T09:00:00.000000+00:00:sess-array`, + deduplicationKey: `zerostack:${arrayRef}:2026-06-20T09:00:00.000000+00:00:sess-array`, userMessage: 'part one part two', sessionId: 'sess-array', project: 'another', diff --git a/packages/cli/tests/session-cache.test.ts b/packages/cli/tests/session-cache.test.ts index 2c736b22..6a4eedb6 100644 --- a/packages/cli/tests/session-cache.test.ts +++ b/packages/cli/tests/session-cache.test.ts @@ -279,6 +279,21 @@ describe('computeEnvFingerprint', () => { expect(computeEnvFingerprint('kiro')).not.toBe(computeEnvFingerprint('unknown-provider')) expect(computeEnvFingerprint('warp')).not.toBe(computeEnvFingerprint('unknown-provider')) }) + + it('dedup-key-hygiene providers carry a parse version so cached raw-path keys re-derive', () => { + // #931: codebuff, zerostack, pi/omp and grok changed their dedup key shape + // (raw source path -> fingerprint) and lingtai-tui normalized the model + // component. Without an entry here the env fingerprint would not change, a + // warm session cache would keep serving the raw-path keys, and those keys + // seed the dedup sets — re-ingesting the same records under the new shape. + // Each of these providers MUST have a parse version so the one-time + // re-parse that drops the old keys actually fires. The comparison baseline + // is a provider with no entry and no env vars, whose fingerprint omits the + // `parser=` component entirely. + for (const provider of ['codebuff', 'zerostack', 'pi', 'omp', 'grok', 'lingtai-tui']) { + expect(computeEnvFingerprint(provider), provider).not.toBe(computeEnvFingerprint('unknown-provider')) + } + }) }) // ── fingerprintFile ──────────────────────────────────────────────────── diff --git a/packages/cli/tests/setup/env-isolation.ts b/packages/cli/tests/setup/env-isolation.ts index 751db6d6..ea1c32a4 100644 --- a/packages/cli/tests/setup/env-isolation.ts +++ b/packages/cli/tests/setup/env-isolation.ts @@ -49,6 +49,9 @@ const CLEARED = [ // Provider session-discovery dirs 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', + 'CLINE_DIR', + 'CLINE_DATA_DIR', + 'CLINE_SESSION_DATA_DIR', 'CODEX_HOME', 'CODEWHALE_HOME', 'CRUSH_GLOBAL_DATA', diff --git a/packages/core/package.json b/packages/core/package.json index 16fc4331..c5d6f267 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,6 +40,10 @@ "types": "./dist/providers/claude/index.d.ts", "import": "./dist/providers/claude/index.js" }, + "./providers/cline-cli": { + "types": "./dist/providers/cline-cli/index.d.ts", + "import": "./dist/providers/cline-cli/index.js" + }, "./providers/codebuff": { "types": "./dist/providers/codebuff/index.d.ts", "import": "./dist/providers/codebuff/index.js" diff --git a/packages/core/schemas/observation-0.2.0.json b/packages/core/schemas/observation-0.2.0.json index e8d32875..92c6b640 100644 --- a/packages/core/schemas/observation-0.2.0.json +++ b/packages/core/schemas/observation-0.2.0.json @@ -69,11 +69,15 @@ }, "model": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:/@-]+$" }, "pricingModel": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:/@-]+$" }, "tokens": { "type": "object", diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts index 10a6190e..f2de01bb 100644 --- a/packages/core/src/contracts.ts +++ b/packages/core/src/contracts.ts @@ -20,7 +20,21 @@ export interface DecodeContext { privacyKey: string /** The provider whose records these are. */ providerId: string - /** An opaque fingerprint of the source (file/stream) being decoded. */ + /** + * The host's absolute filesystem path to the source being decoded — NOT an + * opaque fingerprint. Decoders may use it to derive session/chat identity + * (a chat directory name, a session file's basename), but the RAW value must + * never cross into an observation output — and dedupKey is an observation + * output: it is a field on CallObservation that ships on the envelope, so + * folding the raw path into a dedup key is a leak. A decoder that needs an + * opaque form of the source in a dedup key or identity must fingerprint it + * first via fingerprint.ts (`sourceRefFingerprint` — keyed HMAC-SHA256, + * decision D1; the key is required and an empty key throws, so the ref can + * never degrade to an unkeyed digest). Every fingerprint/ref field on the + * envelope (sessionRef, projectRef, gitBranchRef, resource refs, and the + * dedupKey's source component) is HMAC-derived via fingerprint.ts with the + * host privacyKey, which the CLI bridge threads from getHostPrivacyKey(). + */ sourceRef: string } diff --git a/packages/core/src/fingerprint.ts b/packages/core/src/fingerprint.ts index b0343fa5..8270977b 100644 --- a/packages/core/src/fingerprint.ts +++ b/packages/core/src/fingerprint.ts @@ -13,7 +13,7 @@ import { createHmac } from 'node:crypto' const FINGERPRINT_LEN = 16 /** Domain-separation prefixes so the same string in different roles differs. */ -type Domain = 'session' | 'project' | 'branch' | 'resource' +type Domain = 'session' | 'project' | 'branch' | 'resource' | 'source' /** Field separator for composite HMAC inputs (ASCII Unit Separator). */ const SEP = String.fromCharCode(0x1f) @@ -148,6 +148,22 @@ export function branchRef(privacyKey: string, branch: string): string { return hmac(privacyKey, 'branch', branch) } +/** + * Fingerprint the source path for dedup-key / identity derivation. The raw + * absolute path must never cross into an observation output, but a decoder + * that needs a stable opaque form of it (e.g. inside a dedupKey, which ships + * on the envelope) may use this. Keyed HMAC-SHA256 (decision D1) under the + * caller-supplied privacyKey: the key is REQUIRED and an empty key throws + * (like every other fingerprint in this module), so a source ref can never + * silently degrade to an unkeyed, dictionary-attackable digest. With the host + * key the ref is host-scoped — not brute-forceable, and not comparable across + * hosts. The path is normalized first, so a Windows path and its POSIX + * spelling fingerprint identically. + */ +export function sourceRefFingerprint(privacyKey: string, sourceRef: string): string { + return hmac(privacyKey, 'source', normalizePath(sourceRef)) +} + export type CommandFamily = | 'git' | 'test' diff --git a/packages/core/src/observations.ts b/packages/core/src/observations.ts index 7124c044..72102905 100644 --- a/packages/core/src/observations.ts +++ b/packages/core/src/observations.ts @@ -5,6 +5,7 @@ import { CostBasis, FingerprintHex, IsoTimestamp, + ModelIdentifier, NonNegInt, NonNegUSD, OBSERVATION_SCHEMA_VERSION, @@ -26,8 +27,8 @@ import { export const CallObservation = z .object({ provider: z.string().min(1), - model: z.string().min(1), - pricingModel: z.string().min(1).optional(), + model: ModelIdentifier, + pricingModel: ModelIdentifier.optional(), tokens: TokenBuckets, webSearchRequests: NonNegInt, diff --git a/packages/core/src/providers/antigravity/decode.ts b/packages/core/src/providers/antigravity/decode.ts index 3ad3e25d..bfccd4ab 100644 --- a/packages/core/src/providers/antigravity/decode.ts +++ b/packages/core/src/providers/antigravity/decode.ts @@ -5,6 +5,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { normalizeModelIdentifier } from '../../schema.js' import type { AntigravityDecodedCall, AntigravityGeneratorMetadata, @@ -342,7 +343,13 @@ function parseFiniteToken(value: unknown): number { function usageSignature(event: AntigravityStatusLineEvent): string { const u = event.usage return [ - event.model, + // The model component must never be the raw display name: this signature + // feeds the dedup key, which SHIPS on the envelope, and the observation + // boundary normalizes the same value (a display name like "Gemini 3.5 + // Flash (High)" collapses to 'unknown' there). Building the signature + // from the normalized identifier keeps the key and the envelope's model + // field consistent and stops free text from riding the key. + normalizeModelIdentifier(event.model), u.inputTokens, u.outputTokens, u.cacheCreationInputTokens, diff --git a/packages/core/src/providers/antigravity/observations.ts b/packages/core/src/providers/antigravity/observations.ts index 91d9fcb2..9a21eeef 100644 --- a/packages/core/src/providers/antigravity/observations.ts +++ b/packages/core/src/providers/antigravity/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { AntigravityDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Antigravity cascade's rich decode, as the host holds it before minimization. */ export interface RichAntigravitySessionDecode { @@ -27,7 +28,7 @@ export interface AntigravityToObservationsContext { function toCallObservation(call: AntigravityDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/claude/observations.ts b/packages/core/src/providers/claude/observations.ts index 582af21d..49df19f2 100644 --- a/packages/core/src/providers/claude/observations.ts +++ b/packages/core/src/providers/claude/observations.ts @@ -10,6 +10,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { DecodedCall, DecodedTurn } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One session's rich decode, as the host holds it before minimization. */ export interface RichSessionDecode { @@ -38,7 +39,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: DecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.usage.inputTokens, output: call.usage.outputTokens, diff --git a/packages/core/src/providers/cline-cli/decode.ts b/packages/core/src/providers/cline-cli/decode.ts new file mode 100644 index 00000000..4f6d695d --- /dev/null +++ b/packages/core/src/providers/cline-cli/decode.ts @@ -0,0 +1,355 @@ +// @codeburn/core Cline CLI decoder: pure decode over the composite +// { meta, messages } record the host hands in. No fs / env / clock — the host +// reads and JSON-parses the metadata + messages files and passes one record +// through. The rich output carries token buckets + the CLI's reported cost but +// NO pricing (cost leaves the decoder; the host prices via its measured / +// estimated seam) and NO bash base-name extraction (that, with its `strip-ansi` +// dependency, stays host-side). +// +// Cline CLI is a "simple file-based" provider: one session directory is one +// logical session, the host re-reads both files every run (no incremental +// cache), so the decoder is a single pass with no serializable resume state. +// The only cross-record memory it needs is the pending user message (threaded +// within the one pass) and the cross-file dedup set (threaded live by the host, +// exactly like qwen). + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { ClineCliDecodedCall, ClineCliSessionRecords, ClineCliToolCall } from './types.js' + +export const PROVIDER_NAME = 'cline-cli' + +// Cline CLI tool names mapped to the canonical vocabulary. A name with no +// mapping passes through unchanged so a provider-native tool still shows up. +export const clineCliToolNameMap: Record = { + run_commands: 'Bash', + read_files: 'Read', + editor: 'Edit', + apply_patch: 'Edit', + search_codebase: 'Grep', + fetch_web_content: 'WebFetch', + skills: 'Skill', + spawn_agent: 'Agent', + team_spawn_teammate: 'Agent', + team_run_task: 'Agent', + ask_question: 'AskUser', +} + +function mapToolName(rawTool: string): string { + return clineCliToolNameMap[rawTool] ?? rawTool +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function safeNonNegativeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0 +} + +function safeTokenCount(value: unknown): number { + return Math.floor(Math.min(safeNonNegativeNumber(value), Number.MAX_SAFE_INTEGER)) +} + +// A cost counts as metered only when it is actually present and non-negative. +// `0` is a legitimate metered value (a free/cached call) and must stay reported, +// so this is a presence check, not a truthiness check. +function isReportedCost(value: unknown): boolean { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} + +type ParsedMetrics = { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + /** CLI-reported dollar cost, present only when actually metered (incl. $0). */ + reportedCost?: number +} + +function parseMetrics(value: unknown): ParsedMetrics | null { + if (!isRecord(value)) return null + const metrics: ParsedMetrics = { + inputTokens: safeTokenCount(value['inputTokens']), + outputTokens: safeTokenCount(value['outputTokens']), + cacheReadTokens: safeTokenCount(value['cacheReadTokens']), + cacheWriteTokens: safeTokenCount(value['cacheWriteTokens']), + // A negative cost is not a credit we can represent — treat it as absent and + // fall back to token pricing, rather than reporting a clamped $0 as metered. + ...(isReportedCost(value['cost']) ? { reportedCost: safeNonNegativeNumber(value['cost']) } : {}), + } + const hasTokens = metrics.inputTokens > 0 || metrics.outputTokens > 0 + || metrics.cacheReadTokens > 0 || metrics.cacheWriteTokens > 0 + return hasTokens || metrics.reportedCost !== undefined && metrics.reportedCost > 0 ? metrics : null +} + +// The CLI writes epoch milliseconds, but a seconds-resolution value would +// otherwise silently land in 1970. Promote it and reject what stays +// implausible, matching the guard kiro.ts uses on the same hazard. +const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000 + +function isoTimestamp(value: unknown, fallback: string): string { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + const ms = value < MIN_REASONABLE_TIMESTAMP_MS ? value * 1000 : value + const date = new Date(ms) + if (!Number.isNaN(date.getTime()) && date.getTime() >= MIN_REASONABLE_TIMESTAMP_MS) { + return date.toISOString() + } + } + const parsed = nonEmptyString(value) + if (parsed) { + const date = new Date(parsed) + if (!Number.isNaN(date.getTime())) return date.toISOString() + } + return fallback +} + +// `run_commands` carries its commands as a JSON-encoded array in a string +// field; anything else is treated as a single command line. +function commandsFrom(input: unknown): string[] { + if (!isRecord(input)) return [] + const raw = input['commands'] ?? input['command'] + if (Array.isArray(raw)) return raw.filter((c): c is string => typeof c === 'string') + const text = nonEmptyString(raw) + if (!text) return [] + if (text.startsWith('[')) { + try { + const parsed = JSON.parse(text) as unknown + if (Array.isArray(parsed)) return parsed.filter((c): c is string => typeof c === 'string') + } catch { + // Not JSON after all - fall through and treat the whole string as one command. + } + } + return [text] +} + +function firstString(input: unknown, keys: string[]): string | undefined { + if (!isRecord(input)) return undefined + for (const key of keys) { + const value = nonEmptyString(input[key]) + if (value) return value + } + return undefined +} + +type CollectedTools = { + tools: string[] + rawBashCommands: string[] + toolSequence: ClineCliToolCall[][] + skills: string[] + subagentTypes: string[] + webSearchRequests: number +} + +function collectTools(content: unknown): CollectedTools { + const collected: CollectedTools = { + tools: [], rawBashCommands: [], toolSequence: [], skills: [], subagentTypes: [], webSearchRequests: 0, + } + if (!Array.isArray(content)) return collected + + const turnTools: ClineCliToolCall[] = [] + for (const block of content) { + if (!isRecord(block) || block['type'] !== 'tool_use') continue + const rawName = nonEmptyString(block['name']) + if (!rawName) continue + const mapped = mapToolName(rawName) + const input = block['input'] + const toolCall: ClineCliToolCall = { tool: mapped } + + const file = firstString(input, ['path', 'file_path', 'paths', 'file']) + if (file) toolCall.file = file + + if (mapped === 'Bash') { + const commands = commandsFrom(input) + const [first] = commands + if (first) toolCall.command = first + // Raw command strings travel host-side; base-name extraction (with its + // `strip-ansi` dependency) stays in the CLI adapter's toProviderCall. + for (const command of commands) collected.rawBashCommands.push(command) + } + if (mapped === 'Skill') { + const skill = firstString(input, ['name', 'skill', 'skill_name']) + if (skill) collected.skills.push(skill) + } + if (mapped === 'Agent') { + const subagentType = firstString(input, ['agent', 'agent_type', 'type', 'name']) + if (subagentType) collected.subagentTypes.push(subagentType) + } + if (mapped === 'WebFetch') collected.webSearchRequests++ + + collected.tools.push(mapped) + turnTools.push(toolCall) + } + + if (turnTools.length > 0) collected.toolSequence.push(turnTools) + return collected +} + +function textFromContent(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + for (const block of content) { + if (!isRecord(block) || block['type'] !== 'text') continue + const text = nonEmptyString(block['text']) + if (text) return text + } + return '' +} + +function firstUserMessage(messages: unknown[]): string { + for (const message of messages) { + if (!isRecord(message) || message['role'] !== 'user') continue + const text = textFromContent(message['content']) + // Tool results come back as role:user too; they carry no text block. + if (text) return text + } + return '' +} + +export type ClineCliDecodeInput = { + records: unknown[] + context: DecodeContext + // Optional live dedup set the host mutates in place (its shared cross-file + // seenKeys). Threaded exactly like qwen's live set. Simple file-based + // providers never persist resume state, so there is no serialized fallback. + seenKeys?: Set +} + +export type ClineCliDecodeResult = { + calls: ClineCliDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +/** + * Decode one Cline CLI session's composite record into rich, cost-free calls. + * Emits one call per assistant message carrying a metrics block; when no + * message carries metrics at all, falls back to the session rollup + * (`metadata.usage`, deliberately NOT `aggregateUsage`, which folds in spawned + * subagents that are themselves separate session directories). + * + * Dedup is keyed on `cline-cli::` (per-message) and + * `cline-cli::rollup` against the live `seenKeys` set (host-owned). + */ +// `context` is part of the decode contract but the rich layer never consumes it: +// minimization / fingerprinting happens in toObservations. +export function decodeClineCli({ records, seenKeys: liveSeen }: ClineCliDecodeInput): ClineCliDecodeResult { + const seen = liveSeen ?? new Set() + const calls: ClineCliDecodedCall[] = [] + const diagnostics: RecordDiagnostic[] = [] + + const envelope = records[0] + if (!isRecord(envelope)) return { calls, diagnostics } + const recordsShape = envelope as unknown as ClineCliSessionRecords + const meta = isRecord(recordsShape.meta) ? recordsShape.meta : null + if (!meta) return { calls, diagnostics } + const messages = Array.isArray(recordsShape.messages) ? recordsShape.messages : [] + + const sessionId = nonEmptyString(meta['session_id']) ?? '' + const metadata = isRecord(meta['metadata']) ? meta['metadata'] : {} + const workspace = nonEmptyString(meta['workspace_root']) ?? nonEmptyString(meta['cwd']) + const sessionModel = nonEmptyString(meta['model']) ?? 'unknown' + const startedAt = isoTimestamp(meta['started_at'], new Date(0).toISOString()) + // Always injected by the CLI adapter's readRecords (from the discovered + // source); falls back to the display name only if that ever changes. + const project = nonEmptyString(meta['project']) ?? 'Cline CLI' + const cwd = nonEmptyString(meta['cwd']) + + const userMessage = firstUserMessage(messages) + // Whether the session carried any per-message metrics at all. Set before + // the dedup check below so a session whose calls were all deduped (e.g. a + // duplicated session directory reusing a session_id) still declines the + // rollup fallback rather than double-counting its cost through it. + let hadMetrics = false + + for (const [index, message] of messages.entries()) { + if (!isRecord(message) || message['role'] !== 'assistant') continue + const metrics = parseMetrics(message['metrics']) + if (!metrics) continue + hadMetrics = true + + const modelInfo = isRecord(message['modelInfo']) ? message['modelInfo'] : {} + const model = nonEmptyString(modelInfo['id']) ?? sessionModel + const messageId = nonEmptyString(message['id']) ?? String(index) + const deduplicationKey = `${PROVIDER_NAME}:${sessionId}:${messageId}` + if (seen.has(deduplicationKey)) continue + seen.add(deduplicationKey) + + const { tools, rawBashCommands, toolSequence, skills, subagentTypes, webSearchRequests } + = collectTools(message['content']) + + calls.push({ + provider: PROVIDER_NAME, + model, + inputTokens: metrics.inputTokens, + outputTokens: metrics.outputTokens, + cacheCreationInputTokens: metrics.cacheWriteTokens, + cacheReadInputTokens: metrics.cacheReadTokens, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests, + ...(metrics.reportedCost !== undefined ? { reportedCost: metrics.reportedCost } : {}), + tools, + rawBashCommands, + skills: skills.length > 0 ? skills : [], + subagentTypes: subagentTypes.length > 0 ? subagentTypes : [], + timestamp: isoTimestamp(message['ts'], startedAt), + speed: 'standard', + deduplicationKey, + turnId: `${sessionId}:${messageId}`, + toolSequence: toolSequence.length > 0 ? toolSequence : undefined, + userMessage, + sessionId, + project, + projectPath: workspace, + ...(cwd ? { workingDirectory: cwd } : {}), + }) + } + + if (hadMetrics) return { calls, diagnostics } + + // No per-message metrics: fall back to the session rollup so an interrupted + // or older session still reports its spend. + const rollup = parseMetrics(isRecord(metadata['usage']) ? metadata['usage'] : null) + if (!rollup) return { calls, diagnostics } + const deduplicationKey = `${PROVIDER_NAME}:${sessionId}:rollup` + if (seen.has(deduplicationKey)) return { calls, diagnostics } + seen.add(deduplicationKey) + + // Same presence-not-truthiness rule as the per-message path: a metered $0 + // rollup stays reported instead of being re-estimated from tokens. + const rawRollupCost = (isRecord(metadata['usage']) ? metadata['usage']['totalCost'] : undefined) + ?? metadata['totalCost'] + const rollupReportedCost = isReportedCost(rawRollupCost) ? safeNonNegativeNumber(rawRollupCost) : undefined + + calls.push({ + provider: PROVIDER_NAME, + model: sessionModel, + inputTokens: rollup.inputTokens, + outputTokens: rollup.outputTokens, + cacheCreationInputTokens: rollup.cacheWriteTokens, + cacheReadInputTokens: rollup.cacheReadTokens, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + ...(rollupReportedCost !== undefined ? { reportedCost: rollupReportedCost } : {}), + tools: [], + rawBashCommands: [], + skills: [], + subagentTypes: [], + timestamp: isoTimestamp(meta['ended_at'], startedAt), + speed: 'standard', + deduplicationKey, + turnId: `${sessionId}:rollup`, + userMessage, + sessionId, + project, + projectPath: workspace, + ...(cwd ? { workingDirectory: cwd } : {}), + }) + + return { calls, diagnostics } +} diff --git a/packages/core/src/providers/cline-cli/index.ts b/packages/core/src/providers/cline-cli/index.ts new file mode 100644 index 00000000..89a2deb7 --- /dev/null +++ b/packages/core/src/providers/cline-cli/index.ts @@ -0,0 +1,30 @@ +// @codeburn/core Cline CLI provider. +// +// Two layers: +// - Rich pure decode (`decodeClineCli`): host-facing, NOT part of the stable +// minimized surface. Pure over supplied records; carries content in-memory +// but no pricing (cost leaves the decoder) and no bash base-name extraction +// (that stays host-side with its `strip-ansi` dependency). +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { + decodeClineCli, + clineCliToolNameMap, + PROVIDER_NAME, + type ClineCliDecodeInput, + type ClineCliDecodeResult, +} from './decode.js' + +export { + toObservations, + type RichClineCliSessionDecode, + type ClineCliToObservationsContext, +} from './observations.js' + +export type { + ClineCliDecodedCall, + ClineCliMetrics, + ClineCliSessionRecords, + ClineCliToolCall, +} from './types.js' diff --git a/packages/core/src/providers/cline-cli/observations.ts b/packages/core/src/providers/cline-cli/observations.ts new file mode 100644 index 00000000..ecf06b54 --- /dev/null +++ b/packages/core/src/providers/cline-cli/observations.ts @@ -0,0 +1,100 @@ +// Minimizing transform: rich Cline CLI decode -> the strict observation +// envelope. +// +// Only opaque ids, fingerprints, enums, numbers, timestamps, and canonical tool +// names cross into the output. Project paths are fingerprinted; user messages, +// commands, and file paths stay behind. + +import { projectRef, sessionRef } from '../../fingerprint.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { CallObservation, SessionObservation } from '../../observations.js' +import { extractResourceRefs } from '../resource-refs.js' +import type { ClineCliDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' + +/** One Cline CLI session's rich decode, as the host holds it before minimization. */ +export interface RichClineCliSessionDecode { + sessionId: string + /** Absolute project path (the session workspace); fingerprinted, never emitted raw. */ + projectPath: string + /** Rich, cost-free calls in decode order (one per metered message, or the rollup). */ + calls: ClineCliDecodedCall[] +} + +export interface ClineCliToObservationsContext { + /** HMAC key that scopes every fingerprint. */ + privacyKey: string + /** Provider id stamped onto sessions/calls and folded into sessionRef. */ + provider?: string +} + +const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ + +function toCallObservation(call: ClineCliDecodedCall, turnIndex: number, privacyKey: string): CallObservation { + const obs: CallObservation = { + provider: call.provider, + model: normalizeModelIdentifier(call.model), + tokens: { + input: call.inputTokens, + output: call.outputTokens, + reasoning: call.reasoningTokens, + cacheRead: call.cacheReadInputTokens, + cacheCreate: call.cacheCreationInputTokens, + }, + webSearchRequests: call.webSearchRequests, + speed: call.speed, + // The CLI reports its own metered dollar cost when present (a metered $0 + // stays reported); otherwise the host prices from the token buckets. + costBasis: call.reportedCost !== undefined ? 'measured' : 'estimated', + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + ...extractResourceRefs(privacyKey, call.toolSequence), + } + if (call.reportedCost !== undefined) { + ;(obs as CallObservation & { measuredCostUSD: number }).measuredCostUSD = call.reportedCost + } + return obs +} + +function toSessionObservation( + decode: RichClineCliSessionDecode, + ctx: ClineCliToObservationsContext, +): SessionObservation { + const provider = ctx.provider ?? 'cline-cli' + const calls: CallObservation[] = decode.calls.map((call, i) => toCallObservation(call, i, ctx.privacyKey)) + + const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort() + const startedAt = timestamps[0] ?? '' + const endedAt = timestamps.length > 0 ? timestamps[timestamps.length - 1]! : '' + + const session: SessionObservation = { + sessionRef: sessionRef(ctx.privacyKey, provider, decode.sessionId), + projectRef: projectRef(ctx.privacyKey, decode.projectPath), + providerId: provider, + startedAt, + ...(endedAt ? { endedAt } : {}), + calls, + turnCount: calls.length, + } + return session +} + +/** + * Map a rich Cline CLI decode into the minimized observation layer. Returns the + * `sessions` array plus any per-record `diagnostics`. + * + * Content-smuggling guarantee: no free text (user message, cwd, project path, + * command, read/edited file path, tool argument) is ever copied into the result. + * Only fingerprints, enums, numbers, timestamps, dedup keys, and canonical tool + * names cross the boundary. + */ +export function toObservations( + decode: RichClineCliSessionDecode | RichClineCliSessionDecode[], + ctx: ClineCliToObservationsContext, +): { sessions: SessionObservation[]; diagnostics: RecordDiagnostic[] } { + const decodes = Array.isArray(decode) ? decode : [decode] + const sessions = decodes.map(d => toSessionObservation(d, ctx)) + return { sessions, diagnostics: [] } +} diff --git a/packages/core/src/providers/cline-cli/types.ts b/packages/core/src/providers/cline-cli/types.ts new file mode 100644 index 00000000..26fc4e17 --- /dev/null +++ b/packages/core/src/providers/cline-cli/types.ts @@ -0,0 +1,83 @@ +// Raw record + rich-decode types for the Cline CLI provider. +// +// The Cline CLI (npm `cline`, 3.x) stores sessions as +// //.json (metadata + rolled-up usage) plus a +// co-located .messages.json (per-message metrics). This is a +// different layout from the VS Code extension's tasks/ui_messages.json tree the +// `cline` provider reads, so it is kept as its own provider. +// +// The host reads + JSON-parses both files (I/O stays CLI-side, like codewhale) +// and hands ONE composite record to the pure decoder. The Decoded* types are +// the rich decode layer's output: pure over supplied records, carrying content +// in-memory but NO pricing (the host prices them). The CLI adapter maps +// ClineCliDecodedCall into its own ParsedProviderCall by adding +// `costBasis`/`costUSD` (measured when the CLI reported a cost, estimated +// otherwise) and running the pricing pass. + +export type ClineCliMetrics = { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number +} + +// One tool invocation captured in a message's tool sequence. Mirrors the CLI's +// ToolCall so the host can consume it without a shape conversion; `file` and +// `command` are host-side only (fingerprinted before they can reach an +// observation). +export type ClineCliToolCall = { + tool: string + file?: string + command?: string +} + +/** + * The composite record the host hands the core decoder for one session: the + * parsed metadata file plus the parsed messages array. + * + * The CLI injects two host-side conveniences into `meta` before handing it + * over so the decoder stays path-free: + * - `session_id` when the file omits it (the session directory name, i.e. the + * metadata file's basename without `.json`); + * - `project` (the discovered source's project label). + */ +export type ClineCliSessionRecords = { + meta: Record + messages: unknown[] +} + +// The rich decode of one Cline CLI call (one assistant message with a metrics +// block, or the session rollup fallback), pre-pricing. Mirrors the host's +// ParsedProviderCall minus cost fields (the host adds those): cost leaves the +// decoder. `reportedCost` carries the CLI's own metered dollar figure when one +// was actually present and non-negative (a metered $0 stays reported); when +// absent the host prices from the token buckets. `rawBashCommands` are the +// un-split shell command strings from Bash-mapped tool calls; the CLI adapter +// runs its own base-name extraction on them to build the `bashCommands` field. +export type ClineCliDecodedCall = { + provider: 'cline-cli' + model: string + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + /** CLI-reported dollar cost, present only when actually metered (incl. $0). */ + reportedCost?: number + tools: string[] + rawBashCommands: string[] + skills: string[] + subagentTypes: string[] + toolSequence?: ClineCliToolCall[][] + timestamp: string + speed: 'standard' + deduplicationKey: string + turnId: string + userMessage: string + sessionId: string + project: string + projectPath?: string + workingDirectory?: string +} diff --git a/packages/core/src/providers/codebuff/decode.ts b/packages/core/src/providers/codebuff/decode.ts index 53c6e830..4e09d9b4 100644 --- a/packages/core/src/providers/codebuff/decode.ts +++ b/packages/core/src/providers/codebuff/decode.ts @@ -6,6 +6,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { CodebuffBlock, CodebuffChatMessage, @@ -162,7 +163,8 @@ export type CodebuffDecodeResult = { * Decode a Codebuff chat-messages.json array into rich, cost-free calls. A single * pass: user messages set the pending prompt for the next assistant call; assistant * messages that carry credits or token usage flush into a call. Dedup is keyed on - * `codebuff::` against the live `seenKeys` set (host-owned). + * `codebuff::` against the live `seenKeys` set (host-owned); + * the source path is fingerprinted, never emitted raw. */ export function decodeCodebuff({ records, context, seenKeys: liveSeen }: CodebuffDecodeInput): CodebuffDecodeResult { const seen = liveSeen ?? new Set() @@ -209,7 +211,9 @@ export function decodeCodebuff({ records, context, seenKeys: liveSeen }: Codebuf const timestamp = coerceTimestamp(msg.timestamp ?? msg.metadata?.timestamp) || fallbackTs const dedupId = msg.id ?? String(idx) - const dedupKey = `codebuff:${chatDir}:${dedupId}` + // The dedup key threads a FINGERPRINT of the chat directory (source path), + // never the raw path — dedupKey ships on the envelope. + const dedupKey = `codebuff:${sourceRefFingerprint(context.privacyKey, context.sourceRef)}:${dedupId}` if (seen.has(dedupKey)) continue seen.add(dedupKey) diff --git a/packages/core/src/providers/codebuff/observations.ts b/packages/core/src/providers/codebuff/observations.ts index 71ad60d0..7f03953a 100644 --- a/packages/core/src/providers/codebuff/observations.ts +++ b/packages/core/src/providers/codebuff/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CodebuffDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Codebuff session's rich decode, as the host holds it before minimization. */ export interface RichCodebuffSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CodebuffDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/codewhale/observations.ts b/packages/core/src/providers/codewhale/observations.ts index b726ef38..8d1303d9 100644 --- a/packages/core/src/providers/codewhale/observations.ts +++ b/packages/core/src/providers/codewhale/observations.ts @@ -9,6 +9,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { CodeWhaleDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One CodeWhale session's rich decode, as the host holds it before minimization. */ export interface RichCodeWhaleSessionDecode { @@ -31,7 +32,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CodeWhaleDecodedCall, turnIndex: number, privacyKey: string): CallObservation { const obs: CallObservation = { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/codex/decode.ts b/packages/core/src/providers/codex/decode.ts index 5bcab263..99f14998 100644 --- a/packages/core/src/providers/codex/decode.ts +++ b/packages/core/src/providers/codex/decode.ts @@ -11,6 +11,7 @@ import type { CodexDecodedCall, CodexDecodeState, CodexEntry, + CodexTimingPatch, CodexToolCall, CodexTokenUsage, } from './types.js' @@ -37,6 +38,9 @@ function normalizeContentBlocks( export const codexToolNameMap: Record = { exec_command: 'Bash', + // Codex Desktop's custom-tool transport uses the shorter `exec` name for + // the same shell tool that CLI rollouts record as `exec_command`. + exec: 'Bash', read_file: 'Read', write_file: 'Edit', apply_diff: 'Edit', @@ -100,6 +104,123 @@ function payloadHead(head: string): string { return idx === -1 ? head : head.slice(idx) } +function getRawJsonNumberField(head: string, field: string): number | undefined { + const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(head) + if (!match) return undefined + const value = Number(match[1]) + return Number.isFinite(value) ? value : undefined +} + +function getRawPayloadFieldWindow(source: Buffer, field: string, windowBytes = 4096): string | undefined { + const payloadKey = Buffer.from('"payload"') + const payloadIndex = source.indexOf(payloadKey) + if (payloadIndex < 0) return undefined + let payloadStart = source.indexOf(0x7b, payloadIndex + payloadKey.length) // { + if (payloadStart < 0) return undefined + + let depth = 0 + let inString = false + let escaped = false + for (let i = payloadStart; i < source.length; i++) { + const byte = source[i]! + if (inString) { + if (escaped) escaped = false + else if (byte === 0x5c) escaped = true // \ + else if (byte === 0x22) inString = false // " + continue + } + if (byte === 0x22) { + const keyStart = i + 1 + let keyEnd = keyStart + let keyEscaped = false + for (; keyEnd < source.length; keyEnd++) { + const keyByte = source[keyEnd]! + if (keyEscaped) { keyEscaped = false; continue } + if (keyByte === 0x5c) { keyEscaped = true; continue } + if (keyByte === 0x22) break + } + if (depth === 1 && keyEnd < source.length) { + const key = source.subarray(keyStart, keyEnd).toString('utf-8') + let valueStart = keyEnd + 1 + while (valueStart < source.length && (source[valueStart] === 0x20 || source[valueStart] === 0x09 || source[valueStart] === 0x0a || source[valueStart] === 0x0d)) valueStart++ + if (source[valueStart] === 0x3a && key === field) { + return source.subarray(i, Math.min(source.length, i + windowBytes)).toString('utf-8') + } + } + i = keyEnd + inString = false + continue + } + if (byte === 0x22) inString = true + else if (byte === 0x7b || byte === 0x5b) depth++ // { or [ + else if (byte === 0x7d || byte === 0x5d) depth-- // } or ] + if (depth < 0) break + } + return undefined +} + +function getRawDurationMs(head: string): number | undefined { + const objectMatch = /"duration"\s*:\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(head) + if (objectMatch) { + const seconds = Number(objectMatch[1]) + const nanos = Number(objectMatch[2]) + if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6 + } + const text = getRawJsonStringField(head, 'duration') + if (text) { + const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(text.trim()) + if (match) { + const value = Number(match[1]) + if (Number.isFinite(value)) return value * (match[2] === 's' ? 1000 : 1) + } + } + return undefined +} + +function durationValueMs(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'object' && value) { + const record = value as Record + const seconds = record['secs'] + const nanos = record['nanos'] + if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) { + return seconds * 1000 + nanos / 1e6 + } + } + if (typeof value === 'string') { + const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(value.trim()) + if (match) { + const parsed = Number(match[1]) + if (Number.isFinite(parsed)) return parsed * (match[2] === 's' ? 1000 : 1) + } + } + return undefined +} + +function getRawTokenUsage(head: string, field: 'last_token_usage' | 'total_token_usage'): CodexTokenUsage | undefined { + const match = new RegExp(`"${field}"\\s*:\\s*\\{([^}]*)\\}`).exec(head) + if (!match) return undefined + const body = match[1]! + return { + input_tokens: getRawJsonNumberField(body, 'input_tokens'), + cached_input_tokens: getRawJsonNumberField(body, 'cached_input_tokens'), + output_tokens: getRawJsonNumberField(body, 'output_tokens'), + reasoning_output_tokens: getRawJsonNumberField(body, 'reasoning_output_tokens'), + total_tokens: getRawJsonNumberField(body, 'total_tokens'), + } +} + +function getRawInvocation(head: string): { server?: string; tool?: string } | undefined { + const idx = head.indexOf('"invocation"') + if (idx === -1) return undefined + // Server/tool are shallow fields and precede the potentially huge arguments + // object in Codex MCP records. Limit this scan to keep compact parsing cheap. + const invocationHead = head.slice(idx, idx + 8192) + const server = getRawJsonStringField(invocationHead, 'server') + const tool = getRawJsonStringField(invocationHead, 'tool') + return server || tool ? { server, tool } : undefined +} + function countJsonStringBytes(source: Buffer, valueStart: number): number { let count = 0 for (let i = valueStart; i < source.length; i++) { @@ -172,6 +293,31 @@ export function parseCodexLine(line: string | Buffer): CodexEntry | null { const pHead = payloadHead(head) const payloadType = getRawJsonStringField(pHead, 'type') const role = getRawJsonStringField(pHead, 'role') + // task_complete appends the potentially huge final assistant message before + // its duration fields. Fall back to the full Buffer only for this event so + // timing metadata is not lost when the compact head stops early. + const needsTimingTail = type === 'event_msg' && (payloadType === 'task_complete' || payloadType === 'mcp_tool_call_end') + const timingTail = needsTimingTail && line.length > RAW_HEAD_BYTES + ? line.subarray(Math.max(0, line.length - 16 * 1024)).toString('utf-8') + : pHead + const timingNumber = (field: string): number | undefined => + getRawJsonNumberField(pHead, field) ?? getRawJsonNumberField(timingTail, field) + // MCP records can place a large invocation.arguments object before duration + // and a large result after it. Searching a small window around the field + // avoids materializing the middle of the Buffer while still preserving wait + // timing for those records. + const payloadDuration = payloadType === 'mcp_tool_call_end' + ? getRawDurationMs(getRawPayloadFieldWindow(line, 'duration') ?? '') + : undefined + const timingDuration = payloadDuration ?? getRawDurationMs(pHead) ?? getRawDurationMs(timingTail) + const compactModel = getRawJsonStringField(pHead, 'model') + const compactModelName = getRawJsonStringField(pHead, 'model_name') + const compactLastUsage = getRawTokenUsage(pHead, 'last_token_usage') + const compactTotalUsage = getRawTokenUsage(pHead, 'total_token_usage') + const compactInfo = compactModel || compactModelName || compactLastUsage || compactTotalUsage + ? { model: compactModel, model_name: compactModelName, last_token_usage: compactLastUsage, total_token_usage: compactTotalUsage } + : undefined + const invocation = getRawInvocation(pHead) ?? getRawInvocation(timingTail) const entry: CodexEntry = { type, @@ -186,6 +332,12 @@ export function parseCodexLine(line: string | Buffer): CodexEntry | null { forked_from_id: getRawJsonStringField(pHead, 'forked_from_id'), model: getRawJsonStringField(pHead, 'model'), name: getRawJsonStringField(pHead, 'name'), + invocation, + call_id: getRawJsonStringField(pHead, 'call_id'), + turn_id: getRawJsonStringField(pHead, 'turn_id'), + duration_ms: timingNumber('duration_ms') ?? timingDuration, + started_at: timingNumber('started_at'), + info: compactInfo, }, } @@ -234,6 +386,13 @@ export function freshCodexState(): CodexDecodeState { // recomputes this, so a call before the first user message keeps ':t0'. currentTurnId: ':t0', seenKeys: [], + // Task-timing window: a fresh window starts at the first call of the pass + // (priorCallCount is 0 on a cold decode). + taskResultStart: 0, + taskGeneratedTokens: 0, + taskToolIntervals: [], + taskStartedAt: undefined, + openToolStarts: {}, } } @@ -243,6 +402,8 @@ function cloneState(prev: CodexDecodeState): CodexDecodeState { pendingTools: [...prev.pendingTools], pendingToolSequence: prev.pendingToolSequence.map(step => step.map(c => ({ ...c }))), seenKeys: [...prev.seenKeys], + taskToolIntervals: prev.taskToolIntervals ? prev.taskToolIntervals.map(i => [...i]) : [], + openToolStarts: { ...(prev.openToolStarts ?? {}) }, } } @@ -264,6 +425,12 @@ export type CodexDecodeInput = { // seenKeys). When provided it is canonical and `state.seenKeys` is left empty; // when absent the decoder threads dedup memory through `state.seenKeys`. seenKeys?: Set + // Number of calls the host already emitted in EARLIER passes of this file + // (0 on a cold decode). The task-timing window's `taskResultStart` is an + // absolute index into the concatenated prior+current call list, so a resumed + // pass must know where its own calls begin. Also required for the absolute + // indices in the `timingPatches` it returns. + priorCallCount?: number // Session id to fall back to when a session_meta omits `session_id` (the CLI // passes the rollout file's basename; core never touches the path). sessionIdFallback?: string @@ -273,6 +440,10 @@ export type CodexDecodeResult = { calls: CodexDecodedCall[] diagnostics: RecordDiagnostic[] state: CodexDecodeState + // Proportional active-timing attribution for the calls of a task that was + // OPEN when a prior decode pass ended and whose task_complete arrived in this + // pass (see CodexTimingPatch). Empty unless a task straddles the boundary. + timingPatches: CodexTimingPatch[] } /** @@ -284,16 +455,51 @@ export type CodexDecodeResult = { // `context` is part of the Decoder contract but the rich layer never consumes it: // minimization / fingerprinting happens in toObservations, which takes the // privacy key directly. It stays in the input type for contract conformance. -export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, sessionIdFallback = '' }: CodexDecodeInput): CodexDecodeResult { +export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, sessionIdFallback = '', priorCallCount = 0 }: CodexDecodeInput): CodexDecodeResult { const s = prevState ? cloneState(prevState) : freshCodexState() const seen = liveSeen ?? new Set(s.seenKeys) const calls: CodexDecodedCall[] = [] const diagnostics: RecordDiagnostic[] = [] + const timingPatches: CodexTimingPatch[] = [] + + // Task-timing window (tool-excluded active throughput, issue a6bf81f). + // Seeded from the threaded state so a task whose task_started / token_counts + // landed in an earlier pass keeps its window when task_complete arrives here: + // `taskResultStart` is an ABSOLUTE index into the concatenated prior+current + // call list (priorCallCount offsets this pass's own calls). On a state that + // predates the fields (or a fresh decode) the window starts fresh. + let taskResultStart = s.taskResultStart ?? priorCallCount + let taskGeneratedTokens = s.taskGeneratedTokens ?? 0 + let taskToolIntervals: Array<[number, number]> = s.taskToolIntervals ? s.taskToolIntervals.map(i => [...i]) : [] + let taskStartedAt: number | undefined = s.taskStartedAt + const openToolStarts = new Map(Object.entries(s.openToolStarts ?? {})) for (const rawLine of records) { const entry = parseCodexLine(rawLine as string | Buffer) if (!entry) continue + const isForkReplay = Boolean(s.forkCutoff && entry.timestamp && entry.timestamp < s.forkCutoff) + if (isForkReplay && ( + entry.payload?.type === 'task_started' || + entry.payload?.type === 'task_complete' || + entry.payload?.type === 'function_call' || + entry.payload?.type === 'function_call_output' || + entry.payload?.type === 'custom_tool_call' || + entry.payload?.type === 'custom_tool_call_output' || + entry.payload?.type === 'mcp_tool_call_end' || + entry.payload?.type === 'patch_apply_end' + )) continue + + if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') { + taskResultStart = priorCallCount + calls.length + taskGeneratedTokens = 0 + taskToolIntervals = [] + const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN + taskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined + openToolStarts.clear() + continue + } + if (entry.type === 'session_meta') { // Update in place — do NOT reset the running counters. A single rollout // file can carry more than one session_meta (Codex re-emits it on resume / @@ -317,7 +523,7 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses continue } - if (entry.type === 'response_item' && entry.payload?.type === 'function_call') { + if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call')) { const rawName = entry.payload.name ?? '' const mapped = codexToolNameMap[rawName] ?? rawName s.pendingTools.push(mapped) @@ -337,10 +543,22 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses s.pendingToolSequence.push([{ tool: mcpTool }]) } } + const callId = entry.payload.call_id + const started = entry.timestamp ? Date.parse(entry.timestamp) : NaN + if (callId && Number.isFinite(started)) openToolStarts.set(callId, started) s.pendingToolSequence.push([call]) continue } + if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output')) { + const callId = entry.payload.call_id + const ended = entry.timestamp ? Date.parse(entry.timestamp) : NaN + const started = callId ? openToolStarts.get(callId) : undefined + if (started !== undefined && Number.isFinite(ended) && ended > started) taskToolIntervals.push([started, ended]) + if (callId) openToolStarts.delete(callId) + continue + } + if (entry.type === 'event_msg' && entry.payload?.type === 'patch_apply_end') { s.pendingTools.push('Edit') const p = entry.payload as Record @@ -363,6 +581,11 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses } if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end') { + const endedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN + const durationMs = entry.payload.duration_ms ?? durationValueMs(entry.payload.duration) + if (typeof durationMs === 'number' && durationMs > 0 && Number.isFinite(endedAt)) { + taskToolIntervals.push([endedAt - durationMs, endedAt]) + } const inv = (entry.payload as Record)['invocation'] as Record | undefined const server = typeof inv?.['server'] === 'string' ? inv['server'] as string : '' const tool = typeof inv?.['tool'] === 'string' ? inv['tool'] as string : '' @@ -374,6 +597,46 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses continue } + if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') { + const durationMs = entry.payload.duration_ms + const taskEnd = priorCallCount + calls.length + if (typeof durationMs === 'number' && durationMs > 0 && taskGeneratedTokens > 0 && taskResultStart < taskEnd) { + const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN + const windowStart = taskStartedAt ?? (Number.isFinite(completedAt) ? completedAt - durationMs : undefined) + const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined + const clipped = taskToolIntervals.map(([start, end]) => [ + windowStart !== undefined ? Math.max(start, windowStart) : start, + windowEnd !== undefined ? Math.min(end, windowEnd) : end, + ] as [number, number]).filter(([start, end]) => end > start) + const merged = clipped.sort((a, b) => a[0] - b[0]).reduce>((acc, interval) => { + const previous = acc.at(-1) + if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1]) + else acc.push([...interval]) + return acc + }, []) + const toolWaitMs = Math.min(durationMs, merged.reduce((sum, interval) => sum + interval[1] - interval[0], 0)) + const activeMs = durationMs - toolWaitMs + if (activeMs > 0) { + // Attribute the in-pass calls directly; a task opened in an EARLIER + // pass additionally hands the host a patch so the earlier-pass calls + // (already returned to it) get the same proportional split. + const inPassStart = Math.max(taskResultStart, priorCallCount) + for (let i = inPassStart; i < taskEnd; i++) { + const call = calls[i - priorCallCount]! + const generated = call.outputTokens + call.reasoningTokens + if (generated <= 0) continue + call.activeGeneratedTokens = generated + call.activeDurationMs = activeMs * (generated / taskGeneratedTokens) + call.toolWaitMs = toolWaitMs * (generated / taskGeneratedTokens) + } + if (taskResultStart < priorCallCount) { + timingPatches.push({ resultStart: taskResultStart, resultEnd: taskEnd, activeDurationMs: activeMs, taskGeneratedTokens, toolWaitMs }) + } + } + } + continue + } + if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload?.role === 'user') { const texts = normalizeContentBlocks(entry.payload.content) .filter(c => c.type === 'input_text') @@ -437,6 +700,7 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses ...(s.pendingEditFailed ? { editFailed: s.pendingEditFailed } : {}), }) + taskGeneratedTokens += estOutput clearPending(s) continue } @@ -516,10 +780,48 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses ...(s.pendingEditFailed ? { editFailed: s.pendingEditFailed } : {}), }) + taskGeneratedTokens += outputTokens + reasoningTokens clearPending(s) } } + // Persist the task-timing window in the threaded state so a later + // (append-resume) pass can close a still-open task with attribution across + // the boundary. + s.taskResultStart = taskResultStart + s.taskGeneratedTokens = taskGeneratedTokens + s.taskToolIntervals = taskToolIntervals + s.taskStartedAt = taskStartedAt + s.openToolStarts = Object.fromEntries(openToolStarts) + s.seenKeys = liveSeen ? [] : [...seen] - return { calls, diagnostics, state: s } + return { calls, diagnostics, state: s, timingPatches } +} + +/** + * Apply the timing patches a resumed decode returned (see CodexTimingPatch) to + * the host's CONCATENATED prior+new call list. Indices are absolute into that + * list. Overwriting the in-pass portion is a harmless no-op: the formula below + * is exactly the decoder's, so it writes identical values. + */ +export function applyCodexTimingPatches( + calls: Array<{ + outputTokens: number + reasoningTokens: number + activeDurationMs?: number + activeGeneratedTokens?: number + toolWaitMs?: number + }>, + patches: CodexTimingPatch[], +): void { + for (const patch of patches) { + for (let i = patch.resultStart; i < Math.min(patch.resultEnd, calls.length); i++) { + const call = calls[i]! + const generated = call.outputTokens + call.reasoningTokens + if (generated <= 0) continue + call.activeGeneratedTokens = generated + call.activeDurationMs = patch.activeDurationMs * (generated / patch.taskGeneratedTokens) + call.toolWaitMs = patch.toolWaitMs * (generated / patch.taskGeneratedTokens) + } + } } diff --git a/packages/core/src/providers/codex/index.ts b/packages/core/src/providers/codex/index.ts index a5eae1d8..685d550a 100644 --- a/packages/core/src/providers/codex/index.ts +++ b/packages/core/src/providers/codex/index.ts @@ -14,6 +14,7 @@ export { countUnifiedDiffLoc, mcpToolFromShellCommand, codexToolNameMap, + applyCodexTimingPatches, type CodexDecodeInput, type CodexDecodeResult, } from './decode.js' @@ -28,6 +29,7 @@ export type { CodexDecodedCall, CodexDecodeState, CodexEntry, + CodexTimingPatch, CodexToolCall, CodexTokenUsage, } from './types.js' diff --git a/packages/core/src/providers/codex/observations.ts b/packages/core/src/providers/codex/observations.ts index 5beacbbb..4ecc4c0b 100644 --- a/packages/core/src/providers/codex/observations.ts +++ b/packages/core/src/providers/codex/observations.ts @@ -10,6 +10,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { CodexDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Codex session's rich decode, as the host holds it before minimization. */ export interface RichCodexSessionDecode { @@ -35,7 +36,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CodexDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/codex/types.ts b/packages/core/src/providers/codex/types.ts index b361cdae..3dc0c921 100644 --- a/packages/core/src/providers/codex/types.ts +++ b/packages/core/src/providers/codex/types.ts @@ -28,6 +28,12 @@ export type CodexEntry = { forked_from_id?: string model?: string name?: string + turn_id?: string + call_id?: string + started_at?: number + duration_ms?: number + duration?: { secs?: number; nanos?: number } | string + invocation?: { server?: string; tool?: string } content?: Array<{ type?: string; text?: string }> info?: { model?: string @@ -75,6 +81,15 @@ export type CodexDecodedCall = { locRemoved?: number editFailed?: number costIsEstimated?: boolean + // Tool-excluded active timing, attributed from the enclosing task's + // task_started/task_complete window (see decode.ts). `activeDurationMs` is + // the task duration minus recorded tool-wait intervals, split across the + // task's calls proportionally to their generated tokens; `toolWaitMs` is the + // excluded wait share. Present only when the task recorded both timing and + // generated tokens. + activeDurationMs?: number + activeGeneratedTokens?: number + toolWaitMs?: number } /** @@ -131,4 +146,32 @@ export type CodexDecodeState = { turnCounter: number currentTurnId: string seenKeys: string[] + // Tool-excluded active-timing window (see decode.ts). Threaded through the + // state so a task whose task_started / token_counts land in one decode pass + // and its task_complete in a later (append-resume) pass still attributes + // activeDurationMs / activeGeneratedTokens / toolWaitMs to the calls emitted + // in the earlier pass. `taskResultStart` is an ABSOLUTE index into the + // concatenated prior+current call list (the host's `priorCallCount` offsets + // it on resume). `openToolStarts` is the JSON-serializable form of the + // call_id -> startedAt map. Absent on states written before the field + // existed (read as a fresh window). + taskResultStart?: number + taskGeneratedTokens?: number + taskToolIntervals?: Array<[number, number]> + taskStartedAt?: number + openToolStarts?: Record +} + +// A task that straddled a decode boundary: its task_started / token_counts were +// emitted in an EARLIER pass than its task_complete. The decoder attributes the +// in-pass calls directly; this patch carries the same proportional attribution +// for the earlier-pass calls, which the host applies to its CONCATENATED +// prior+new call list (both indices are absolute into that list). Emitted only +// when a task_complete closes a window opened in a prior pass. +export type CodexTimingPatch = { + resultStart: number + resultEnd: number + activeDurationMs: number + taskGeneratedTokens: number + toolWaitMs: number } diff --git a/packages/core/src/providers/copilot/decode.ts b/packages/core/src/providers/copilot/decode.ts index be889de0..1c28f4e5 100644 --- a/packages/core/src/providers/copilot/decode.ts +++ b/packages/core/src/providers/copilot/decode.ts @@ -1,6 +1,7 @@ import { createHash } from 'crypto' import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { normalizeModelIdentifier } from '../../schema.js' import type { AssistantMessageData, ChatJournalPathSegment, @@ -928,7 +929,11 @@ function decodeJsonl(envelope: Extract // to avoid an empty $0 row (output is intentionally excluded). if (inputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) continue - const dedupKey = `copilot:${sessionId}:shutdown:${model}` + // The model component is normalized exactly as the observation + // boundary normalizes `model`: the key ships on the envelope, so a + // display-name or hostile model string from the JSONL must collapse + // to 'unknown' inside the key, never ride it raw. + const dedupKey = `copilot:${sessionId}:shutdown:${normalizeModelIdentifier(model)}` if (seen.has(dedupKey)) continue seen.add(dedupKey) @@ -1098,6 +1103,11 @@ function decodeJetBrains(envelope: Extract() for (const turn of turns) { // One .db holds many chat tabs; group each turn under its own diff --git a/packages/core/src/providers/copilot/observations.ts b/packages/core/src/providers/copilot/observations.ts index 92ed1a49..7061073c 100644 --- a/packages/core/src/providers/copilot/observations.ts +++ b/packages/core/src/providers/copilot/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CopilotDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Copilot session's rich decode, as the host holds it before minimization. */ export interface RichCopilotSessionDecode { @@ -29,7 +30,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CopilotDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/crush/observations.ts b/packages/core/src/providers/crush/observations.ts index e89342fd..7205660f 100644 --- a/packages/core/src/providers/crush/observations.ts +++ b/packages/core/src/providers/crush/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CrushDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Crush session's rich decode, as the host holds it before minimization. */ export interface RichCrushSessionDecode { @@ -30,7 +31,7 @@ function toCallObservation(call: CrushDecodedCall, turnIndex: number): CallObser const measured = call.measuredCostUSD !== undefined return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/cursor-agent/observations.ts b/packages/core/src/providers/cursor-agent/observations.ts index 06e944e0..5a9d90c1 100644 --- a/packages/core/src/providers/cursor-agent/observations.ts +++ b/packages/core/src/providers/cursor-agent/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CursorAgentDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Cursor Agent session's rich decode, as the host holds it before minimization. */ export interface RichCursorAgentSessionDecode { @@ -32,7 +33,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CursorAgentDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/cursor/observations.ts b/packages/core/src/providers/cursor/observations.ts index ddbda94d..720e00ee 100644 --- a/packages/core/src/providers/cursor/observations.ts +++ b/packages/core/src/providers/cursor/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CursorDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Cursor session's rich decode, as the host holds it before minimization. */ export interface RichCursorSessionDecode { @@ -32,7 +33,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CursorDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/devin/observations.ts b/packages/core/src/providers/devin/observations.ts index 1bb71a64..ad68c66d 100644 --- a/packages/core/src/providers/devin/observations.ts +++ b/packages/core/src/providers/devin/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { DevinDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Devin session's rich decode, as the host holds it before minimization. */ export interface RichDevinSessionDecode { @@ -34,7 +35,7 @@ function toCallObservation(call: DevinDecodedCall, turnIndex: number, privacyKey provider: call.provider, // The raw model id, not the host's display name: the envelope is keyed by // provider ids, and display formatting lives CLI-side. - model: call.generationModel ?? call.modelName, + model: normalizeModelIdentifier(call.generationModel ?? call.modelName), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/droid/observations.ts b/packages/core/src/providers/droid/observations.ts index db24be90..f6784773 100644 --- a/packages/core/src/providers/droid/observations.ts +++ b/packages/core/src/providers/droid/observations.ts @@ -4,6 +4,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { DroidDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export interface RichDroidSessionDecode { sessionId: string @@ -21,7 +22,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: DroidDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/forge/decode.ts b/packages/core/src/providers/forge/decode.ts index a27d29c6..cfffd082 100644 --- a/packages/core/src/providers/forge/decode.ts +++ b/packages/core/src/providers/forge/decode.ts @@ -6,6 +6,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { normalizeModelIdentifier } from '../../schema.js' import type { ForgeConversationRow, ForgeContextMessage, ForgeDecodedCall } from './types.js' function sqliteTimestampToIso(value: string | null | undefined): string { @@ -158,7 +159,11 @@ export function decodeForge({ records, seenKeys: liveSeen }: ForgeDecodeInput): const model = typeof text?.model === 'string' ? text.model : 'unknown' const toolCalls = toolCallsOf(text?.tool_calls) const { tools, rawBashCommands, firstCallId } = extractToolsAndCommands(toolCalls) - const stableId = firstCallId ?? `${model}:${promptTokens}:${outputTokens}:${i}` + // The fallback stableId normalizes the model component exactly as the + // observation boundary does: this key ships on the envelope, so a + // display-name model must collapse to 'unknown' inside it, never ride + // it raw (the primary path uses the tool-call id, which is a machine id). + const stableId = firstCallId ?? `${normalizeModelIdentifier(model)}:${promptTokens}:${outputTokens}:${i}` const deduplicationKey = `forge:${row.conversation_id}:${stableId}` if (seen.has(deduplicationKey)) continue seen.add(deduplicationKey) diff --git a/packages/core/src/providers/forge/observations.ts b/packages/core/src/providers/forge/observations.ts index bb4c9793..1b6472f2 100644 --- a/packages/core/src/providers/forge/observations.ts +++ b/packages/core/src/providers/forge/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { ForgeDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Forge conversation's rich decode, as the host holds it before minimization. */ export interface RichForgeSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: ForgeDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/gemini/observations.ts b/packages/core/src/providers/gemini/observations.ts index 744fa11d..52f056dc 100644 --- a/packages/core/src/providers/gemini/observations.ts +++ b/packages/core/src/providers/gemini/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { GeminiDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Gemini session's rich decode, as the host holds it before minimization. */ export interface RichGeminiSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: GeminiDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/goose/observations.ts b/packages/core/src/providers/goose/observations.ts index ba90005b..c46bdabb 100644 --- a/packages/core/src/providers/goose/observations.ts +++ b/packages/core/src/providers/goose/observations.ts @@ -8,6 +8,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { GooseDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Goose session's rich decode, as the host holds it before minimization. */ export interface RichGooseSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: GooseDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/grok/decode.ts b/packages/core/src/providers/grok/decode.ts index 8ac68c71..fdb11639 100644 --- a/packages/core/src/providers/grok/decode.ts +++ b/packages/core/src/providers/grok/decode.ts @@ -5,6 +5,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { GrokDecodedCall, GrokSessionRecords, GrokSignals, GrokSummary, GrokUpdate } from './types.js' // Grok Build tool ids mapped to the canonical vocabulary. Unknown ids pass @@ -124,7 +125,7 @@ export type GrokDecodeResult = { * The host owns file I/O and the live cross-file dedup set; this function is * pure over the supplied record. */ -export function decodeGrok({ records, seenKeys: liveSeen }: GrokDecodeInput): GrokDecodeResult { +export function decodeGrok({ records, context, seenKeys: liveSeen }: GrokDecodeInput): GrokDecodeResult { const seen = liveSeen ?? new Set() const session = records.find(isGrokSessionRecords) if (!session) return { calls: [], diagnostics: [] } @@ -142,7 +143,11 @@ export function decodeGrok({ records, seenKeys: liveSeen }: GrokDecodeInput): Gr const timestamp = summary.updated_at ?? summary.last_active_at ?? summary.created_at ?? '' const sessionId = summary.info?.id ?? sessionName - const dedupKey = `grok:${sourceDir}:${timestamp}:${sessionId}` + // The dedup key threads a FINGERPRINT of the session directory, never the raw + // path — dedupKey ships on the envelope, so the raw path must not cross into + // an observation output. (sessionName stays the basename-derived session + // identity; it is not a path.) + const dedupKey = `grok:${sourceRefFingerprint(context.privacyKey, sourceDir)}:${timestamp}:${sessionId}` if (seen.has(dedupKey)) return { calls: [], diagnostics: [] } seen.add(dedupKey) diff --git a/packages/core/src/providers/grok/observations.ts b/packages/core/src/providers/grok/observations.ts index 86318aee..a89b25ea 100644 --- a/packages/core/src/providers/grok/observations.ts +++ b/packages/core/src/providers/grok/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { GrokDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Grok session's rich decode, as the host holds it before minimization. */ export interface RichGrokSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: GrokDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/grok/types.ts b/packages/core/src/providers/grok/types.ts index 92d52ea8..5b6c0fbe 100644 --- a/packages/core/src/providers/grok/types.ts +++ b/packages/core/src/providers/grok/types.ts @@ -37,7 +37,8 @@ export type GrokSessionRecords = { summary: GrokSummary signals: GrokSignals | null updatesLines: string[] - /** Absolute session directory, used only for the host-side dedup key. */ + /** Absolute session directory; fingerprinted into the host-side dedup key, + * never emitted raw. */ sourceDir: string /** Basename of the session directory, used as a session id fallback. */ sessionName: string diff --git a/packages/core/src/providers/hermes/observations.ts b/packages/core/src/providers/hermes/observations.ts index 112a11cc..4ccd4ae6 100644 --- a/packages/core/src/providers/hermes/observations.ts +++ b/packages/core/src/providers/hermes/observations.ts @@ -8,6 +8,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { HermesDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Hermes session's rich decode, as the host holds it before minimization. */ export interface RichHermesSessionDecode { @@ -34,7 +35,7 @@ function toCallObservation(call: HermesDecodedCall, turnIndex: number, privacyKe const measured = call.recordedCost !== undefined return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/kimi/observations.ts b/packages/core/src/providers/kimi/observations.ts index 93d26f3a..51f1d6ab 100644 --- a/packages/core/src/providers/kimi/observations.ts +++ b/packages/core/src/providers/kimi/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { KimiDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Kimi session's rich decode, as the host holds it before minimization. */ export interface RichKimiSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: KimiDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/kimicode/observations.ts b/packages/core/src/providers/kimicode/observations.ts index 510c88b9..e6d2aefc 100644 --- a/packages/core/src/providers/kimicode/observations.ts +++ b/packages/core/src/providers/kimicode/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { KimicodeDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Kimicode session's rich decode, as the host holds it before minimization. */ export interface RichKimicodeSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: KimicodeDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/kiro/observations.ts b/packages/core/src/providers/kiro/observations.ts index 08842fec..11206cf2 100644 --- a/packages/core/src/providers/kiro/observations.ts +++ b/packages/core/src/providers/kiro/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { KiroDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Kiro session's rich decode, as the host holds it before minimization. */ export interface RichKiroSessionDecode { @@ -31,7 +32,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: KiroDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/lingtai-tui/decode.ts b/packages/core/src/providers/lingtai-tui/decode.ts index fee074dc..4bbd5723 100644 --- a/packages/core/src/providers/lingtai-tui/decode.ts +++ b/packages/core/src/providers/lingtai-tui/decode.ts @@ -2,6 +2,8 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' +import { normalizeModelIdentifier } from '../../schema.js' import type { LingTaiTuiDecodedCall, LingTaiLedgerEntry, JsonObject } from './types.js' export type LingTaiTuiDecodeInput = { @@ -141,14 +143,20 @@ export function decodeLingTaiTui({ const runId = stringField(obj, 'run_id') ?? '' const sessionId = runId || `${agentId}:${sourceLabel}` const activity = activityForSource(sourceLabel) - // The dedup key threads the source ref (host ledger path) exactly as the - // pre-migration decode did — NOT the agent-dir projectPath. + // The dedup key threads a FINGERPRINT of the source ref (host ledger path), + // never the raw path — dedupKey ships on the envelope, so the raw path must + // not cross into an observation output. (The agent-dir projectPath is never + // used here.) The model component is the NORMALIZED identifier, never the + // raw ledger text: the observation boundary normalizes the same value (a + // display name like "GPT-5.5 Pro (High)" collapses to 'unknown' there), so + // building the key from the normalized form keeps the key and the + // envelope's model field consistent and stops free text from riding the key. const dedupKey = [ 'lingtai-tui', - context.sourceRef, + sourceRefFingerprint(context.privacyKey, context.sourceRef), lineNo, timestamp, - model, + normalizeModelIdentifier(model), endpoint, sourceLabel, emId, diff --git a/packages/core/src/providers/lingtai-tui/observations.ts b/packages/core/src/providers/lingtai-tui/observations.ts index 89364810..e650348e 100644 --- a/packages/core/src/providers/lingtai-tui/observations.ts +++ b/packages/core/src/providers/lingtai-tui/observations.ts @@ -2,6 +2,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { LingTaiTuiDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export interface RichLingTaiTuiSessionDecode { sessionId: string @@ -19,7 +20,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: LingTaiTuiDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/mistral-vibe/observations.ts b/packages/core/src/providers/mistral-vibe/observations.ts index f5e51ae9..e3be2a80 100644 --- a/packages/core/src/providers/mistral-vibe/observations.ts +++ b/packages/core/src/providers/mistral-vibe/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { MistralVibeDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Mistral Vibe session's rich decode, as the host holds it before minimization. */ export interface RichMistralVibeSessionDecode { @@ -33,7 +34,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: MistralVibeDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/mux/observations.ts b/packages/core/src/providers/mux/observations.ts index 584c7330..3e03dfb9 100644 --- a/packages/core/src/providers/mux/observations.ts +++ b/packages/core/src/providers/mux/observations.ts @@ -2,6 +2,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { MuxDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export interface RichMuxSessionDecode { sessionId: string @@ -19,7 +20,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: MuxDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/open-design/observations.ts b/packages/core/src/providers/open-design/observations.ts index ad7f1480..535df0f8 100644 --- a/packages/core/src/providers/open-design/observations.ts +++ b/packages/core/src/providers/open-design/observations.ts @@ -2,6 +2,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { OpenDesignDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export interface RichOpenDesignSessionDecode { sessionId: string @@ -19,7 +20,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: OpenDesignDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/openclaw/observations.ts b/packages/core/src/providers/openclaw/observations.ts index d9e88b7a..fc8e851c 100644 --- a/packages/core/src/providers/openclaw/observations.ts +++ b/packages/core/src/providers/openclaw/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { OpenClawDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One OpenClaw session's rich decode, as the host holds it before minimization. */ export interface RichOpenClawSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: OpenClawDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/opencode-session/observations.ts b/packages/core/src/providers/opencode-session/observations.ts index 60d66291..e026c5d6 100644 --- a/packages/core/src/providers/opencode-session/observations.ts +++ b/packages/core/src/providers/opencode-session/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { OpenCodeSessionDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One OpenCode-session decode, as the host holds it before minimization. */ export interface RichOpenCodeSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: OpenCodeSessionDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/pi/decode.ts b/packages/core/src/providers/pi/decode.ts index a2b488d8..597de5e4 100644 --- a/packages/core/src/providers/pi/decode.ts +++ b/packages/core/src/providers/pi/decode.ts @@ -7,6 +7,7 @@ import { basename } from 'node:path' import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { PiDecodedCall, PiEntry } from './types.js' // Pi/OMP tool ids mapped to the canonical vocabulary. Unknown ids pass through. @@ -83,9 +84,11 @@ function normalizeContentBlocks( /** * Decode Pi/OMP session records into rich, cost-free calls. A single pass over * the entries: user messages set pending prompt; assistant messages with token - * usage flush into calls. Dedup is keyed on `::` - * against live seenKeys. `provider` ('pi' or 'omp') comes from - * `context.providerId`, since Pi and OMP share this exact decode. + * usage flush into calls. Dedup is keyed on + * `::` against live seenKeys — the + * source path is fingerprinted, never emitted raw (dedupKey ships on the + * envelope). `provider` ('pi' or 'omp') comes from `context.providerId`, since + * Pi and OMP share this exact decode. */ export function decodePi({ records, @@ -147,7 +150,11 @@ export function decodePi({ const model = msg.model ?? 'gpt-5' const responseId = msg.responseId ?? '' - const dedupKey = `${provider}:${sourcePath}:${responseId || entry.id || entry.timestamp || String(lineIdx)}` + // The dedup key threads a FINGERPRINT of the session file path, never the + // raw path — dedupKey ships on the envelope, so the raw path must not + // cross into an observation output. (The basename-derived sessionId below + // stays the host's session identity; it is not a path.) + const dedupKey = `${provider}:${sourceRefFingerprint(context.privacyKey, context.sourceRef)}:${responseId || entry.id || entry.timestamp || String(lineIdx)}` if (seen.has(dedupKey)) continue seen.add(dedupKey) diff --git a/packages/core/src/providers/pi/observations.ts b/packages/core/src/providers/pi/observations.ts index 3c47062c..cc3aa22d 100644 --- a/packages/core/src/providers/pi/observations.ts +++ b/packages/core/src/providers/pi/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { PiDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Pi/OMP session's rich decode, as the host holds it before minimization. */ export interface RichPiSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: PiDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/quickdesk/decode.ts b/packages/core/src/providers/quickdesk/decode.ts index e54a995e..1a0a1c90 100644 --- a/packages/core/src/providers/quickdesk/decode.ts +++ b/packages/core/src/providers/quickdesk/decode.ts @@ -6,6 +6,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { normalizeModelIdentifier } from '../../schema.js' import type { QuickdeskDatabaseInput, QuickdeskDecodedCall, @@ -142,7 +143,11 @@ function decodeMetrics(input: QuickdeskMetricsInput, seen: Set): Quickde if (metadata?.deleted) continue const fallbackId = `${project}:${fileId}` - const deduplicationKey = `quickdesk:${linkedSessionId || fallbackId}:${timestamp}:${model}:${inputTokens}:${outputTokens}` + // The model component is normalized exactly as the observation boundary + // normalizes `model`: the key ships on the envelope, so a display name or + // free text in the CSV 'Model' column must collapse to 'unknown' inside + // the key too, never ride it raw. + const deduplicationKey = `quickdesk:${linkedSessionId || fallbackId}:${timestamp}:${normalizeModelIdentifier(model)}:${inputTokens}:${outputTokens}` if (seen.has(deduplicationKey)) continue seen.add(deduplicationKey) diff --git a/packages/core/src/providers/quickdesk/observations.ts b/packages/core/src/providers/quickdesk/observations.ts index 35324147..b256ee6a 100644 --- a/packages/core/src/providers/quickdesk/observations.ts +++ b/packages/core/src/providers/quickdesk/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { QuickdeskDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Quickdesk session's rich decode, as the host holds it before minimization. */ export interface RichQuickdeskSessionDecode { @@ -33,7 +34,7 @@ function toCallObservation(call: QuickdeskDecodedCall, turnIndex: number, privac const measured = call.recordedCost !== undefined return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/qwen/observations.ts b/packages/core/src/providers/qwen/observations.ts index 0eb67394..37257543 100644 --- a/packages/core/src/providers/qwen/observations.ts +++ b/packages/core/src/providers/qwen/observations.ts @@ -10,6 +10,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { QwenDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Qwen session's rich decode, as the host holds it before minimization. */ export interface RichQwenSessionDecode { @@ -35,7 +36,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: QwenDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/vercel-gateway/decode.ts b/packages/core/src/providers/vercel-gateway/decode.ts index 487e9df2..07c3dec1 100644 --- a/packages/core/src/providers/vercel-gateway/decode.ts +++ b/packages/core/src/providers/vercel-gateway/decode.ts @@ -5,6 +5,7 @@ // threads the shared cross-file dedup set. import type { VercelGatewayDecodedCall, VercelGatewayReportRow } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export type VercelGatewayDecodeInput = { records: unknown[] @@ -22,9 +23,15 @@ export type VercelGatewayDecodeResult = { * mapping matches the original host-side parser verbatim: * - day/model/cost defaults * - all-zero rows are skipped BEFORE dedup key burn - * - dedup key `vercel-gateway::` with add-after-skip semantics + * - dedup key `vercel-gateway::` with add-after-skip + * semantics. The model component is run through normalizeModelIdentifier + * (the same function the observation boundary applies to `model`): the + * key SHIPS on the envelope, so a hostile prompt or display name planted + * in the externally-supplied report collapses to 'unknown' inside the key + * too, and a legitimate identifier-shaped slug is unchanged. * - timestamp synthesized as `${day}T12:00:00.000Z` - * - sessionId synthesized as `${day}:${model}` + * - sessionId synthesized as `${day}:${model}` (rich-decode only — never + * shipped raw; the envelope's sessionRef is an HMAC fingerprint of it) */ export function decodeVercelGateway(input: VercelGatewayDecodeInput): VercelGatewayDecodeResult { const { records, seenKeys: liveSeen } = input @@ -44,7 +51,7 @@ export function decodeVercelGateway(input: VercelGatewayDecodeInput): VercelGate // key and block a later non-zero row for the same day×model. if (costUSD === 0 && inputTokens === 0 && outputTokens === 0) continue - const deduplicationKey = `vercel-gateway:${day}:${model}` + const deduplicationKey = `vercel-gateway:${day}:${normalizeModelIdentifier(model)}` if (seen.has(deduplicationKey)) continue seen.add(deduplicationKey) diff --git a/packages/core/src/providers/vercel-gateway/observations.ts b/packages/core/src/providers/vercel-gateway/observations.ts index 9db01053..07150b36 100644 --- a/packages/core/src/providers/vercel-gateway/observations.ts +++ b/packages/core/src/providers/vercel-gateway/observations.ts @@ -3,8 +3,12 @@ // // Vercel Gateway reports contain no free-text user content. The only string // fields that cross into the envelope are machine identifiers: -// - `provider` and `model` are emitted by design under the identifier-exemption +// - `provider` is emitted by design under the identifier-exemption // convention (see architecture-gate.test.ts MACHINE_ID_ALLOWLIST). +// - `model` is externally supplied (the fetched report) and is normalized at +// this boundary: values inside the ModelIdentifier charset cross unchanged, +// anything else (a hostile prompt, a display name) collapses to 'unknown', +// so a bad model can never reject the whole envelope. // - `day` is an API-supplied calendar date. It IS emitted, verbatim, inside the // synthesized timestamp and the dedup key. The envelope's `format: date-time` // constraint on every timestamp is what bounds it: a `day` that is not a real @@ -16,6 +20,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { VercelGatewayDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Vercel Gateway report's rich decode, as the host holds it before minimization. */ export interface RichVercelGatewaySessionDecode { @@ -36,7 +41,7 @@ export interface VercelGatewayToObservationsContext { function toCallObservation(call: VercelGatewayDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/vscode-cline/observations.ts b/packages/core/src/providers/vscode-cline/observations.ts index 66667e2b..6efc88f9 100644 --- a/packages/core/src/providers/vscode-cline/observations.ts +++ b/packages/core/src/providers/vscode-cline/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { VscodeClineDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One vscode-cline session's rich decode, as the host holds it before minimization. */ export interface RichVscodeClineSessionDecode { @@ -30,7 +31,7 @@ function toCallObservation(call: VscodeClineDecodedCall, turnIndex: number): Cal const measured = call.measuredCostUSD !== undefined return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/warp/observations.ts b/packages/core/src/providers/warp/observations.ts index 90606683..ab6a274b 100644 --- a/packages/core/src/providers/warp/observations.ts +++ b/packages/core/src/providers/warp/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { WarpDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Warp session's rich decode, as the host holds it before minimization. */ export interface RichWarpSessionDecode { @@ -32,7 +33,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: WarpDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/zcode/observations.ts b/packages/core/src/providers/zcode/observations.ts index 2f26dd18..5a67f446 100644 --- a/packages/core/src/providers/zcode/observations.ts +++ b/packages/core/src/providers/zcode/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { ZcodeDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One ZCode session's rich decode, as the host holds it before minimization. */ export interface RichZcodeSessionDecode { @@ -29,7 +30,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: ZcodeDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/zed/observations.ts b/packages/core/src/providers/zed/observations.ts index b01ca7b0..43ab3da9 100644 --- a/packages/core/src/providers/zed/observations.ts +++ b/packages/core/src/providers/zed/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { ZedDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Zed thread's rich decode, as the host holds it before minimization. */ export interface RichZedSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: ZedDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/zerostack/decode.ts b/packages/core/src/providers/zerostack/decode.ts index b7f05101..24f6cf3e 100644 --- a/packages/core/src/providers/zerostack/decode.ts +++ b/packages/core/src/providers/zerostack/decode.ts @@ -5,6 +5,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { ZerostackDecodedCall, ZerostackMessage, ZerostackSession } from './types.js' // Zerostack tool ids mapped to the canonical vocabulary. An id with @@ -48,8 +49,9 @@ export type ZerostackDecodeResult = { /** * Decode a Zerostack session file's record into a rich, cost-free call. * Zerostack has one record per session with cumulative token totals. The dedup - * key threads the source ref (host path) exactly as the pre-migration decode did: - * `zerostack:::`. + * key threads a FINGERPRINT of the source ref (host path), never the raw path: + * `zerostack:::`. The raw path + * must not cross into an observation output (dedupKey ships on the envelope). */ export function decodeZerostack({ records, @@ -72,7 +74,7 @@ export function decodeZerostack({ const timestamp = session.updated_at ?? session.created_at ?? '' const sessionId = session.id ?? sessionIdFallback ?? '' - const dedupKey = `zerostack:${context.sourceRef}:${timestamp}:${sessionId}` + const dedupKey = `zerostack:${sourceRefFingerprint(context.privacyKey, context.sourceRef)}:${timestamp}:${sessionId}` if (seen.has(dedupKey)) continue seen.add(dedupKey) diff --git a/packages/core/src/providers/zerostack/observations.ts b/packages/core/src/providers/zerostack/observations.ts index 4955f65c..be3d346b 100644 --- a/packages/core/src/providers/zerostack/observations.ts +++ b/packages/core/src/providers/zerostack/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { ZerostackDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Zerostack session's rich decode, as the host holds it before minimization. */ export interface RichZerostackSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: ZerostackDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 41d2a503..5e01e4fa 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -7,6 +7,20 @@ import { z } from 'zod' * 0.2.0 adds the optional per-call `resourceReads` / `resourceEdits` arrays * (ResourceRef). Strictness rules are unchanged: every added field is either a * fingerprint or a coarse enum, so the anti-smuggling property still holds. + * + * MIGRATION NOTE (in-place hardening, not a version bump): during 0.2.0's + * lifetime the `model` / `pricingModel` validation was tightened in place from + * `minLength: 1` to the ModelIdentifier bound (maxLength 128 + identifier + * charset), and the published schemas/observation-0.2.0.json changed in lock- + * step. The envelope shape is unchanged — producers always normalize through + * `normalizeModelIdentifier` now, so no newly produced envelope can be + * rejected. The one hazard is ARCHIVED envelopes: a pre-hardening 0.2.0 + * envelope whose model held a display name (e.g. "Gemini 3.5 Flash (High)") + * now fails validation against the same version string. Such archives must be + * re-normalized (collapse the model to 'unknown' or an identifier) before + * re-validating. A version bump was considered and rejected: 0.x is already + * breaking-by-default, no field changed shape, and a new version would force + * consumers to carry a second schema for a validation tightening alone. */ export const OBSERVATION_SCHEMA_VERSION = '0.2.0' @@ -42,6 +56,47 @@ export const CanonicalToolName = z .max(64) .regex(/^[A-Za-z0-9_.-]+$/, 'canonical tool names only (no args, paths, or spaces)') +/** + * Model identifier, as reported by the provider. Bounded to the identifier + * charset real model slugs use — letters, digits, and the separators `._:/@-` + * (openai/gpt-4o, anthropic--claude-4.6-opus, us.anthropic.claude-3-5-sonnet- + * 20241022-v2:0, cloudflare/@cf/meta/llama-2-7b-chat-fp16). The bound is + * anti-free-text: whitespace, punctuation outside the separators, and prompt + * text cannot fit, so a planted prompt or command line fails validation. It is + * NOT path-proof — `/`, `.`, `-` and `:` are valid identifier characters, so a + * path-shaped string (e.g. /Users/victim/company/secret-plan.md) can still + * match; the anti-path guarantee lives in the fingerprint fields + * (FingerprintHex), not here. A provider value outside the charset (e.g. a + * display name like "Gemini 3.5 Flash (High)") is normalized to 'unknown' at + * the observation boundary by `normalizeModelIdentifier`, never rejected here. + * The cap is generous (the longest slug in the litellm pricing snapshot is 76 + * chars) but the charset is the binding constraint. + */ +const MODEL_IDENTIFIER_PATTERN = /^[A-Za-z0-9._:/@-]+$/ + +export const ModelIdentifier = z + .string() + .min(1) + .max(128) + .regex(MODEL_IDENTIFIER_PATTERN, 'model identifiers only (letters, digits, and . _ : / @ - separators)') + +/** + * Normalize a provider-supplied model string at the observation boundary (each + * provider's toObservations). Values already inside the ModelIdentifier + * charset pass through unchanged; anything else — provider display names with + * spaces ("Gemini 3.5 Flash (High)", "GPT-5.3 Codex (medium reasoning)"), + * unmapped aliases, empty strings — collapses to 'unknown', the same fallback + * the decoders already use when no model can be resolved. This mirrors how + * non-canonical tool names are dropped rather than failing: a hostile value + * must never be able to reject a whole envelope, because one bad model id in + * one call would otherwise fail an entire multi-session batch. + */ +export function normalizeModelIdentifier(raw: string): string { + const trimmed = raw.trim() + if (trimmed.length === 0 || trimmed.length > 128) return 'unknown' + return MODEL_IDENTIFIER_PATTERN.test(trimmed) ? trimmed : 'unknown' +} + /** Per-call token buckets. All five are required, non-negative integers. */ export const TokenBuckets = z .object({ diff --git a/packages/core/tests/architecture-gate.test.ts b/packages/core/tests/architecture-gate.test.ts index b4016eb3..6e692ba8 100644 --- a/packages/core/tests/architecture-gate.test.ts +++ b/packages/core/tests/architecture-gate.test.ts @@ -127,6 +127,8 @@ const CORRECTION_PHRASES = [ const USER_MESSAGE_ALLOWLIST = new Set([ 'src/providers/claude/decode.ts', 'src/providers/claude/types.ts', + 'src/providers/cline-cli/decode.ts', + 'src/providers/cline-cli/types.ts', 'src/providers/codebuff/decode.ts', 'src/providers/codebuff/types.ts', 'src/providers/codewhale/decode.ts', @@ -299,11 +301,13 @@ function isBoundedKind(kind: string): boolean { // The only string fields NOT length/charset-capped: machine-generated // identifiers with `minLength:1` and no upper bound. Each is a controlled -// vocabulary emitted by the host (a generator version, a provider/model/pricing -// slug, or a hash-derived dedup key), never user free text — provider and model -// ids have no natural maximum, so no maxLength is asserted. content-smuggling -// tests prove no user text reaches these. Every entry is justified; a NEW -// minLength-only string field NOT listed here fails the gate. +// vocabulary emitted by the host (a generator version, a provider slug, or a +// hash-derived dedup key), never user free text — provider ids have no natural +// maximum, so no maxLength is asserted. In 0.2.0 the model/pricingModel slugs +// ARE capped (ModelIdentifier charset + maxLength); the 0.1.0 entries below +// stay allowlisted only because that schema is frozen as shipped. Every entry +// is justified; a NEW minLength-only string field NOT listed here fails the +// gate. const MACHINE_ID_ALLOWLIST = new Set([ 'observation-0.1.0#ObservationEnvelope/generator/version', 'observation-0.1.0#ObservationEnvelope/sessions/items/providerId', @@ -314,8 +318,6 @@ const MACHINE_ID_ALLOWLIST = new Set([ 'observation-0.2.0#ObservationEnvelope/generator/version', 'observation-0.2.0#ObservationEnvelope/sessions/items/providerId', 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/provider', - 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/model', - 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/pricingModel', 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/dedupKey', ]) @@ -351,8 +353,8 @@ const EXPECTED_STRING_FIELDS: StringField[] = [ { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/endedAt', kind: 'format:date-time' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/gitBranchRef', kind: 'pattern:^[0-9a-f]{16}$' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/provider', kind: 'minLength-only:1' }, - { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/model', kind: 'minLength-only:1' }, - { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/pricingModel', kind: 'minLength-only:1' }, + { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/model', kind: 'pattern:^[A-Za-z0-9._:/@-]+$' }, + { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/pricingModel', kind: 'pattern:^[A-Za-z0-9._:/@-]+$' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/speed', kind: 'enum[2]' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/costBasis', kind: 'enum[2]' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/timestamp', kind: 'format:date-time' }, diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index ebfd145f..9f4ebb22 100644 --- a/packages/core/tests/content-smuggling.test.ts +++ b/packages/core/tests/content-smuggling.test.ts @@ -20,6 +20,7 @@ import { } from '../src/providers/claude/index.js' import type { JournalEntry, ToolResultMeta } from '../src/providers/claude/index.js' import { decodeCodex, toObservations as toCodexObservations } from '../src/providers/codex/index.js' +import { decodeClineCli, toObservations as toClineCliObservations } from '../src/providers/cline-cli/index.js' import { decodeQwen, toObservations as toQwenObservations } from '../src/providers/qwen/index.js' import { decodeGrok, toObservations as toGrokObservations } from '../src/providers/grok/index.js' import { decodeKimi, toObservations as toKimiObservations } from '../src/providers/kimi/index.js' @@ -54,6 +55,9 @@ import { toObservations as toKiroObservations, } from '../src/providers/kiro/index.js' import { decodeVercelGateway, toObservations as toVercelGatewayObservations } from '../src/providers/vercel-gateway/index.js' +import { decodeZerostack, toObservations as toZerostackObservations } from '../src/providers/zerostack/index.js' +import { decodeLingTaiTui, toObservations as toLingTaiTuiObservations } from '../src/providers/lingtai-tui/index.js' +import { decodePi, toObservations as toPiObservations } from '../src/providers/pi/index.js' import type { DecodeContext } from '../src/contracts.js' import type { ZedThreadRow } from '../src/providers/zed/index.js' import type { @@ -397,6 +401,85 @@ describe('content-smuggling guardrail: real qwen decode -> toObservations is sec }) }) +describe('content-smuggling guardrail: real cline-cli decode -> toObservations is secret-free', () => { + // A hostile Cline CLI session planting every secret in the free-text fields a + // real decode captures: the user prompt, a run_commands shell line, and a + // read_files path — plus a tool NAME carrying a command line. Decoding it + // fully and minimizing MUST surface none of them. + const clineCliContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'cline-cli', sourceRef: 'ref' } + + function decodeAndMinimize() { + const records = [{ + meta: { + version: 1, session_id: 'sess-hostile', source: 'cli', status: 'completed', + provider: 'cline-pass', model: 'z-ai/glm-5.2', + cwd: SECRETS.absPath, workspace_root: SECRETS.absPath, + started_at: '2026-08-02T20:04:18.628Z', ended_at: '2026-08-02T20:08:27.768Z', + metadata: {}, project: 'secret-plan', + }, + messages: [ + { + id: 'u1', role: 'user', ts: 1785701064304, + content: [{ type: 'text', text: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}` }], + }, + { + id: 'a1', role: 'assistant', ts: 1785701064305, + modelInfo: { id: 'z-ai/glm-5.2', provider: 'cline-pass' }, + metrics: { inputTokens: 500, outputTokens: 200, cacheReadTokens: 0, cacheWriteTokens: 0, cost: 0.01 }, + content: [ + { type: 'text', text: 'done' }, + { type: 'tool_use', id: 'call_0', name: 'run_commands', input: { commands: JSON.stringify([SECRETS.commandLine]) } }, + { type: 'tool_use', id: 'call_1', name: 'read_files', input: { path: SECRETS.absPath } }, + // A hostile tool NAME carrying a command line (spaces + slashes): it + // fails the canonical charset and must be dropped, not emitted. + { type: 'tool_use', id: 'call_2', name: SECRETS.commandLine, input: {} }, + ], + }, + ], + }] + const { calls } = decodeClineCli({ records, context: clineCliContext }) + const { sessions } = toClineCliObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'cline-cli' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile chat', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) + + it('keeps canonical tool names (Bash/Read) and drops the argument-carrying name', () => { + const env = decodeAndMinimize() + const allToolNames = env.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).toContain('Bash') + expect(allToolNames).toContain('Read') + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) + + it('fingerprints the read_files path into a 16-hex resourceRead, never the raw path', () => { + const env = decodeAndMinimize() + const reads = env.sessions.flatMap(s => s.calls.flatMap(c => c.resourceReads ?? [])) + expect(reads.length).toBeGreaterThan(0) + for (const ref of reads) { + expect(ref.resourceId).toMatch(/^[0-9a-f]{16}$/) + expect(typeof ref.resourceClass).toBe('string') + } + expect(allStrings(reads)).not.toContain(SECRETS.absPath) + }) +}) + describe('content-smuggling guardrail: real vscode-cline decode -> toObservations is secret-free', () => { // A hostile vscode-cline task planting every secret in the free-text fields the // decode captures: the user message, the workspace path, and raw history text. @@ -2081,11 +2164,13 @@ describe('content-smuggling guardrail: real kiro decode -> toObservations is sec describe('content-smuggling guardrail: real vercel-gateway decode -> toObservations is secret-free', () => { - // A hostile Vercel Gateway report planting every secret in the API fields the - // decode sees. The only free-text-capable API field is `model`; under the - // identifier-exemption convention model is an API identifier emitted by design, - // so the secret planted there is expected to remain. Every other secret must - // be absent from the envelope. + // A hostile Vercel Gateway report planting every secret the API fields can + // carry. `model` is externally supplied (the fetched report), so it is + // normalized at the observation boundary: a hostile prompt collapses to + // 'unknown' and the envelope still parses — a bad model in one call can + // never reject the whole batch. `day` is spliced into the synthesized + // timestamp, whose date-time constraint is the containment: a hostile day + // fails validation, and the error path names the timestamp field. function decodeAndMinimize() { const { calls } = decodeVercelGateway({ records: [ @@ -2109,8 +2194,19 @@ describe('content-smuggling guardrail: real vercel-gateway decode -> toObservati } } - it('produces a schema-valid envelope from the hostile report', () => { - expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + it('normalizes a hostile prompt in model to unknown; the envelope still parses (no whole-batch rejection)', () => { + const env = decodeAndMinimize() + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + const call = env.sessions[0]!.calls[0]! + expect(call.model).toBe('unknown') + }) + + it('the hostile envelope serializes with none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + // The prompt was planted in model, the abs path was passed as projectPath; + // neither may survive the boundary. + expect(serialized).not.toContain(SECRETS.prompt) + expect(serialized).not.toContain(SECRETS.absPath) }) it('is non-vacuous (at least one call)', () => { @@ -2119,20 +2215,40 @@ describe('content-smuggling guardrail: real vercel-gateway decode -> toObservati expect(callCount).toBeGreaterThan(0) }) - it('contains the model secret (identifier-exemption convention) and no other secrets', () => { - const serialized = JSON.stringify(decodeAndMinimize()) - expect(serialized).toContain(SECRETS.prompt) - expect(serialized).not.toContain(SECRETS.absPath) - expect(serialized).not.toContain(SECRETS.apiKey) - expect(serialized).not.toContain(SECRETS.commandLine) - expect(serialized).not.toContain(SECRETS.fileContent) + it('still lets a legitimate identifier-shaped model cross unchanged', () => { + const { calls } = decodeVercelGateway({ + records: [ + { + day: '2026-07-17', + model: 'openai/gpt-4o', + total_cost: 1.23, + input_tokens: 100, + output_tokens: 50, + }, + ], + }) + const { sessions } = toVercelGatewayObservations( + { sessionId: 'report-identifier', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'vercel-gateway' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(envelope.sessions[0]!.calls[0]!.model).toBe('openai/gpt-4o') + const serialized = JSON.stringify(envelope) + expect(serialized).toContain('openai/gpt-4o') }) // `day` is the report's only other string field, and it is NOT sanitized: the // decode splices it verbatim into the synthesized timestamp and the dedup key. // The envelope's date-time constraint is the containment, not the decode — a - // hostile `day` fails validation and therefore never ships. - it('rejects the envelope when a hostile day is spliced into the timestamp', () => { + // hostile `day` fails validation and therefore never ships. The error is + // asserted to name the timestamp field so this test cannot pass because an + // unrelated field broke. + it('rejects the envelope when a hostile day is spliced into the timestamp, and the error names the timestamp field', () => { const { calls } = decodeVercelGateway({ records: [{ day: SECRETS.apiKey, model: 'openai/gpt-4o', total_cost: 1, input_tokens: 1, output_tokens: 1 }], }) @@ -2148,5 +2264,322 @@ describe('content-smuggling guardrail: real vercel-gateway decode -> toObservati sessions, }) expect(parsed.success).toBe(false) + if (!parsed.success) { + const paths = parsed.error.issues.map(i => i.path.join('.')) + expect(paths.some(p => p.includes('timestamp'))).toBe(true) + } + }) +}) + +describe('content-smuggling guardrail: real zerostack decode -> toObservations is secret-free', () => { + // The zerostack dedup key threads the source ref. The raw host path must not + // cross into the envelope — dedupKey ships on the envelope — so the decoder + // fingerprints the source ref instead. A hostile sourceRef (the victim's + // absolute path) planted through the real decoder must appear nowhere in the + // serialized envelope. + const zerostackContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'zerostack', + sourceRef: SECRETS.absPath, + } + + function decodeAndMinimize() { + const records = [ + { + id: 'sess-hostile', + messages: [{ role: 'user', content: SECRETS.prompt }], + total_input_tokens: 100, + total_output_tokens: 50, + model: 'deepseek/deepseek-v4-pro', + created_at: '2026-07-17T10:00:00Z', + updated_at: '2026-07-17T10:01:00Z', + }, + ] + const { calls } = decodeZerostack({ records, context: zerostackContext }) + const { sessions } = toZerostackObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'zerostack' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('fingerprints the source ref into the dedup key; the raw path appears nowhere', () => { + const env = decodeAndMinimize() + const allDedupKeys = env.sessions.flatMap(s => s.calls.map(c => c.dedupKey)) + expect(allDedupKeys.length).toBeGreaterThan(0) + for (const key of allDedupKeys) { + expect(key).not.toContain(SECRETS.absPath) + expect(key).toMatch(/^zerostack:[0-9a-f]{16}:/) + } + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.absPath) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real lingtai-tui decode -> toObservations is secret-free', () => { + // The lingtai-tui dedup key threads the source ref (the ledger path). The raw + // host path must not cross into the envelope — dedupKey ships on the + // envelope — so the decoder fingerprints the source ref instead. + const lingTaiContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'lingtai-tui', + sourceRef: SECRETS.absPath, + } + + function decodeAndMinimize() { + const records = [ + JSON.stringify({ + ts: '2026-07-17T10:00:00.000Z', + input: 100, + output: 50, + model: 'gpt-5.5', + endpoint: 'example-endpoint', + source: 'main', + }), + ] + const { calls } = decodeLingTaiTui({ + records, + context: lingTaiContext, + agentId: 'agent-hostile', + fallbackModel: 'unknown', + fallbackEndpoint: 'unknown', + projectPath: SECRETS.absPath, + }) + const { sessions } = toLingTaiTuiObservations( + { sessionId: 'agent-hostile:main', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'lingtai-tui' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile ledger', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('fingerprints the source ref into the dedup key; the raw path appears nowhere', () => { + const env = decodeAndMinimize() + const allDedupKeys = env.sessions.flatMap(s => s.calls.map(c => c.dedupKey)) + expect(allDedupKeys.length).toBeGreaterThan(0) + for (const key of allDedupKeys) { + expect(key).not.toContain(SECRETS.absPath) + expect(key).toMatch(/^lingtai-tui:[0-9a-f]{16}:/) + } + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.absPath) + }) + + it('normalizes a hostile model out of the dedup key; the raw ledger text appears nowhere', () => { + // The ledger's `model` field is provider free text. The observation boundary + // collapses it to 'unknown', so the KEY (built in the decoder) must use the + // normalized identifier too — otherwise the raw display name / planted + // prompt rides the envelope inside dedupKey. + const records = [ + JSON.stringify({ + ts: '2026-07-17T10:00:00.000Z', + input: 100, + output: 50, + model: SECRETS.prompt, + endpoint: 'example-endpoint', + source: 'main', + }), + ] + const { calls } = decodeLingTaiTui({ + records, + context: lingTaiContext, + agentId: 'agent-hostile', + fallbackModel: 'unknown', + fallbackEndpoint: 'unknown', + projectPath: SECRETS.absPath, + }) + const { sessions } = toLingTaiTuiObservations( + { sessionId: 'agent-hostile:main', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'lingtai-tui' }, + ) + const env = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + expect(env.sessions[0]!.calls[0]!.model).toBe('unknown') + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.prompt) + expect(env.sessions[0]!.calls[0]!.dedupKey).toContain(':unknown:') + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real pi/omp decode -> toObservations is secret-free', () => { + // The pi/omp dedup key threads the session file path. The raw host path must + // not cross into the envelope — dedupKey ships on the envelope — so the + // decoder fingerprints the source ref instead. A hostile sourceRef (the + // victim's absolute path) and a hostile display-name model planted through + // the real decoder must appear nowhere in the serialized envelope. + const piContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'pi', + sourceRef: SECRETS.absPath, + } + + function decodeAndMinimize() { + const records = [ + JSON.stringify({ + type: 'session', + id: 'sess-hostile', + timestamp: '2026-07-17T10:00:00.000Z', + }), + JSON.stringify({ + type: 'message', + id: 'msg-hostile-1', + timestamp: '2026-07-17T10:00:10.000Z', + message: { + role: 'assistant', + model: SECRETS.prompt, + responseId: 'resp-hostile-1', + content: [], + usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 }, + }, + }), + ] + const { calls } = decodePi({ records, context: piContext }) + const { sessions } = toPiObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'pi' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('fingerprints the source ref into the dedup key; the raw path appears nowhere', () => { + const env = decodeAndMinimize() + const allDedupKeys = env.sessions.flatMap(s => s.calls.map(c => c.dedupKey)) + expect(allDedupKeys.length).toBeGreaterThan(0) + for (const key of allDedupKeys) { + expect(key).not.toContain(SECRETS.absPath) + expect(key).toMatch(/^pi:[0-9a-f]{16}:/) + } + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.absPath) + }) + + it('normalizes a hostile display-name model out of the envelope', () => { + const env = decodeAndMinimize() + expect(env.sessions[0]!.calls[0]!.model).toBe('unknown') + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.prompt) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real grok decode -> toObservations is secret-free', () => { + // The grok dedup key threads the session directory. The raw host path must + // not cross into the envelope — dedupKey ships on the envelope — so the + // decoder fingerprints the session dir instead. A hostile sourceDir (the + // victim's absolute path) and a hostile display-name model planted through + // the real decoder must appear nowhere in the serialized envelope. + const grokContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'grok', + sourceRef: SECRETS.absPath, + } + + function decodeAndMinimize() { + const records = [ + { + summary: { + info: { id: 'sess-hostile', cwd: SECRETS.absPath }, + created_at: '2026-07-17T10:00:00.000Z', + updated_at: '2026-07-17T10:01:00.000Z', + current_model_id: SECRETS.prompt, + session_summary: 'hostile', + }, + signals: null, + updatesLines: [ + JSON.stringify({ params: { _meta: { totalTokens: 1000, promptId: 'p1' } } }), + JSON.stringify({ params: { _meta: { totalTokens: 1500, promptId: 'p1' } } }), + ], + sourceDir: SECRETS.absPath, + sessionName: 'sess-hostile', + project: 'hostile', + }, + ] + const { calls } = decodeGrok({ records, context: grokContext }) + const { sessions } = toGrokObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'grok' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('fingerprints the session dir into the dedup key; the raw path appears nowhere', () => { + const env = decodeAndMinimize() + const allDedupKeys = env.sessions.flatMap(s => s.calls.map(c => c.dedupKey)) + expect(allDedupKeys.length).toBeGreaterThan(0) + for (const key of allDedupKeys) { + expect(key).not.toContain(SECRETS.absPath) + expect(key).toMatch(/^grok:[0-9a-f]{16}:/) + } + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.absPath) + }) + + it('normalizes a hostile display-name model out of the envelope', () => { + const env = decodeAndMinimize() + expect(env.sessions[0]!.calls[0]!.model).toBe('unknown') + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.prompt) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } }) }) diff --git a/packages/core/tests/providers/antigravity-decode.test.ts b/packages/core/tests/providers/antigravity-decode.test.ts index ca261c7d..fc794d16 100644 --- a/packages/core/tests/providers/antigravity-decode.test.ts +++ b/packages/core/tests/providers/antigravity-decode.test.ts @@ -346,6 +346,42 @@ describe('antigravity rich decode (moved to @codeburn/core)', () => { expect(calls[1]).toMatchObject({ inputTokens: 100, outputTokens: 10, cacheReadInputTokens: 50 }) }) + it('acceptance: a display-name model read from payload.model.display_name is normalized at the observation boundary', () => { + // The status-line decoder reads payload.model.display_name verbatim when no + // id is present (e.g. "Gemini 3.5 Flash (High)"). The observation boundary + // must normalize it to 'unknown' instead of rejecting the whole envelope. + const payload = { + conversation_id: 'accept-1', + model: { display_name: 'Gemini 3.5 Flash (High)' }, + context_window: { + current_usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + } + const event = parseAntigravityStatusLinePayload(payload, '2026-05-05T05:05:05.005Z') + expect(event).not.toBeNull() + expect(event!.model).toBe('Gemini 3.5 Flash (High)') + + const { calls } = decodeAntigravityStatusLine({ + records: [JSON.stringify(event)], + context, + seenKeys: new Set(), + }) + expect(calls[0]!.model).toBe('Gemini 3.5 Flash (High)') + + const { sessions } = toObservations( + { sessionId: 'accept-1', projectPath: '/Users/t/project', calls }, + { privacyKey: 'test-privacy-key', provider: 'antigravity' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.model).toBe('unknown') + expect(JSON.stringify(envelope)).not.toContain('Gemini 3.5 Flash') + }) + it('parseAntigravityStatusLinePayload uses the injected at value and never captures cwd', () => { const fixedAt = '2026-05-05T05:05:05.005Z' const payload = { diff --git a/packages/core/tests/providers/cline-cli-decode.test.ts b/packages/core/tests/providers/cline-cli-decode.test.ts new file mode 100644 index 00000000..fc3fbe06 --- /dev/null +++ b/packages/core/tests/providers/cline-cli-decode.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' + +import { decodeClineCli, toObservations } from '../../src/providers/cline-cli/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import type { DecodeContext } from '../../src/contracts.js' + +const context: DecodeContext = { privacyKey: 'k', providerId: 'cline-cli', sourceRef: 'ref' } + +type MessageSpec = { + id?: string + role: 'user' | 'assistant' + text?: string + metrics?: Record + model?: string + ts?: number + toolUse?: { name: string; input: Record } +} + +function session(meta: Record, messages: MessageSpec[]): unknown[] { + return [{ + meta, + messages: messages.map((spec, index) => { + const content: unknown[] = [] + if (spec.text) content.push({ type: 'text', text: spec.text }) + if (spec.toolUse) content.push({ type: 'tool_use', id: `call_${index}`, ...spec.toolUse }) + const message: Record = { + id: spec.id ?? `msg_${index}`, + role: spec.role, + content, + ts: spec.ts ?? 1785701064304 + index * 1000, + } + if (spec.metrics) message['metrics'] = spec.metrics + if (spec.model) message['modelInfo'] = { id: spec.model, provider: 'cline-pass' } + return message + }), + }] +} + +const DEFAULT_META: Record = { + version: 1, + session_id: 'sess-a', + source: 'cli', + status: 'completed', + provider: 'cline-pass', + model: 'z-ai/glm-5.2', + cwd: '/Users/dev/work/my-repo', + workspace_root: '/Users/dev/work/my-repo', + started_at: '2026-08-02T20:04:18.628Z', + ended_at: '2026-08-02T20:08:27.768Z', + metadata: {}, + project: 'my-repo', +} + +describe('cline-cli rich decode (moved to @codeburn/core)', () => { + it('decodes metered assistant messages into rich, cost-free calls', () => { + const records = session(DEFAULT_META, [ + { role: 'user', text: 'do the thing' }, + { role: 'assistant', text: 'ok', metrics: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 2, cost: 0.01 } }, + { role: 'assistant', text: 'done', metrics: { inputTokens: 200, outputTokens: 20, cost: 0.02 } }, + ]) + const { calls } = decodeClineCli({ records, context }) + + expect(calls).toHaveLength(2) + // No pricing crosses into the decode layer. + expect(calls[0]).not.toHaveProperty('costUSD') + expect(calls[0]).not.toHaveProperty('costBasis') + + expect(calls[0]!.inputTokens).toBe(100) + expect(calls[0]!.outputTokens).toBe(10) + expect(calls[0]!.cacheReadInputTokens).toBe(5) + expect(calls[0]!.cacheCreationInputTokens).toBe(2) + expect(calls[0]!.reportedCost).toBe(0.01) + expect(calls[0]!.sessionId).toBe('sess-a') + expect(calls[0]!.project).toBe('my-repo') + expect(calls[0]!.projectPath).toBe('/Users/dev/work/my-repo') + expect(calls[0]!.workingDirectory).toBe('/Users/dev/work/my-repo') + expect(calls[0]!.deduplicationKey).toBe('cline-cli:sess-a:msg_1') + expect(calls[0]!.turnId).toBe('sess-a:msg_1') + expect(calls[0]!.userMessage).toBe('do the thing') + expect(calls[1]!.reportedCost).toBe(0.02) + }) + + it('keeps a metered $0 reported and treats a negative cost as absent', () => { + const records = session(DEFAULT_META, [ + { role: 'assistant', text: 'zero', metrics: { inputTokens: 10, outputTokens: 10, cost: 0 } }, + { role: 'assistant', text: 'negative', metrics: { inputTokens: 10, outputTokens: 10, cost: -5 } }, + ]) + const { calls } = decodeClineCli({ records, context }) + + expect(calls).toHaveLength(2) + expect(calls[0]!.reportedCost).toBe(0) + expect(calls[1]!.reportedCost).toBeUndefined() + }) + + it('maps tools to canonical names and carries raw bash commands host-side', () => { + const records = session(DEFAULT_META, [{ + role: 'assistant', text: 'running', + metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 }, + toolUse: { name: 'run_commands', input: { commands: JSON.stringify(['git status', 'ls -la']) } }, + }]) + const { calls } = decodeClineCli({ records, context }) + + expect(calls[0]!.tools).toEqual(['Bash']) + // Raw command strings survive host-side; base-name extraction is the CLI's job. + expect(calls[0]!.rawBashCommands).toEqual(['git status', 'ls -la']) + expect(calls[0]!.toolSequence?.[0]?.[0]).toEqual({ tool: 'Bash', command: 'git status' }) + }) + + it('threads a live seenKeys set so a repeated message id across passes drops', () => { + const records = session(DEFAULT_META, [ + { role: 'assistant', text: 'a', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }, + ]) + const seen = new Set() + expect(decodeClineCli({ records, context, seenKeys: seen }).calls).toHaveLength(1) + // Re-decoding the same records with the shared set yields nothing. + expect(decodeClineCli({ records, context, seenKeys: seen }).calls).toEqual([]) + }) + + it('promotes a seconds-resolution timestamp instead of landing in 1970', () => { + const seconds = Math.floor(Date.parse('2026-08-02T20:04:18.000Z') / 1000) + const records = session(DEFAULT_META, [ + { role: 'assistant', text: 'a', ts: seconds, metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }, + ]) + const { calls } = decodeClineCli({ records, context }) + expect(calls[0]!.timestamp).toBe('2026-08-02T20:04:18.000Z') + }) + + it('falls back to the session rollup when no message carries metrics', () => { + const meta = { ...DEFAULT_META, metadata: { usage: { inputTokens: 5483, outputTokens: 133, cacheReadTokens: 50, cacheWriteTokens: 0, totalCost: 0.0081984 } } } + const records = session(meta, []) + const { calls } = decodeClineCli({ records, context }) + + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(5483) + expect(calls[0]!.reportedCost).toBeCloseTo(0.0081984, 7) + expect(calls[0]!.deduplicationKey).toBe('cline-cli:sess-a:rollup') + }) + + it('declines the rollup when per-message calls were all deduped (hadMetrics gate)', () => { + // A duplicated session directory: the shared dedup already owns the message + // id, so every per-message call is suppressed — the rollup must not then + // fire and double-count the session (regression for #894). + const meta = { ...DEFAULT_META, session_id: 'shared', metadata: { usage: { inputTokens: 100, outputTokens: 10, totalCost: 0.01 } } } + const records = session(meta, [ + { id: 'msg_0', role: 'assistant', text: 'a', metrics: { inputTokens: 100, outputTokens: 10, cost: 0.01 } }, + ]) + const seen = new Set(['cline-cli:shared:msg_0']) + const { calls } = decodeClineCli({ records, context, seenKeys: seen }) + + expect(calls).toEqual([]) + }) + + it('toObservations produces a schema-valid, content-free envelope', () => { + const records = session(DEFAULT_META, [ + { role: 'user', text: 'read the file' }, + { + role: 'assistant', text: 'ok', metrics: { inputTokens: 100, outputTokens: 10, cost: 0.01 }, + toolUse: { name: 'read_files', input: { path: '/Users/dev/work/my-repo/src/a.ts' } }, + }, + ]) + const { calls } = decodeClineCli({ records, context }) + const { sessions } = toObservations( + { sessionId: 'sess-a', projectPath: '/Users/dev/work/my-repo', calls }, + { privacyKey: 'test-privacy-key', provider: 'cline-cli' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + // The metered cost crosses as measuredCostUSD (the observation carries the + // provider-reported figure the host would otherwise re-price). + expect(sessions[0]!.calls[0]!.costBasis).toBe('measured') + expect(sessions[0]!.calls[0]!.measuredCostUSD).toBe(0.01) + // The read_file path is fingerprinted into a resourceRead, never emitted raw. + const reads = sessions.flatMap(s => s.calls.flatMap(c => c.resourceReads ?? [])) + expect(reads.length).toBeGreaterThan(0) + for (const ref of reads) expect(ref.resourceId).toMatch(/^[0-9a-f]{16}$/) + }) +}) diff --git a/packages/core/tests/providers/codebuff-decode.test.ts b/packages/core/tests/providers/codebuff-decode.test.ts index 66082790..a5bb4305 100644 --- a/packages/core/tests/providers/codebuff-decode.test.ts +++ b/packages/core/tests/providers/codebuff-decode.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { decodeCodebuff, toObservations, type CodebuffChatMessage } from '../../src/providers/codebuff/index.js' import { ObservationEnvelope } from '../../src/observations.js' import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import { sourceRefFingerprint } from '../../src/fingerprint.js' import type { DecodeContext } from '../../src/contracts.js' const context: DecodeContext = { privacyKey: 'k', providerId: 'codebuff', sourceRef: '/data/manicode/projects/alpha/chats/2026-04-14T10-00-00.000Z' } @@ -93,7 +94,13 @@ describe('codebuff rich decode (moved to @codeburn/core)', () => { expect(first!.rawBashCommands).toEqual(['npm test']) expect(first!.credits).toBe(42) expect(first!.userMessage).toBe('implement the feature') - expect(first!.deduplicationKey).toBe(`codebuff:${context.sourceRef}:a1`) + // The dedup key threads a FINGERPRINT of the chat directory (the source + // ref), never the raw absolute path: dedupKey ships on the envelope, so + // the raw-path form (`codebuff:${context.sourceRef}:a1`) was the defect — + // do not restore it. The expectation is DERIVED from the same fingerprint + // function the decoder uses, so the golden pins the contract, not a + // literal. + expect(first!.deduplicationKey).toBe(`codebuff:${sourceRefFingerprint(context.privacyKey, context.sourceRef)}:a1`) expect(second!.model).toBe('claude-haiku-4-5-20251001') expect(second!.inputTokens).toBe(5000) diff --git a/packages/core/tests/providers/codex-decode.test.ts b/packages/core/tests/providers/codex-decode.test.ts index 164fb029..17924ce8 100644 --- a/packages/core/tests/providers/codex-decode.test.ts +++ b/packages/core/tests/providers/codex-decode.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { decodeCodex } from '../../src/providers/codex/index.js' +import { codexToolNameMap, decodeCodex, parseCodexLine } from '../../src/providers/codex/index.js' import type { CodexDecodeState } from '../../src/providers/codex/index.js' import type { DecodeContext } from '../../src/contracts.js' @@ -28,6 +28,23 @@ function assistantMessage(text: string, timestamp: string) { function functionCall(name: string, timestamp: string) { return JSON.stringify({ type: 'response_item', timestamp, payload: { type: 'function_call', name } }) } +function customToolCall(name: string, timestamp: string) { + return JSON.stringify({ type: 'response_item', timestamp, payload: { type: 'custom_tool_call', name } }) +} +function patchApplyEnd(opts: { success: boolean; added: number; file: string; timestamp: string }) { + return JSON.stringify({ + type: 'event_msg', + timestamp: opts.timestamp, + payload: { + type: 'patch_apply_end', + success: opts.success, + changes: { [opts.file]: { unified_diff: '+a\n'.repeat(opts.added) } }, + }, + }) +} +function mcpToolCallEnd(server: string, tool: string, timestamp: string) { + return JSON.stringify({ type: 'event_msg', timestamp, payload: { type: 'mcp_tool_call_end', invocation: { server, tool } } }) +} function tokenCount(opts: { timestamp: string; last?: { input?: number; cached?: number; output?: number; reasoning?: number }; total?: { input?: number; cached?: number; output?: number; reasoning?: number; total?: number }; noInfo?: boolean }) { const info = opts.noInfo ? undefined : { last_token_usage: opts.last ? { input_tokens: opts.last.input ?? 0, cached_input_tokens: opts.last.cached ?? 0, output_tokens: opts.last.output ?? 0, reasoning_output_tokens: opts.last.reasoning ?? 0, total_tokens: (opts.last.input ?? 0) + (opts.last.output ?? 0) } : undefined, @@ -65,7 +82,7 @@ const CORPUS: string[] = [ const FORK_BOUNDARY_INDEX = 7 // the fork's session_meta const MID_PARENT_INDEX = 4 // between A's two turns -function decodeCold(records: string[]) { +function decodeCold(records: (string | Buffer)[]) { return decodeCodex({ records, context }).calls } @@ -127,3 +144,130 @@ describe('codex decoder — round-trip resume invariant', () => { expect(parentAndForkFresh.length).toBeGreaterThan(threaded.length) }) }) + +describe('codex decoder — decode fidelity restoration (GAP 1-4)', () => { + it('GAP 1: custom_tool_call events feed the turn tools and tool sequence', () => { + const calls = decodeCold([ + sessionMeta({ session_id: 'sess-custom' }), + userMessage('use the custom tool', '2026-04-14T12:00:01Z'), + customToolCall('my_custom_tool', '2026-04-14T12:00:02Z'), + tokenCount({ timestamp: '2026-04-14T12:00:03Z', last: { input: 100 }, total: { input: 100, total: 100 } }), + ]) + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['my_custom_tool']) + expect(calls[0]!.toolSequence).toEqual([[{ tool: 'my_custom_tool' }]]) + }) + + it("GAP 2: fork replay does not leak the parent's tool/patch/MCP events into the child turn", () => { + // Parent: one turn with a FAILED edit alongside Bash + MCP. The child + // replays that history verbatim inside the 5s fork window, then does its + // own genuine work (a read + a successful edit on a different file). The + // child's turn must carry exactly its own tools/sequence/LOC — none of the + // replayed Bash, failed edit or MCP end. The child fixture has real tool + // events of its own so the test bites in BOTH directions: an + // over-aggressive skip that eats everything empties the child turn (child + // asserts fail); a neutralized skip leaks the parent's replay into it + // (parent asserts still pass, child asserts fail). + const calls = decodeCold([ + // Parent session: one turn with a Bash call, a FAILED edit and an MCP call. + sessionMeta({ session_id: 'sess-parent', timestamp: '2026-04-14T10:00:00Z' }), + userMessage('parent turn', '2026-04-14T10:00:01Z'), + functionCall('exec_command', '2026-04-14T10:00:02Z'), + patchApplyEnd({ success: false, added: 2, file: 'src/a.ts', timestamp: '2026-04-14T10:00:03Z' }), + mcpToolCallEnd('srv', 't1', '2026-04-14T10:00:04Z'), + tokenCount({ timestamp: '2026-04-14T10:00:05Z', last: { input: 500 }, total: { input: 500, total: 500 } }), + // Fork created at 10:05:00 → cutoff 10:05:05. The parent's history is + // replayed clustered inside the window (10:05:01-04) and must be skipped + // wholesale — a replayed FAILED patch and MCP end must not leak into the + // child's turn (which would inflate tools, locAdded and editFailed). + sessionMeta({ session_id: 'sess-fork', forked_from_id: 'sess-parent', timestamp: '2026-04-14T10:05:00Z' }), + functionCall('exec_command', '2026-04-14T10:05:01Z'), + patchApplyEnd({ success: false, added: 2, file: 'src/a.ts', timestamp: '2026-04-14T10:05:02Z' }), + mcpToolCallEnd('srv', 't1', '2026-04-14T10:05:03Z'), + tokenCount({ timestamp: '2026-04-14T10:05:04Z', last: { input: 500 }, total: { input: 500, total: 500 } }), + // Child's own turn, past the cutoff: genuine tool events of its own. + userMessage('child turn', '2026-04-14T10:05:20Z'), + functionCall('read_file', '2026-04-14T10:05:20Z'), + patchApplyEnd({ success: true, added: 3, file: 'src/b.ts', timestamp: '2026-04-14T10:05:20Z' }), + tokenCount({ timestamp: '2026-04-14T10:05:21Z', last: { input: 300 }, total: { input: 800, total: 800 } }), + ]) + expect(calls).toHaveLength(2) + // The parent's turn keeps its own tools, failed-edit flag and LOC. + expect(calls[0]!.inputTokens).toBe(500) + expect(calls[0]!.tools).toEqual(['Bash', 'Edit', 'mcp__srv__t1']) + expect(calls[0]!.toolSequence).toEqual([ + [{ tool: 'Bash' }], + [{ tool: 'Edit', file: 'src/a.ts' }], + [{ tool: 'mcp__srv__t1' }], + ]) + expect(calls[0]!.locAdded).toBe(2) + expect(calls[0]!.editFailed).toBe(1) + // The child's turn sees NONE of the replayed events: exactly its own read + + // successful edit, no leaked Bash / failed edit / MCP end. + expect(calls[1]!.inputTokens).toBe(300) + expect(calls[1]!.tools).toEqual(['Read', 'Edit']) + expect(calls[1]!.toolSequence).toEqual([ + [{ tool: 'Read' }], + [{ tool: 'Edit', file: 'src/b.ts' }], + ]) + expect(calls[1]!.locAdded).toBe(3) + expect(calls[1]!.locRemoved).toBeUndefined() + expect(calls[1]!.editFailed).toBeUndefined() + }) + + it('GAP 3: Buffer path synthesizes payload.info and payload.invocation', () => { + // info on the Buffer path is a latent gap: a token_count line is a handful + // of numbers and never exceeds LARGE_STREAM_LINE_BYTES (32KB), so it always + // arrives as a string. The Buffer branch must still preserve it if hit. + const tokenLine = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T13:00:00Z', + payload: { + type: 'token_count', + info: { + last_token_usage: { input_tokens: 111, output_tokens: 7, total_tokens: 118 }, + total_token_usage: { input_tokens: 111, total_tokens: 118 }, + }, + }, + }) + const tokenEntry = parseCodexLine(Buffer.from(tokenLine)) + expect(tokenEntry?.payload?.info?.last_token_usage?.input_tokens).toBe(111) + expect(tokenEntry?.payload?.info?.total_token_usage?.total_tokens).toBe(118) + + // invocation is LIVE: an mcp_tool_call_end carrying a huge + // invocation.arguments object exceeds the 32KB threshold, routes to the + // Buffer path, and without invocation extraction the mcp__server__tool + // name is lost from the turn. + const bigArgs = 'y'.repeat(40 * 1024) + const mcpLine = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T13:00:01Z', + payload: { type: 'mcp_tool_call_end', invocation: { server: 'srv', tool: 'big', arguments: { blob: bigArgs } } }, + }) + expect(Buffer.byteLength(mcpLine)).toBeGreaterThan(32 * 1024) + const mcpEntry = parseCodexLine(Buffer.from(mcpLine)) + expect(mcpEntry?.payload?.invocation).toEqual({ server: 'srv', tool: 'big' }) + + // Decode-level: the large MCP record is attributed as mcp__srv__big. + const calls = decodeCold([ + sessionMeta({ session_id: 'sess-big' }), + userMessage('big mcp', '2026-04-14T13:00:10Z'), + Buffer.from(mcpLine), + tokenCount({ timestamp: '2026-04-14T13:00:11Z', last: { input: 50 }, total: { input: 50, total: 50 } }), + ]) + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['mcp__srv__big']) + }) + + it("GAP 4: 'exec' maps to 'Bash' for the Codex Desktop custom-tool transport", () => { + expect(codexToolNameMap['exec']).toBe('Bash') + const calls = decodeCold([ + sessionMeta({ session_id: 'sess-exec' }), + userMessage('run it', '2026-04-14T14:00:01Z'), + functionCall('exec_command', '2026-04-14T14:00:02Z'), + customToolCall('exec', '2026-04-14T14:00:03Z'), + tokenCount({ timestamp: '2026-04-14T14:00:04Z', last: { input: 50 }, total: { input: 50, total: 50 } }), + ]) + expect(calls[0]!.tools).toEqual(['Bash', 'Bash']) + }) +}) diff --git a/packages/core/tests/providers/devin-decode.test.ts b/packages/core/tests/providers/devin-decode.test.ts index b653c87b..5454f404 100644 --- a/packages/core/tests/providers/devin-decode.test.ts +++ b/packages/core/tests/providers/devin-decode.test.ts @@ -286,6 +286,45 @@ describe('devin rich decode (moved to @codeburn/core)', () => { } }) + it('acceptance: a display-name model name (e.g. "Gemini 3 Flash") is normalized at the observation boundary', () => { + // When no generation_model is recorded, devin falls back to the agent's + // model_name, which real databases carry as a display name ("Gemini 3 + // Flash"). The observation boundary must normalize it to 'unknown' + // instead of rejecting the whole envelope. + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + agent: { name: 'devin', version: '2.0', model_name: 'Gemini 3 Flash' }, + steps: [ + { + step_id: 1, + source: 'assistant', + message: 'working', + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 100 }, + }, + }, + ], + } + const { calls } = decodeDevin({ records: [makeRecord(transcript)], context }) + expect(calls[0]!.generationModel).toBeUndefined() + expect(calls[0]!.modelName).toBe('Gemini 3 Flash') + + const { sessions } = toObservations( + { sessionId: 'sess-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'devin' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.model).toBe('unknown') + expect(JSON.stringify(envelope)).not.toContain('Gemini 3 Flash') + }) + it('extracts user message from ContentPart[] messages', () => { const transcript: DevinAgentTrajectory = { ...BASE_TRANSCRIPT, diff --git a/packages/core/tests/providers/grok-decode.test.ts b/packages/core/tests/providers/grok-decode.test.ts index 6fd3f37a..2f589086 100644 --- a/packages/core/tests/providers/grok-decode.test.ts +++ b/packages/core/tests/providers/grok-decode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { decodeGrok, toObservations } from '../../src/providers/grok/index.js' +import { sourceRefFingerprint } from '../../src/fingerprint.js' import { ObservationEnvelope } from '../../src/observations.js' import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' import type { DecodeContext } from '../../src/contracts.js' @@ -92,6 +93,10 @@ describe('grok rich decode (moved to @codeburn/core)', () => { expect(call.sessionId).toBe('sess-1') expect(call.project).toBe('project') expect(call.projectPath).toBe('/Users/test/project') + // The dedup key threads a FINGERPRINT of the session dir, never the raw + // path — dedupKey ships on the envelope. + expect(call.deduplicationKey).toBe(`grok:${sourceRefFingerprint('k', '/sessions/%2FUsers%2Ftest/sess-1')}:2026-06-19T11:31:12.282793Z:sess-1`) + expect(call.deduplicationKey).not.toContain('/sessions/') }) it('sums fresh input across compaction segments', () => { diff --git a/packages/core/tests/providers/vercel-gateway-decode.test.ts b/packages/core/tests/providers/vercel-gateway-decode.test.ts index 6cf6f228..cd1dac9e 100644 --- a/packages/core/tests/providers/vercel-gateway-decode.test.ts +++ b/packages/core/tests/providers/vercel-gateway-decode.test.ts @@ -109,8 +109,11 @@ describe('vercel-gateway observations', () => { } } - it('produces a schema-valid envelope', () => { - expect(ObservationEnvelope.safeParse(buildEnvelope()).success).toBe(true) + it('normalizes a hostile prompt in model to unknown; the envelope still parses (no whole-batch rejection)', () => { + const env = buildEnvelope() + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + const call = env.sessions[0]?.calls[0] + expect(call?.model).toBe('unknown') }) it('contains at least one call (non-vacuous)', () => { @@ -119,17 +122,39 @@ describe('vercel-gateway observations', () => { expect(callCount).toBeGreaterThan(0) }) - it('emits no free text except the model identifier (identifier-exemption convention)', () => { - const env = buildEnvelope() - const serialized = JSON.stringify(env) - - // The model field is an API identifier and is emitted by design; the planted - // secret in model is therefore expected to appear there and only there. - expect(serialized).toContain(SECRETS.prompt) + it('the hostile envelope serializes with none of the planted secrets', () => { + const serialized = JSON.stringify(buildEnvelope()) + // The prompt was planted in model, the abs path was passed as projectPath; + // neither may survive the boundary. + expect(serialized).not.toContain(SECRETS.prompt) expect(serialized).not.toContain(SECRETS.absPath) - expect(serialized).not.toContain(SECRETS.apiKey) - expect(serialized).not.toContain(SECRETS.commandLine) - expect(serialized).not.toContain(SECRETS.fileContent) + }) + + it('a legitimate identifier-shaped model crosses unchanged', () => { + const { calls } = decodeVercelGateway({ + records: [ + { + day: '2026-07-17', + model: 'anthropic/claude-sonnet-4.6', + total_cost: 1.23, + input_tokens: 100, + output_tokens: 50, + }, + ], + }) + const { sessions } = toObservations( + { sessionId: 'report-2026-07-17', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'vercel-gateway' }, + ) + const env = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + expect(env.sessions[0]?.calls[0]?.model).toBe('anthropic/claude-sonnet-4.6') + const serialized = JSON.stringify(env) + expect(serialized).toContain('anthropic/claude-sonnet-4.6') }) it('exposes the provider-reported cost as measured', () => { diff --git a/packages/core/tests/providers/warp-decode.test.ts b/packages/core/tests/providers/warp-decode.test.ts index 1dc7f9f7..f91d9435 100644 --- a/packages/core/tests/providers/warp-decode.test.ts +++ b/packages/core/tests/providers/warp-decode.test.ts @@ -170,6 +170,29 @@ describe('warp rich decode (moved to @codeburn/core)', () => { expect(calls[0]!.model).toBe('gpt-5.3-codex') }) + it('acceptance: a display-name model the alias map does not cover is normalized at the observation boundary', () => { + // Warp's alias map is closed, so any NEW model id arrives verbatim (spaces + // and all) — e.g. "GPT-5.4 Codex (medium reasoning)" or "Claude Sonnet + // 4.7". The decode passes it through; the observation boundary must + // normalize it to 'unknown' instead of rejecting the whole envelope. + const exchanges: WarpQueryRow[] = [makeExchange('ex-1', { model_id: 'GPT-5.4 Codex (medium reasoning)' })] + const { calls } = decodeWarp({ records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges)], context }) + expect(calls[0]!.model).toBe('GPT-5.4 Codex (medium reasoning)') + + const { sessions } = toObservations( + { sessionId: 'conv-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'warp' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.model).toBe('unknown') + expect(JSON.stringify(envelope)).not.toContain('GPT-5.4 Codex') + }) + it('uses the fallback token budget when conversation usage is absent', () => { const conversation: WarpConversationRow = { ...BASE_CONVERSATION, diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 396313a9..8308e352 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ 'src/contracts.ts', 'src/detectors/index.ts', 'src/providers/claude/index.ts', + 'src/providers/cline-cli/index.ts', 'src/providers/codebuff/index.ts', 'src/providers/codewhale/index.ts', 'src/providers/codex/index.ts',