From 1c010918ec7428e7681134f2958d60d36f05e3f2 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sun, 26 Jul 2026 16:49:17 -0700 Subject: [PATCH 1/2] =?UTF-8?q?refactor(core):=20tail=20migrations=20?= =?UTF-8?q?=E2=80=94=20gemini,=20kimicode,=20pi/omp=20(phase=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/providers/gemini.ts | 246 +++---------- packages/cli/src/providers/kimicode.ts | 333 ++++++------------ packages/cli/src/providers/pi.ts | 284 ++++++--------- .../tests/fixtures/gemini-parity/jsonl.jsonl | 4 + .../tests/fixtures/gemini-parity/single.json | 24 ++ .../session_S1/agents/agentA/wire.jsonl | 13 + .../session_S1/state.json | 5 + .../pi-parity/omp-sessions/projO/ofile.jsonl | 2 + .../pi-sessions/proj1/sess-file.jsonl | 4 + .../cli/tests/providers/gemini-bridge.test.ts | 137 +++++++ .../tests/providers/kimicode-bridge.test.ts | 139 ++++++++ .../cli/tests/providers/pi-bridge.test.ts | 127 +++++++ packages/core/package.json | 12 + packages/core/src/providers/gemini/decode.ts | 181 ++++++++++ packages/core/src/providers/gemini/index.ts | 5 + .../core/src/providers/gemini/observations.ts | 87 +++++ packages/core/src/providers/gemini/types.ts | 64 ++++ .../core/src/providers/kimicode/decode.ts | 249 +++++++++++++ packages/core/src/providers/kimicode/index.ts | 5 + .../src/providers/kimicode/observations.ts | 87 +++++ packages/core/src/providers/kimicode/types.ts | 44 +++ packages/core/src/providers/pi/decode.ts | 207 +++++++++++ packages/core/src/providers/pi/index.ts | 5 + .../core/src/providers/pi/observations.ts | 87 +++++ packages/core/src/providers/pi/types.ts | 53 +++ packages/core/tests/architecture-gate.test.ts | 11 +- packages/core/tsup.config.ts | 3 + 27 files changed, 1793 insertions(+), 625 deletions(-) create mode 100644 packages/cli/tests/fixtures/gemini-parity/jsonl.jsonl create mode 100644 packages/cli/tests/fixtures/gemini-parity/single.json create mode 100644 packages/cli/tests/fixtures/kimicode-parity/home/sessions/wd_myproj_0123456789ab/session_S1/agents/agentA/wire.jsonl create mode 100644 packages/cli/tests/fixtures/kimicode-parity/home/sessions/wd_myproj_0123456789ab/session_S1/state.json create mode 100644 packages/cli/tests/fixtures/pi-parity/omp-sessions/projO/ofile.jsonl create mode 100644 packages/cli/tests/fixtures/pi-parity/pi-sessions/proj1/sess-file.jsonl create mode 100644 packages/cli/tests/providers/gemini-bridge.test.ts create mode 100644 packages/cli/tests/providers/kimicode-bridge.test.ts create mode 100644 packages/cli/tests/providers/pi-bridge.test.ts create mode 100644 packages/core/src/providers/gemini/decode.ts create mode 100644 packages/core/src/providers/gemini/index.ts create mode 100644 packages/core/src/providers/gemini/observations.ts create mode 100644 packages/core/src/providers/gemini/types.ts create mode 100644 packages/core/src/providers/kimicode/decode.ts create mode 100644 packages/core/src/providers/kimicode/index.ts create mode 100644 packages/core/src/providers/kimicode/observations.ts create mode 100644 packages/core/src/providers/kimicode/types.ts create mode 100644 packages/core/src/providers/pi/decode.ts create mode 100644 packages/core/src/providers/pi/index.ts create mode 100644 packages/core/src/providers/pi/observations.ts create mode 100644 packages/core/src/providers/pi/types.ts diff --git a/packages/cli/src/providers/gemini.ts b/packages/cli/src/providers/gemini.ts index 7eb805a8..d46a18db 100644 --- a/packages/cli/src/providers/gemini.ts +++ b/packages/cli/src/providers/gemini.ts @@ -2,208 +2,39 @@ import { readdir, stat } from 'fs/promises' import { join } from 'path' import { homedir } from 'os' +import { decodeGemini, geminiToolNameMap } from '@codeburn/core/providers/gemini' +import type { GeminiDecodedCall } from '@codeburn/core/providers/gemini' + import { readSessionFile } from '../fs-utils.js' import { extractBashCommands } from '../bash-utils.js' -import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' - -const toolNameMap: Record = { - read_file: 'Read', - write_file: 'Write', - edit_file: 'Edit', - create_file: 'Write', - delete_file: 'Delete', - list_dir: 'LS', - grep_search: 'Grep', - search_files: 'Grep', - find_files: 'Glob', - run_command: 'Bash', - web_search: 'WebSearch', - ReadFile: 'Read', - WriteFile: 'Write', - EditFile: 'Edit', - ListDir: 'LS', - SearchText: 'Grep', - Shell: 'Bash', -} - -type GeminiTokens = { - input?: number - output?: number - cached?: number - thoughts?: number - tool?: number - total?: number -} - -type GeminiToolCall = { - id: string - name: string - args: Record - status?: string - displayName?: string -} - -type GeminiMessage = { - id: string - timestamp: string - type: 'user' | 'gemini' | 'info' - content: string | Array<{ text: string }> - tokens?: GeminiTokens - model?: string - toolCalls?: GeminiToolCall[] - thoughts?: unknown[] -} - -type GeminiSession = { - sessionId: string - projectHash?: string - startTime: string - lastUpdated?: string - messages: GeminiMessage[] - kind?: string -} - -function parseSession(data: GeminiSession, seenKeys: Set): ParsedProviderCall[] { - const results: ParsedProviderCall[] = [] - - let lastUserMessage = '' - let turnOrdinal = 0 - let currentTurnId = `${data.sessionId}:prelude` - let geminiOrdinal = 0 - - for (const msg of data.messages) { - if (msg.type === 'user') { - if (Array.isArray(msg.content)) { - lastUserMessage = msg.content.map(c => c.text).join(' ').slice(0, 500) - } else if (typeof msg.content === 'string') { - lastUserMessage = msg.content.slice(0, 500) - } - currentTurnId = `${data.sessionId}:turn-${turnOrdinal++}` - continue - } - - if (msg.type !== 'gemini' || !msg.tokens || !msg.model) continue - - const t = msg.tokens - const totalInput = t.input ?? 0 - const totalOutput = t.output ?? 0 - const totalCached = t.cached ?? 0 - const totalThoughts = t.thoughts ?? 0 - if (totalInput === 0 && totalOutput === 0 && totalCached === 0 && totalThoughts === 0) continue - - const messageKey = msg.id || `idx-${geminiOrdinal}` - geminiOrdinal++ - const dedupKey = `gemini:${data.sessionId}:${messageKey}` - if (seenKeys.has(dedupKey)) continue - - const tools: string[] = [] - const bashCommands: string[] = [] - - if (msg.toolCalls) { - for (const tc of msg.toolCalls) { - const mapped = toolNameMap[tc.displayName ?? ''] ?? toolNameMap[tc.name] ?? tc.displayName ?? tc.name - tools.push(mapped) - if (mapped === 'Bash' && tc.args && typeof tc.args.command === 'string') { - bashCommands.push(...extractBashCommands(tc.args.command)) - } - } - } - - // Gemini's `input` count includes `cached` tokens as a subset, so fresh - // input must subtract cached to avoid double-charging at both rates. - const freshInput = Math.max(0, totalInput - totalCached) - - const tsDate = new Date(msg.timestamp || data.startTime) - if (isNaN(tsDate.getTime()) || tsDate.getTime() < 1_000_000_000_000) continue - - seenKeys.add(dedupKey) - - results.push({ - provider: 'gemini', - model: msg.model, - inputTokens: freshInput, - outputTokens: totalOutput, - cacheCreationInputTokens: 0, - cacheReadInputTokens: totalCached, - cachedInputTokens: totalCached, - reasoningTokens: totalThoughts, - webSearchRequests: 0, - costBasis: 'estimated', - tools: [...new Set(tools)], - bashCommands: [...new Set(bashCommands)], - timestamp: tsDate.toISOString(), - speed: 'standard', - deduplicationKey: dedupKey, - turnId: currentTurnId, - userMessage: lastUserMessage, - sessionId: data.sessionId, - }) - } - - return results -} - -function parseJsonl(raw: string): GeminiSession | null { - const lines = raw.split('\n').filter(l => l.trim()) - if (lines.length === 0) return null - - let sessionId = '' - let startTime = '' - let projectHash: string | undefined - let lastUpdated: string | undefined - let kind: string | undefined - const messages: GeminiMessage[] = [] - - for (const line of lines) { - let obj: Record - try { - obj = JSON.parse(line) - } catch { - continue - } - if (obj['$set'] !== undefined) continue - if (obj['sessionId'] && obj['startTime'] && !sessionId) { - sessionId = obj['sessionId'] as string - startTime = obj['startTime'] as string - projectHash = obj['projectHash'] as string | undefined - lastUpdated = obj['lastUpdated'] as string | undefined - kind = obj['kind'] as string | undefined - } else if (obj['id'] && obj['type']) { - messages.push(obj as unknown as GeminiMessage) - } - } - - if (!sessionId) return null - return { sessionId, projectHash, startTime, lastUpdated, kind, messages } -} - -function createParser(source: SessionSource, seenKeys: Set): SessionParser { +import { createBridgedProvider } from './bridge.js' +import type { Provider, SessionSource, ParsedProviderCall } from './types.js' + +// Map one rich, cost-free decoder call into the host's ParsedProviderCall. Cost +// re-enters here: `costBasis: 'estimated'` marks the call so the parser.ts +// pricing pass fills `costUSD` from the token buckets. 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: GeminiDecodedCall): ParsedProviderCall { return { - async *parse(): AsyncGenerator { - const raw = await readSessionFile(source.path) - if (raw === null) return - - let data: GeminiSession | null = null - - // Try single JSON first (Gemini CLI <=0.38), then JSONL (>=0.39) - try { - const parsed = JSON.parse(raw) - if (parsed.messages && parsed.sessionId) { - data = parsed - } - } catch { /* not single JSON */ } - - if (!data) { - data = parseJsonl(raw) - } - - if (!data?.messages || !data.sessionId) return - - const calls = parseSession(data, seenKeys) - for (const call of calls) { - yield call - } - }, + provider: 'gemini', + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + costBasis: 'estimated', + tools: rich.tools, + bashCommands: [...new Set(rich.rawBashCommands.flatMap(c => extractBashCommands(c)))], + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + turnId: rich.turnId, + userMessage: rich.userMessage, + sessionId: rich.sessionId, } } @@ -245,7 +76,7 @@ async function discoverSessions(): Promise { } export function createGeminiProvider(): Provider { - return { + return createBridgedProvider({ name: 'gemini', displayName: 'Gemini', @@ -263,17 +94,24 @@ export function createGeminiProvider(): Provider { }, toolDisplayName(rawTool: string): string { - return toolNameMap[rawTool] ?? rawTool + return geminiToolNameMap[rawTool] ?? rawTool }, async discoverSessions(): Promise { return discoverSessions() }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createParser(source, seenKeys) + // I/O adapter: read the raw session file. The core decoder does its own + // format detection (single JSON <=0.38 vs headerless JSONL >=0.39). + async readRecords(source: SessionSource): Promise { + const raw = await readSessionFile(source.path) + if (raw === null) return null + return [raw] }, - } + + decode: decodeGemini, + toProviderCall, + }) } export const gemini = createGeminiProvider() diff --git a/packages/cli/src/providers/kimicode.ts b/packages/cli/src/providers/kimicode.ts index d00c0f27..6ec3df8f 100644 --- a/packages/cli/src/providers/kimicode.ts +++ b/packages/cli/src/providers/kimicode.ts @@ -2,73 +2,12 @@ import { readdir, readFile, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' +import { decodeKimicode, kimicodeToolNameMap } from '@codeburn/core/providers/kimicode' +import type { KimicodeDecodedCall } from '@codeburn/core/providers/kimicode' +import type { DecodeContext } from '@codeburn/core' import { extractBashCommands } from '../bash-utils.js' -import type { ParsedProviderCall, ProbeRoot, Provider, SessionParser, SessionSource } from './types.js' - -type JsonObject = Record - -type SessionState = { - createdAt?: string - updatedAt?: string - workDir?: string -} - -type RequestContext = { - model: string - modelAlias: string - turnId: string - timestamp: string -} - -const toolNameMap: Record = { - Bash: 'Bash', - Shell: 'Bash', - bash: 'Bash', - shell: 'Bash', - Read: 'Read', - ReadFile: 'Read', - read_file: 'Read', - Write: 'Write', - WriteFile: 'Write', - write_file: 'Write', - Edit: 'Edit', - EditFile: 'Edit', - edit_file: 'Edit', - Grep: 'Grep', - grep: 'Grep', - Glob: 'Glob', - glob: 'Glob', - Agent: 'Agent', - Task: 'Agent', -} - -function asObject(value: unknown): JsonObject | null { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as JsonObject - : null -} - -function stringValue(value: unknown): string { - return typeof value === 'string' ? value.trim() : '' -} - -function nonNegativeNumber(value: unknown): number { - const number = typeof value === 'number' - ? value - : typeof value === 'string' && value.trim() ? Number(value) : NaN - return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : 0 -} - -function timestampIso(value: unknown): string { - if (typeof value === 'string') { - const date = new Date(value) - return Number.isNaN(date.getTime()) ? '' : date.toISOString() - } - if (typeof value !== 'number' || !Number.isFinite(value)) return '' - const milliseconds = value > 1_000_000_000_000 ? value : value * 1000 - const date = new Date(milliseconds) - return Number.isNaN(date.getTime()) ? '' : date.toISOString() -} +import { createBridgedProvider } from './bridge.js' +import type { ParsedProviderCall, ProbeRoot, Provider, SessionSource } from './types.js' function kimicodeHomes(override?: string): string[] { const explicit = override || process.env['KIMI_CODE_HOME'] @@ -100,14 +39,14 @@ async function isFile(path: string): Promise { } } -async function readState(sessionDir: string): Promise { +async function readState(sessionDir: string): Promise<{ createdAt?: string; updatedAt?: string; workDir?: string }> { try { - const state = asObject(JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8'))) - if (!state) return {} + const state = JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8')) + if (!state || typeof state !== 'object') return {} return { - createdAt: stringValue(state['createdAt']) || undefined, - updatedAt: stringValue(state['updatedAt']) || undefined, - workDir: stringValue(state['workDir']) || undefined, + createdAt: typeof state.createdAt === 'string' ? state.createdAt.trim() || undefined : undefined, + updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt.trim() || undefined : undefined, + workDir: typeof state.workDir === 'string' ? state.workDir.trim() || undefined : undefined, } } catch { return {} @@ -168,175 +107,98 @@ function agentIdForWire(path: string): string { return basename(dirname(path)) } -function turnIdFromStep(value: unknown): string { - const turnStep = stringValue(value) - if (!turnStep) return '' - return turnStep.split('.', 1)[0] ?? '' -} - -function inputText(value: unknown): string { - if (typeof value === 'string') return value - if (!Array.isArray(value)) return '' - return value - .map(part => { - const record = asObject(part) - return record?.['type'] === 'text' ? stringValue(record['text']) : '' - }) - .filter(Boolean) - .join('\n') -} - -function toolDetails(value: unknown): { name: string; bashCommands: string[] } | null { - const event = asObject(value) - if (!event || stringValue(event['type']) !== 'tool.call') return null - const rawName = stringValue(event['name']) - if (!rawName) return null - const name = toolNameMap[rawName] ?? rawName - - let args = asObject(event['args']) - if (!args && typeof event['args'] === 'string') { - try { - args = asObject(JSON.parse(event['args'])) - } catch { - args = null - } - } - const command = stringValue(args?.['command']) +function toProviderCall(rich: KimicodeDecodedCall): ParsedProviderCall { return { - name, - bashCommands: name === 'Bash' && command ? extractBashCommands(command) : [], + provider: 'kimicode', + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + costBasis: 'estimated', + costIsEstimated: true, + tools: rich.tools, + // Kimicode's legacy decode did NOT dedup bash commands: pendingBashCommands + // was a flat list of every extracted base name. Preserve that (no Set). + bashCommands: rich.rawBashCommands.flatMap(c => extractBashCommands(c)), + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + turnId: rich.turnId, + userMessage: rich.userMessage, + sessionId: rich.sessionId, + project: rich.project, + projectPath: rich.projectPath, } } -function createParser(source: SessionSource, seenKeys: Set): SessionParser { - return { - async *parse(): AsyncGenerator { - let contents: string - try { - contents = await readFile(source.path, 'utf8') - } catch { - return - } - - const sessionDir = sessionDirForWire(source.path) - const sessionId = sessionIdForWire(source.path) - const agentId = source.sourceId || agentIdForWire(source.path) - const state = await readState(sessionDir) - const fallbackTimestamp = timestampIso(state.updatedAt) || timestampIso(state.createdAt) - const projectPath = state.workDir || source.sourcePath - const aliasModels = new Map() - const prompts = new Map() - let currentPrompt = '' - let currentRequest: RequestContext | null = null - let pendingTools: string[] = [] - let pendingBashCommands: string[] = [] - let usageOrdinal = 0 - - const lines = contents.split(/\r?\n/) - for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { - const line = lines[lineIndex]!.trim() - if (!line) continue - - let record: JsonObject | null - try { - record = asObject(JSON.parse(line)) - } catch { - continue - } - if (!record) continue - - const type = stringValue(record['type']) - if (type === 'turn.prompt') { - pendingTools = [] - pendingBashCommands = [] - currentPrompt = inputText(record['input']) - continue - } - - if (type === 'llm.request') { - const model = stringValue(record['model']) - const modelAlias = stringValue(record['modelAlias']) - const turnId = turnIdFromStep(record['turnStep']) - if (model && modelAlias) aliasModels.set(modelAlias, model) - if (turnId && currentPrompt) prompts.set(turnId, currentPrompt) - currentRequest = { - model, - modelAlias, - turnId, - timestamp: timestampIso(record['time']), - } - continue - } - - if (type === 'context.append_loop_event') { - const tool = toolDetails(record['event']) - if (tool) { - pendingTools.push(tool.name) - pendingBashCommands.push(...tool.bashCommands) - } - continue - } - - if (type !== 'usage.record') continue - const usage = asObject(record['usage']) - if (!usage) continue - - const usageAlias = stringValue(record['model']) - const realModel = aliasModels.get(usageAlias) ?? (currentRequest?.model || 'kimicode-unknown') - const turnId = currentRequest?.turnId || '' - const inputTokens = nonNegativeNumber(usage['inputOther']) - const outputTokens = nonNegativeNumber(usage['output']) - const cacheReadInputTokens = nonNegativeNumber(usage['inputCacheRead']) - const cacheCreationInputTokens = nonNegativeNumber(usage['inputCacheCreation']) - const timestamp = timestampIso(record['time']) || currentRequest?.timestamp || fallbackTimestamp - if (!timestamp) { - pendingTools = [] - pendingBashCommands = [] - continue - } - - const deduplicationKey = `kimicode:${sessionId}:${agentId}:${lineIndex + 1}:${usageOrdinal}` - usageOrdinal++ - if (seenKeys.has(deduplicationKey)) { - pendingTools = [] - pendingBashCommands = [] - continue - } - seenKeys.add(deduplicationKey) +// Host-derived scalars the pure core decode needs, packed alongside the wire +// lines so they cross the bridge's fixed decode signature (same pattern as +// droid). The session-state timestamps feed the decoder's last-resort timestamp +// fallback; sessionId/agentId feed the dedup key; project/projectPath are stamped +// onto each call. +type KimicodeMeta = { + sessionId: string + agentId: string + project: string + projectPath?: string + stateUpdatedAt?: string + stateCreatedAt?: string +} +type KimicodePacked = { meta: KimicodeMeta; records: unknown[] } - yield { - provider: 'kimicode', - model: realModel, - inputTokens, - outputTokens, - cacheCreationInputTokens, - cacheReadInputTokens, - cachedInputTokens: cacheReadInputTokens, - reasoningTokens: 0, - webSearchRequests: 0, - costBasis: 'estimated', - costIsEstimated: true, - tools: pendingTools, - bashCommands: pendingBashCommands, - timestamp, - speed: 'standard', - deduplicationKey, - turnId: turnId || undefined, - userMessage: prompts.get(turnId) ?? currentPrompt, - sessionId, - project: source.project, - projectPath, - } +async function readRecords(source: SessionSource): Promise { + let contents: string + try { + contents = await readFile(source.path, 'utf8') + } catch { + return null + } - pendingTools = [] - pendingBashCommands = [] - } + const sessionDir = sessionDirForWire(source.path) + const sessionId = sessionIdForWire(source.path) + const agentId = source.sourceId || agentIdForWire(source.path) + const state = await readState(sessionDir) + const projectPath = state.workDir || source.sourcePath + + // Preserve legacy line indexing: the dedup key embeds `lineIndex + 1` over the + // raw \r?\n split (blank lines included), so do NOT filter here. + const lines = contents.split(/\r?\n/) + const packed: KimicodePacked = { + meta: { + sessionId, + agentId, + project: source.project ?? '', + projectPath, + stateUpdatedAt: state.updatedAt, + stateCreatedAt: state.createdAt, }, + records: lines, } + return [packed] +} + +function decode(input: { records: unknown[]; context: DecodeContext; seenKeys: Set }): { calls: KimicodeDecodedCall[] } { + const packed = input.records[0] as KimicodePacked | undefined + if (!packed) return { calls: [] } + return decodeKimicode({ + records: packed.records, + context: input.context, + seenKeys: input.seenKeys, + sessionId: packed.meta.sessionId, + agentId: packed.meta.agentId, + project: packed.meta.project, + projectPath: packed.meta.projectPath, + stateUpdatedAt: packed.meta.stateUpdatedAt, + stateCreatedAt: packed.meta.stateCreatedAt, + }) } export function createKimicodeProvider(homeOverride?: string): Provider { - return { + return createBridgedProvider({ name: 'kimicode', displayName: 'Kimi Code', @@ -345,7 +207,7 @@ export function createKimicodeProvider(homeOverride?: string): Provider { }, toolDisplayName(rawTool: string): string { - return toolNameMap[rawTool] ?? rawTool + return kimicodeToolNameMap[rawTool] ?? rawTool }, async probeRoots(): Promise { @@ -360,10 +222,13 @@ export function createKimicodeProvider(homeOverride?: string): Provider { return all.sort((a, b) => a.path.localeCompare(b.path)) }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createParser(source, seenKeys) + async readRecords(source: SessionSource): Promise { + return readRecords(source) }, - } + + decode, + toProviderCall, + }) } export const kimicode = createKimicodeProvider() diff --git a/packages/cli/src/providers/pi.ts b/packages/cli/src/providers/pi.ts index c71c9334..158253c0 100644 --- a/packages/cli/src/providers/pi.ts +++ b/packages/cli/src/providers/pi.ts @@ -2,83 +2,19 @@ import { readdir, stat } from 'fs/promises' import { basename, join } from 'path' import { homedir } from 'os' +import { decodePi } from '@codeburn/core/providers/pi' +import type { PiDecodedCall } from '@codeburn/core/providers/pi' import { readSessionFile } from '../fs-utils.js' import { extractBashCommands } from '../bash-utils.js' -import { normalizeContentBlocks } from '../content-utils.js' -import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' - -const modelDisplayNames: Record = { - 'gpt-5.4': 'GPT-5.4', - 'gpt-5.4-mini': 'GPT-5.4 Mini', - 'gpt-5.5': 'GPT-5.5', - 'gpt-5': 'GPT-5', - 'gpt-4o': 'GPT-4o', - 'gpt-4o-mini': 'GPT-4o Mini', -} - -const toolNameMap: Record = { - bash: 'Bash', - read: 'Read', - edit: 'Edit', - write: 'Write', - glob: 'Glob', - grep: 'Grep', - task: 'Agent', - dispatch_agent: 'Agent', - fetch: 'WebFetch', - search: 'WebSearch', - todo: 'TodoWrite', - patch: 'Patch', -} - -// Pre-sorted by key length descending so longer/more-specific keys match first -const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length) - -// Pi/OMP have no dedicated skill tool the way Claude Code does. A native skill -// load is emitted as an ordinary `read` tool call whose path points at the -// skill's `SKILL.md` (Pi resolves skills from many roots: ~/.pi/agent/skills, -// project .pi/skills, .agents/skills, package skills/, --skill ), or, in -// newer OMP builds, at a `skill://` URI. Left untouched these inflate the -// Read tool count and leave the Skills dimension empty (issue #588). Return the -// skill name when a read is really a skill load, else null so it stays a Read. -function skillLoadName(name: string | undefined, args: Record | undefined): string | null { - if (name !== 'read') return null - const raw = args?.['path'] ?? args?.['file_path'] - if (typeof raw !== 'string') return null - const path = raw.trim() - if (path.length === 0) return null - - if (path.startsWith('skill://')) { - const rest = path.slice('skill://'.length).replace(/^\/+/, '') - const first = rest.split(/[/?#]/)[0]?.trim() ?? '' - return first.length > 0 ? first : null - } - - // Match on the SKILL.md basename, not a directory prefix, because skill roots - // live in many locations. Split on both separators so Windows paths work. - const segments = path.split(/[\\/]/).filter(Boolean) - if (segments[segments.length - 1] !== 'SKILL.md') return null - const parent = segments[segments.length - 2]?.trim() - return parent && parent.length > 0 ? parent : null -} +import { createBridgedProvider } from './bridge.js' +import type { Provider, SessionSource, ParsedProviderCall } from './types.js' type PiEntry = { type: string id?: string timestamp?: string cwd?: string - message?: { - role?: string - content?: Array<{ type?: string; text?: string; name?: string; arguments?: Record }> | string - model?: string - responseId?: string - usage?: { - input: number - output: number - cacheRead: number - cacheWrite: number - } - } + message?: unknown } function getPiSessionsDir(override?: string): string { @@ -140,132 +76,75 @@ async function discoverSessionsInDir(sessionsDir: string, providerName: string): return sources } -function createParser(source: SessionSource, seenKeys: Set): SessionParser { +function toProviderCall(rich: PiDecodedCall): ParsedProviderCall { return { - async *parse(): AsyncGenerator { - const content = await readSessionFile(source.path) - if (content === null) return - const lines = content.split('\n').filter(l => l.trim()) - let sessionId = basename(source.path, '.jsonl') - let pendingUserMessage = '' - - for (const [lineIdx, line] of lines.entries()) { - let entry: PiEntry - try { - entry = JSON.parse(line) as PiEntry - } catch { - continue - } - - if (entry.type === 'session') { - sessionId = entry.id ?? sessionId - continue - } - - if (entry.type !== 'message') continue - - const msg = entry.message - if (!msg) continue - - if (msg.role === 'user') { - const texts = normalizeContentBlocks(msg.content) - .filter(c => c.type === 'text') - .map(c => c.text ?? '') - .filter(Boolean) - if (texts.length > 0) pendingUserMessage = texts.join(' ') - continue - } - - if (msg.role !== 'assistant' || !msg.usage) continue - - // Coerce undefined/null token fields to 0. Pi/OMP session files - // sometimes omit individual usage fields; the destructure used to - // pass undefined into calculateCost which then returned NaN, and - // that NaN propagated into every aggregate cost total. - const input = msg.usage.input ?? 0 - const output = msg.usage.output ?? 0 - const cacheRead = msg.usage.cacheRead ?? 0 - const cacheWrite = msg.usage.cacheWrite ?? 0 - if (input === 0 && output === 0) continue - - const model = msg.model ?? 'gpt-5' - const responseId = msg.responseId ?? '' - const dedupKey = `${source.provider}:${source.path}:${responseId || entry.id || entry.timestamp || String(lineIdx)}` - - if (seenKeys.has(dedupKey)) continue - seenKeys.add(dedupKey) - - const toolCalls = normalizeContentBlocks(msg.content).filter(c => c.type === 'toolCall' && c.name) - - // A SKILL.md-loading read is surfaced as the `Skill` tool (not `Read`) - // and its name is recorded in `skills`. This mirrors how the Claude - // parser represents a skill invocation, so the shared classifier tags - // the turn `general` and the "Skills & Agents" breakdown picks it up, - // instead of over-counting a Read and leaving Skills empty (#588). - // Every other call stays a normal tool. - const tools: string[] = [] - const skills: string[] = [] - for (const c of toolCalls) { - const skill = skillLoadName(c.name, c.arguments) - if (skill !== null) { - skills.push(skill) - tools.push('Skill') - continue - } - tools.push(toolNameMap[c.name!] ?? c.name!) - } - - const bashCommands = toolCalls - .filter(c => c.name === 'bash') - .flatMap(c => { - const cmd = c.arguments?.['command'] - return typeof cmd === 'string' ? extractBashCommands(cmd) : [] - }) - - const timestamp = entry.timestamp ?? '' - - yield { - provider: source.provider, - model, - inputTokens: input, - outputTokens: output, - cacheCreationInputTokens: cacheWrite, - cacheReadInputTokens: cacheRead, - cachedInputTokens: cacheRead, - reasoningTokens: 0, - webSearchRequests: 0, - costBasis: 'estimated', - tools, - bashCommands, - skills, - timestamp, - speed: 'standard', - deduplicationKey: dedupKey, - userMessage: pendingUserMessage, - sessionId, - } - - pendingUserMessage = '' - } - }, + provider: rich.provider, + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + costBasis: 'estimated', + tools: rich.tools, + // Pi/OMP's legacy decode did NOT dedup bash commands: it flat-mapped every + // extracted base name into a flat list. Preserve that (no Set). + bashCommands: rich.rawBashCommands.flatMap(c => extractBashCommands(c)), + skills: rich.skills ?? [], + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + userMessage: rich.userMessage, + sessionId: rich.sessionId, } } +async function readRecords(source: SessionSource): Promise { + const content = await readSessionFile(source.path) + if (content === null) return null + return content.split('\n').filter(l => l.trim()) +} + export function createPiProvider(sessionsDir?: string): Provider { const dir = getPiSessionsDir(sessionsDir) - return { + return createBridgedProvider({ name: 'pi', displayName: 'Pi', modelDisplayName(model: string): string { - for (const [key, name] of modelDisplayEntries) { + const displayNames: Record = { + 'gpt-5.4': 'GPT-5.4', + 'gpt-5.4-mini': 'GPT-5.4 Mini', + 'gpt-5.5': 'GPT-5.5', + 'gpt-5': 'GPT-5', + 'gpt-4o': 'GPT-4o', + 'gpt-4o-mini': 'GPT-4o Mini', + } + const entries = Object.entries(displayNames).sort((a, b) => b[0].length - a[0].length) + for (const [key, name] of entries) { if (model.startsWith(key)) return name } return model }, toolDisplayName(rawTool: string): string { + const toolNameMap: Record = { + bash: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + dispatch_agent: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + patch: 'Patch', + } return toolNameMap[rawTool] ?? rawTool }, @@ -273,10 +152,15 @@ export function createPiProvider(sessionsDir?: string): Provider { return discoverSessionsInDir(dir, 'pi') }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createParser(source, seenKeys) + async readRecords(source: SessionSource): Promise { + return readRecords(source) }, - } + + // Pi vs OMP is disambiguated inside the decoder via context.providerId, which + // the bridge sets from spec.name ('pi' here) — no wrapper needed. + decode: decodePi, + toProviderCall, + }) } export const pi = createPiProvider() @@ -284,18 +168,41 @@ export const pi = createPiProvider() export function createOmpProvider(sessionsDir?: string): Provider { const dir = getOmpSessionsDir(sessionsDir) - return { + return createBridgedProvider({ name: 'omp', displayName: 'OMP', modelDisplayName(model: string): string { - for (const [key, name] of modelDisplayEntries) { + const displayNames: Record = { + 'gpt-5.4': 'GPT-5.4', + 'gpt-5.4-mini': 'GPT-5.4 Mini', + 'gpt-5.5': 'GPT-5.5', + 'gpt-5': 'GPT-5', + 'gpt-4o': 'GPT-4o', + 'gpt-4o-mini': 'GPT-4o Mini', + } + const entries = Object.entries(displayNames).sort((a, b) => b[0].length - a[0].length) + for (const [key, name] of entries) { if (model.startsWith(key)) return name } return model }, toolDisplayName(rawTool: string): string { + const toolNameMap: Record = { + bash: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + dispatch_agent: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + patch: 'Patch', + } return toolNameMap[rawTool] ?? rawTool }, @@ -303,10 +210,15 @@ export function createOmpProvider(sessionsDir?: string): Provider { return discoverSessionsInDir(dir, 'omp') }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createParser(source, seenKeys) + async readRecords(source: SessionSource): Promise { + return readRecords(source) }, - } + + // context.providerId is 'omp' here (from spec.name), so the shared decoder + // stamps provider: 'omp' and the omp dedup-key prefix. + decode: decodePi, + toProviderCall, + }) } export const omp = createOmpProvider() diff --git a/packages/cli/tests/fixtures/gemini-parity/jsonl.jsonl b/packages/cli/tests/fixtures/gemini-parity/jsonl.jsonl new file mode 100644 index 00000000..3127c479 --- /dev/null +++ b/packages/cli/tests/fixtures/gemini-parity/jsonl.jsonl @@ -0,0 +1,4 @@ +{"sessionId":"gsess-2","startTime":"2026-06-02T09:00:00.000Z","projectHash":"abc"} +{"$set":{"foo":1}} +{"id":"u1","type":"user","content":"hello"} +{"id":"g1","type":"gemini","model":"gemini-auto","content":"hi","tokens":{"input":50,"output":5}} diff --git a/packages/cli/tests/fixtures/gemini-parity/single.json b/packages/cli/tests/fixtures/gemini-parity/single.json new file mode 100644 index 00000000..4f97f26f --- /dev/null +++ b/packages/cli/tests/fixtures/gemini-parity/single.json @@ -0,0 +1,24 @@ +{ + "sessionId": "gsess-1", + "startTime": "2026-06-01T10:00:00.000Z", + "messages": [ + { "id": "u1", "type": "user", "timestamp": "2026-06-01T10:00:00.000Z", "content": "inspect the repo" }, + { + "id": "g1", + "type": "gemini", + "timestamp": "2026-06-01T10:00:05.000Z", + "model": "gemini-3.1-pro-preview", + "content": "working", + "tokens": { "input": 120, "cached": 20, "output": 30, "thoughts": 5 }, + "toolCalls": [ + { "id": "t1", "name": "read_file", "args": { "path": "src/index.ts" } }, + { "id": "t2", "name": "run_command", "args": { "command": "git status && git log" } }, + { "id": "t3", "name": "run_command", "args": { "command": "git diff" } }, + { "id": "t4", "name": "read_file", "args": { "path": "src/a.ts" } } + ] + }, + { "id": "g0", "type": "gemini", "timestamp": "2026-06-01T10:00:06.000Z", "model": "gemini-2.5-pro", "content": "zero", "tokens": { "input": 0, "output": 0, "cached": 0, "thoughts": 0 } }, + { "id": "u2", "type": "user", "timestamp": "2026-06-01T10:01:00.000Z", "content": [{ "text": "run tests" }] }, + { "id": "g2", "type": "gemini", "timestamp": "2026-06-01T10:01:05.000Z", "model": "gemini-2.5-flash", "content": "done", "tokens": { "output": 10 } } + ] +} diff --git a/packages/cli/tests/fixtures/kimicode-parity/home/sessions/wd_myproj_0123456789ab/session_S1/agents/agentA/wire.jsonl b/packages/cli/tests/fixtures/kimicode-parity/home/sessions/wd_myproj_0123456789ab/session_S1/agents/agentA/wire.jsonl new file mode 100644 index 00000000..21501958 --- /dev/null +++ b/packages/cli/tests/fixtures/kimicode-parity/home/sessions/wd_myproj_0123456789ab/session_S1/agents/agentA/wire.jsonl @@ -0,0 +1,13 @@ +{"type":"turn.prompt","input":[{"type":"text","text":"first prompt"}]} +{"type":"llm.request","model":"kimi-k2","modelAlias":"default","turnStep":"1.0","time":"2026-07-01T10:00:01.000Z"} +{"type":"context.append_loop_event","event":{"type":"tool.call","name":"Bash","args":{"command":"ls -la"}}} +{"type":"context.append_loop_event","event":{"type":"tool.call","name":"Bash","args":{"command":"ls -la"}}} +{"type":"context.append_loop_event","event":{"type":"tool.call","name":"Read","args":{}}} +{"type":"usage.record","model":"default","usage":{"inputOther":100,"output":40,"inputCacheRead":10,"inputCacheCreation":5},"time":"2026-07-01T10:00:02.000Z"} + +{"type":"turn.prompt","input":[{"type":"text","text":"second prompt"}]} +{"type":"llm.request","model":"kimi-k2-turbo","modelAlias":"fast","turnStep":"2.5","time":"2026-07-01T10:00:03.000Z"} +{"type":"usage.record","model":"fast","usage":{"inputOther":200,"output":80}} +{"type":"turn.prompt","input":[{"type":"text","text":"third prompt"}]} +{"type":"llm.request","model":"kimi-k2","modelAlias":"default","turnStep":"3.0"} +{"type":"usage.record","model":"default","usage":{"inputOther":5,"output":5}} diff --git a/packages/cli/tests/fixtures/kimicode-parity/home/sessions/wd_myproj_0123456789ab/session_S1/state.json b/packages/cli/tests/fixtures/kimicode-parity/home/sessions/wd_myproj_0123456789ab/session_S1/state.json new file mode 100644 index 00000000..08486654 --- /dev/null +++ b/packages/cli/tests/fixtures/kimicode-parity/home/sessions/wd_myproj_0123456789ab/session_S1/state.json @@ -0,0 +1,5 @@ +{ + "createdAt": "2026-07-01T10:00:00.000Z", + "updatedAt": "2026-07-01T10:05:00.000Z", + "workDir": "/work/myproj" +} diff --git a/packages/cli/tests/fixtures/pi-parity/omp-sessions/projO/ofile.jsonl b/packages/cli/tests/fixtures/pi-parity/omp-sessions/projO/ofile.jsonl new file mode 100644 index 00000000..ec20a8ac --- /dev/null +++ b/packages/cli/tests/fixtures/pi-parity/omp-sessions/projO/ofile.jsonl @@ -0,0 +1,2 @@ +{"type":"session","cwd":"/Users/test/projO"} +{"type":"message","timestamp":"2026-06-11T10:00:00.000Z","message":{"role":"assistant","model":"gpt-4o","responseId":"","usage":{"input":10,"output":0},"content":[]}} diff --git a/packages/cli/tests/fixtures/pi-parity/pi-sessions/proj1/sess-file.jsonl b/packages/cli/tests/fixtures/pi-parity/pi-sessions/proj1/sess-file.jsonl new file mode 100644 index 00000000..fe8e7390 --- /dev/null +++ b/packages/cli/tests/fixtures/pi-parity/pi-sessions/proj1/sess-file.jsonl @@ -0,0 +1,4 @@ +{"type":"session","id":"pi-sess-1","cwd":"/Users/test/proj1","timestamp":"2026-06-10T10:00:00.000Z"} +{"type":"message","id":"m1","timestamp":"2026-06-10T10:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"do stuff"}]}} +{"type":"message","id":"m2","timestamp":"2026-06-10T10:00:02.000Z","message":{"role":"assistant","model":"gpt-5.5","responseId":"resp-1","usage":{"input":100,"output":40,"cacheRead":10,"cacheWrite":5},"content":[{"type":"toolCall","name":"bash","arguments":{"command":"git status && git log"}},{"type":"toolCall","name":"bash","arguments":{"command":"git status && git log"}},{"type":"toolCall","name":"read","arguments":{"path":"/skills/my-skill/SKILL.md"}},{"type":"toolCall","name":"read","arguments":{"path":"skill://web-search/foo"}},{"type":"toolCall","name":"read","arguments":{"path":"src/index.ts"}},{"type":"toolCall","name":"task","arguments":{}}]}} +{"type":"message","id":"m3","timestamp":"2026-06-10T10:00:03.000Z","message":{"role":"assistant","model":"gpt-5.5","responseId":"resp-1","usage":{"input":1,"output":1},"content":[]}} diff --git a/packages/cli/tests/providers/gemini-bridge.test.ts b/packages/cli/tests/providers/gemini-bridge.test.ts new file mode 100644 index 00000000..70059655 --- /dev/null +++ b/packages/cli/tests/providers/gemini-bridge.test.ts @@ -0,0 +1,137 @@ +import { dirname, resolve } from 'path' +import { fileURLToPath } from 'url' + +import { describe, it, expect } from 'vitest' + +import { createGeminiProvider } from '../../src/providers/gemini.js' +import { priceProviderCall } from '../../src/pricing-pass.js' +import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' + +// Byte-identical parity gate for the gemini bridge migration (phase 8). The +// GOLDEN below was captured from the legacy in-CLI decode (git show +// origin/feat/core-extraction:packages/cli/src/providers/gemini.ts) run over the +// committed fixtures. Covers: single-JSON (<=0.38) vs headerless JSONL (>=0.39) +// parsing incl. the `$set` skip, cached-token subtraction from fresh input, the +// tool-name map with Set-dedup on BOTH tools and bash base names, the zero-token +// gemini-message skip, per-message dedup key, turn threading, and the timestamp +// fallback to the session startTime when a message omits its own timestamp. + +const here = dirname(fileURLToPath(import.meta.url)) +const FIXTURE_DIR = resolve(here, '../fixtures/gemini-parity') + +// Gemini discovery keys off ~/.gemini/tmp (no dir override), so the parity gate +// drives the parser directly over the committed fixture files — that is the +// read + decode + map path where every migration risk lives. +function sources(): SessionSource[] { + return [ + { path: resolve(FIXTURE_DIR, 'single.json'), project: 'proj', provider: 'gemini' }, + { path: resolve(FIXTURE_DIR, 'jsonl.jsonl'), project: 'proj', provider: 'gemini' }, + ].sort((a, b) => a.path.localeCompare(b.path)) +} + +const GOLDEN: ParsedProviderCall[] = [ + { + provider: 'gemini', + model: 'gemini-auto', + inputTokens: 50, + outputTokens: 5, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + tools: [], + bashCommands: [], + timestamp: '2026-06-02T09:00:00.000Z', + speed: 'standard', + deduplicationKey: 'gemini:gsess-2:g1', + turnId: 'gsess-2:turn-0', + userMessage: 'hello', + sessionId: 'gsess-2', + }, + { + provider: 'gemini', + model: 'gemini-3.1-pro-preview', + inputTokens: 100, + outputTokens: 30, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 20, + cachedInputTokens: 20, + reasoningTokens: 5, + webSearchRequests: 0, + costBasis: 'estimated', + // Set-deduped: [Read,Bash,Bash,Read] -> [Read,Bash]; git repeated across + // `git status && git log` + `git diff` -> single 'git'. + tools: ['Read', 'Bash'], + bashCommands: ['git'], + timestamp: '2026-06-01T10:00:05.000Z', + speed: 'standard', + deduplicationKey: 'gemini:gsess-1:g1', + turnId: 'gsess-1:turn-0', + userMessage: 'inspect the repo', + sessionId: 'gsess-1', + }, + { + provider: 'gemini', + model: 'gemini-2.5-flash', + inputTokens: 0, + outputTokens: 10, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + tools: [], + bashCommands: [], + timestamp: '2026-06-01T10:01:05.000Z', + speed: 'standard', + deduplicationKey: 'gemini:gsess-1:g2', + turnId: 'gsess-1:turn-1', + userMessage: 'run tests', + sessionId: 'gsess-1', + }, +] + +async function collect(): Promise { + const provider = createGeminiProvider() + const seen = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources()) { + for await (const call of provider.createSessionParser(source, seen).parse()) calls.push(call) + } + return calls +} + +describe('gemini bridge — fixture parity', () => { + it('the bridged provider reproduces the pre-migration decode byte-for-byte', async () => { + expect(await collect()).toEqual(GOLDEN) + }) + + it('the priced output survives the pricing pass with only costUSD added', async () => { + const raw = await collect() + const priced = raw.map(priceProviderCall) + priced.forEach((call, i) => { + expect(typeof call.costUSD).toBe('number') + expect(Number.isFinite(call.costUSD)).toBe(true) + const { costUSD, ...rest } = call + expect(rest).toEqual(raw[i]) + }) + }) + + it('dedup threads through the host-owned seenKeys set', async () => { + const provider = createGeminiProvider() + const seen = new Set() + const first: ParsedProviderCall[] = [] + for (const source of sources()) { + for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call) + } + const second: ParsedProviderCall[] = [] + for (const source of sources()) { + for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call) + } + expect(first).toHaveLength(3) + expect(second).toEqual([]) + }) +}) diff --git a/packages/cli/tests/providers/kimicode-bridge.test.ts b/packages/cli/tests/providers/kimicode-bridge.test.ts new file mode 100644 index 00000000..43b19f8e --- /dev/null +++ b/packages/cli/tests/providers/kimicode-bridge.test.ts @@ -0,0 +1,139 @@ +import { dirname, resolve } from 'path' +import { fileURLToPath } from 'url' + +import { describe, it, expect } from 'vitest' + +import { createKimicodeProvider } from '../../src/providers/kimicode.js' +import { priceProviderCall } from '../../src/pricing-pass.js' +import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' + +// Byte-identical parity gate for the kimicode bridge migration (phase 8). The +// GOLDEN below was captured from the legacy in-CLI decode (git show +// origin/feat/core-extraction:packages/cli/src/providers/kimicode.ts) run over the +// committed fixture home. Covers: model-alias resolution (usage.model -> real +// model from an earlier llm.request), the `kimicode:::: +// ` dedup key whose lineIndex counts BLANK lines (fixture line 7 is +// blank, shifting call 2 to line 10), tools/bash carried as FLAT un-deduped lists +// (two identical `ls -la` Bash calls stay two `ls`), and the three-tier timestamp +// fallback — record.time, then request.time, then state.json updatedAt (call 3 +// omits both and resolves to the state's 10:05:00 updatedAt). + +const here = dirname(fileURLToPath(import.meta.url)) +const FIXTURE_HOME = resolve(here, '../fixtures/kimicode-parity/home') + +async function collect(): Promise { + const provider = createKimicodeProvider(FIXTURE_HOME) + const sources: SessionSource[] = await provider.discoverSessions() + const seen = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) calls.push(call) + } + return calls +} + +const GOLDEN: ParsedProviderCall[] = [ + { + provider: 'kimicode', + model: 'kimi-k2', + inputTokens: 100, + outputTokens: 40, + cacheCreationInputTokens: 5, + cacheReadInputTokens: 10, + cachedInputTokens: 10, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + tools: ['Bash', 'Bash', 'Read'], + bashCommands: ['ls', 'ls'], + timestamp: '2026-07-01T10:00:02.000Z', + speed: 'standard', + deduplicationKey: 'kimicode:S1:agentA:6:0', + turnId: '1', + userMessage: 'first prompt', + sessionId: 'S1', + project: 'myproj', + projectPath: '/work/myproj', + }, + { + provider: 'kimicode', + model: 'kimi-k2-turbo', + inputTokens: 200, + outputTokens: 80, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + tools: [], + bashCommands: [], + timestamp: '2026-07-01T10:00:03.000Z', + speed: 'standard', + deduplicationKey: 'kimicode:S1:agentA:10:1', + turnId: '2', + userMessage: 'second prompt', + sessionId: 'S1', + project: 'myproj', + projectPath: '/work/myproj', + }, + { + provider: 'kimicode', + model: 'kimi-k2', + inputTokens: 5, + outputTokens: 5, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + tools: [], + bashCommands: [], + // record.time and request.time both absent -> state.json updatedAt. + timestamp: '2026-07-01T10:05:00.000Z', + speed: 'standard', + deduplicationKey: 'kimicode:S1:agentA:13:2', + turnId: '3', + userMessage: 'third prompt', + sessionId: 'S1', + project: 'myproj', + projectPath: '/work/myproj', + }, +] + +describe('kimicode bridge — fixture parity', () => { + it('the bridged provider reproduces the pre-migration decode byte-for-byte', async () => { + expect(await collect()).toEqual(GOLDEN) + }) + + it('the priced output survives the pricing pass with only costUSD added', async () => { + const raw = await collect() + const priced = raw.map(priceProviderCall) + priced.forEach((call, i) => { + expect(typeof call.costUSD).toBe('number') + expect(Number.isFinite(call.costUSD)).toBe(true) + const { costUSD, ...rest } = call + expect(rest).toEqual(raw[i]) + }) + }) + + it('dedup threads through the host-owned seenKeys set', async () => { + const provider = createKimicodeProvider(FIXTURE_HOME) + const sources = await provider.discoverSessions() + const seen = new Set() + const first: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call) + } + const second: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call) + } + expect(first).toHaveLength(3) + expect(second).toEqual([]) + }) +}) diff --git a/packages/cli/tests/providers/pi-bridge.test.ts b/packages/cli/tests/providers/pi-bridge.test.ts new file mode 100644 index 00000000..af932955 --- /dev/null +++ b/packages/cli/tests/providers/pi-bridge.test.ts @@ -0,0 +1,127 @@ +import { dirname, resolve } from 'path' +import { fileURLToPath } from 'url' + +import { describe, it, expect } from 'vitest' + +import { createPiProvider, createOmpProvider } from '../../src/providers/pi.js' +import { priceProviderCall } from '../../src/pricing-pass.js' +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 +// 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 +// their names in `skills`; FLAT un-deduped tools/bash (the repeated `git` base +// name appears four times); and the responseId dedup skip (fixture line 4 repeats +// resp-1). + +const here = dirname(fileURLToPath(import.meta.url)) +const PI_DIR = resolve(here, '../fixtures/pi-parity/pi-sessions') +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') + +async function collect(provider: { + discoverSessions: () => Promise + createSessionParser: (s: SessionSource, seen: Set) => { parse: () => AsyncGenerator } +}): Promise { + const sources = await provider.discoverSessions() + sources.sort((a, b) => a.path.localeCompare(b.path)) + const seen = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) calls.push(call) + } + return calls +} + +const PI_GOLDEN: ParsedProviderCall[] = [ + { + provider: 'pi', + model: 'gpt-5.5', + inputTokens: 100, + outputTokens: 40, + cacheCreationInputTokens: 5, + cacheReadInputTokens: 10, + cachedInputTokens: 10, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + tools: ['Bash', 'Bash', 'Skill', 'Skill', 'Read', 'Agent'], + bashCommands: ['git', 'git', 'git', 'git'], + skills: ['my-skill', 'web-search'], + timestamp: '2026-06-10T10:00:02.000Z', + speed: 'standard', + deduplicationKey: `pi:${PI_PATH}:resp-1`, + userMessage: 'do stuff', + sessionId: 'pi-sess-1', + }, +] + +const OMP_GOLDEN: ParsedProviderCall[] = [ + { + provider: 'omp', + model: 'gpt-4o', + inputTokens: 10, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + tools: [], + bashCommands: [], + skills: [], + 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`, + userMessage: '', + sessionId: 'ofile', + }, +] + +describe('pi/omp bridge — fixture parity', () => { + it('pi reproduces the pre-migration decode byte-for-byte', async () => { + expect(await collect(createPiProvider(PI_DIR))).toEqual(PI_GOLDEN) + }) + + it('omp reuses the same decode with the omp provider id and path fallback', async () => { + expect(await collect(createOmpProvider(OMP_DIR))).toEqual(OMP_GOLDEN) + }) + + it('the priced output survives the pricing pass with only costUSD added', async () => { + const raw = await collect(createPiProvider(PI_DIR)) + const priced = raw.map(priceProviderCall) + priced.forEach((call, i) => { + expect(typeof call.costUSD).toBe('number') + expect(Number.isFinite(call.costUSD)).toBe(true) + const { costUSD, ...rest } = call + expect(rest).toEqual(raw[i]) + }) + }) + + it('dedup threads through the host-owned seenKeys set', async () => { + const provider = createPiProvider(PI_DIR) + const sources = await provider.discoverSessions() + const seen = new Set() + const first: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call) + } + const second: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call) + } + expect(first).toHaveLength(1) + expect(second).toEqual([]) + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 37fd5ebc..5f0134b5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -78,6 +78,18 @@ "./providers/lingtai-tui": { "types": "./dist/providers/lingtai-tui/index.d.ts", "import": "./dist/providers/lingtai-tui/index.js" + }, + "./providers/gemini": { + "types": "./dist/providers/gemini/index.d.ts", + "import": "./dist/providers/gemini/index.js" + }, + "./providers/kimicode": { + "types": "./dist/providers/kimicode/index.d.ts", + "import": "./dist/providers/kimicode/index.js" + }, + "./providers/pi": { + "types": "./dist/providers/pi/index.d.ts", + "import": "./dist/providers/pi/index.js" } }, "files": [ diff --git a/packages/core/src/providers/gemini/decode.ts b/packages/core/src/providers/gemini/decode.ts new file mode 100644 index 00000000..402ff737 --- /dev/null +++ b/packages/core/src/providers/gemini/decode.ts @@ -0,0 +1,181 @@ +// @codeburn/core Gemini decoder: pure decode over a host-supplied session. +// The host reads the JSON/JSONL file; this decoder extracts token buckets, +// tool calls, and user message threading with no fs, env, clock, or pricing. + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { GeminiDecodedCall, GeminiMessage, GeminiSession } from './types.js' + +// Gemini tool ids mapped to the canonical vocabulary. Unknown ids pass through. +export const geminiToolNameMap: Record = { + read_file: 'Read', + write_file: 'Write', + edit_file: 'Edit', + create_file: 'Write', + delete_file: 'Delete', + list_dir: 'LS', + grep_search: 'Grep', + search_files: 'Grep', + find_files: 'Glob', + run_command: 'Bash', + web_search: 'WebSearch', + ReadFile: 'Read', + WriteFile: 'Write', + EditFile: 'Edit', + ListDir: 'LS', + SearchText: 'Grep', + Shell: 'Bash', +} + +// Reconstruct a session from headerless JSONL lines (Gemini CLI >=0.39): a +// header line carrying sessionId/startTime, plus one line per message. Moved +// verbatim from the CLI's `parseJsonl`. +function parseGeminiJsonl(raw: string): GeminiSession | null { + const lines = raw.split('\n').filter(l => l.trim()) + if (lines.length === 0) return null + + let sessionId = '' + let startTime = '' + let projectHash: string | undefined + let lastUpdated: string | undefined + let kind: string | undefined + const messages: GeminiMessage[] = [] + + for (const line of lines) { + let obj: Record + try { + obj = JSON.parse(line) + } catch { + continue + } + if (obj['$set'] !== undefined) continue + if (obj['sessionId'] && obj['startTime'] && !sessionId) { + sessionId = obj['sessionId'] as string + startTime = obj['startTime'] as string + projectHash = obj['projectHash'] as string | undefined + lastUpdated = obj['lastUpdated'] as string | undefined + kind = obj['kind'] as string | undefined + } else if (obj['id'] && obj['type']) { + messages.push(obj as unknown as GeminiMessage) + } + } + + if (!sessionId) return null + return { sessionId, projectHash, startTime, lastUpdated, kind, messages } +} + +// Try single JSON first (Gemini CLI <=0.38), then JSONL (>=0.39). Moved +// verbatim from the CLI's `createParser`. +function parseGeminiRaw(raw: string): GeminiSession | null { + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && parsed.messages && parsed.sessionId) { + return parsed as GeminiSession + } + } catch { /* not single JSON */ } + + return parseGeminiJsonl(raw) +} + +export type GeminiDecodeInput = { + // records[0] is the full raw text of one session file (single JSON object or + // headerless JSONL); the host does no parsing, only the read. + records: unknown[] + context: DecodeContext + seenKeys?: Set +} + +export type GeminiDecodeResult = { + calls: GeminiDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +/** + * Decode a Gemini session (single JSON or JSONL) into rich, cost-free calls. + * User messages set pending prompt; Gemini messages with token usage flush into + * calls. Dedup is keyed on `gemini::` against live seenKeys. + */ +export function decodeGemini({ records, seenKeys: liveSeen }: GeminiDecodeInput): GeminiDecodeResult { + const seen = liveSeen ?? new Set() + const calls: GeminiDecodedCall[] = [] + const diagnostics: RecordDiagnostic[] = [] + + const raw = records[0] + const data = typeof raw === 'string' ? parseGeminiRaw(raw) : null + if (!data?.messages || !data.sessionId) return { calls, diagnostics } + + let lastUserMessage = '' + let turnOrdinal = 0 + let currentTurnId = `${data.sessionId}:prelude` + let geminiOrdinal = 0 + + for (const msg of data.messages) { + if (msg.type === 'user') { + if (Array.isArray(msg.content)) { + lastUserMessage = msg.content.map(c => c.text).join(' ').slice(0, 500) + } else if (typeof msg.content === 'string') { + lastUserMessage = msg.content.slice(0, 500) + } + currentTurnId = `${data.sessionId}:turn-${turnOrdinal++}` + continue + } + + if (msg.type !== 'gemini' || !msg.tokens || !msg.model) continue + + const t = msg.tokens + const totalInput = t.input ?? 0 + const totalOutput = t.output ?? 0 + const totalCached = t.cached ?? 0 + const totalThoughts = t.thoughts ?? 0 + if (totalInput === 0 && totalOutput === 0 && totalCached === 0 && totalThoughts === 0) continue + + const messageKey = msg.id || `idx-${geminiOrdinal}` + geminiOrdinal++ + const dedupKey = `gemini:${data.sessionId}:${messageKey}` + if (seen.has(dedupKey)) continue + + const tools: string[] = [] + const bashCommands: string[] = [] + + if (msg.toolCalls) { + for (const tc of msg.toolCalls) { + const mapped = geminiToolNameMap[tc.displayName ?? ''] ?? geminiToolNameMap[tc.name] ?? tc.displayName ?? tc.name + tools.push(mapped) + if (mapped === 'Bash' && tc.args && typeof tc.args.command === 'string') { + bashCommands.push(tc.args.command) + } + } + } + + // Gemini's `input` count includes `cached` tokens as a subset, so fresh + // input must subtract cached to avoid double-charging at both rates. + const freshInput = Math.max(0, totalInput - totalCached) + + const tsDate = new Date(msg.timestamp || data.startTime) + if (isNaN(tsDate.getTime()) || tsDate.getTime() < 1_000_000_000_000) continue + + seen.add(dedupKey) + + calls.push({ + provider: 'gemini', + model: msg.model, + inputTokens: freshInput, + outputTokens: totalOutput, + cacheCreationInputTokens: 0, + cacheReadInputTokens: totalCached, + cachedInputTokens: totalCached, + reasoningTokens: totalThoughts, + webSearchRequests: 0, + tools: [...new Set(tools)], + rawBashCommands: bashCommands, + timestamp: tsDate.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + turnId: currentTurnId, + userMessage: lastUserMessage, + sessionId: data.sessionId, + }) + } + + return { calls, diagnostics } +} diff --git a/packages/core/src/providers/gemini/index.ts b/packages/core/src/providers/gemini/index.ts new file mode 100644 index 00000000..978db0f8 --- /dev/null +++ b/packages/core/src/providers/gemini/index.ts @@ -0,0 +1,5 @@ +export { decodeGemini, geminiToolNameMap } from './decode.js' +export type { GeminiDecodeInput, GeminiDecodeResult } from './decode.js' +export { toObservations } from './observations.js' +export type { GeminiToObservationsContext, RichGeminiSessionDecode } from './observations.js' +export type { GeminiSession, GeminiMessage, GeminiTokens, GeminiToolCall, GeminiDecodedCall } from './types.js' diff --git a/packages/core/src/providers/gemini/observations.ts b/packages/core/src/providers/gemini/observations.ts new file mode 100644 index 00000000..744fa11d --- /dev/null +++ b/packages/core/src/providers/gemini/observations.ts @@ -0,0 +1,87 @@ +// Minimizing transform: rich Gemini decode -> the strict observation envelope. +// Only opaque ids, fingerprints, enums, numbers, timestamps, and CANONICAL tool +// names cross into the output — never the user message, project path, shell +// command, or file path. + +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' + +/** One Gemini session's rich decode, as the host holds it before minimization. */ +export interface RichGeminiSessionDecode { + sessionId: string + /** Absolute project path (the session cwd); fingerprinted, never emitted raw. */ + projectPath: string + /** Rich, cost-free calls in decode order (as decodeGemini emits them). */ + calls: GeminiDecodedCall[] +} + +export interface GeminiToObservationsContext { + /** HMAC key that scopes every fingerprint. */ + privacyKey: string + /** Provider id stamped onto sessions/calls and folded into sessionRef. */ + provider?: string +} + +// Canonical tool-name charset, mirroring core's CanonicalToolName schema. +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, + tokens: { + input: call.inputTokens, + output: call.outputTokens, + reasoning: call.reasoningTokens, + cacheRead: call.cacheReadInputTokens, + cacheCreate: call.cacheCreationInputTokens, + }, + webSearchRequests: call.webSearchRequests, + speed: call.speed, + costBasis: 'estimated', + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + } +} + +function toSessionObservation(decode: RichGeminiSessionDecode, ctx: GeminiToObservationsContext): SessionObservation { + const provider = ctx.provider ?? 'gemini' + 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 Gemini decode (one or many sessions) 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: RichGeminiSessionDecode | RichGeminiSessionDecode[], + ctx: GeminiToObservationsContext, +): { 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/gemini/types.ts b/packages/core/src/providers/gemini/types.ts new file mode 100644 index 00000000..e246e813 --- /dev/null +++ b/packages/core/src/providers/gemini/types.ts @@ -0,0 +1,64 @@ +/** + * Gemini session record types (moved from CLI, verbatim) + rich decoded call. + */ + +export type GeminiTokens = { + input?: number + output?: number + cached?: number + thoughts?: number + tool?: number + total?: number +} + +export type GeminiToolCall = { + id: string + name: string + args: Record + status?: string + displayName?: string +} + +export type GeminiMessage = { + id: string + timestamp: string + type: 'user' | 'gemini' | 'info' + content: string | Array<{ text: string }> + tokens?: GeminiTokens + model?: string + toolCalls?: GeminiToolCall[] + thoughts?: unknown[] +} + +export type GeminiSession = { + sessionId: string + projectHash?: string + startTime: string + lastUpdated?: string + messages: GeminiMessage[] + kind?: string +} + +/** + * Rich decoded call: token buckets + tool list + raw bash commands, no pricing, + * no bash base-name extraction (that's host-side). + */ +export type GeminiDecodedCall = { + provider: 'gemini' + model: string + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + tools: string[] + rawBashCommands: string[] + timestamp: string + speed: 'standard' + deduplicationKey: string + userMessage: string + sessionId: string + turnId?: string +} diff --git a/packages/core/src/providers/kimicode/decode.ts b/packages/core/src/providers/kimicode/decode.ts new file mode 100644 index 00000000..66920ae8 --- /dev/null +++ b/packages/core/src/providers/kimicode/decode.ts @@ -0,0 +1,249 @@ +// @codeburn/core Kimicode decoder: pure decode over host-supplied JSONL records. +// The host reads wire.jsonl; this decoder extracts token buckets, tool calls, +// and user message threading with no fs, env, clock, or pricing. + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { KimicodeDecodedCall, JsonObject, RequestContext } from './types.js' + +// Kimicode tool ids mapped to the canonical vocabulary. Unknown ids pass through. +export const kimicodeToolNameMap: Record = { + Bash: 'Bash', + Shell: 'Bash', + bash: 'Bash', + shell: 'Bash', + Read: 'Read', + ReadFile: 'Read', + read_file: 'Read', + Write: 'Write', + WriteFile: 'Write', + write_file: 'Write', + Edit: 'Edit', + EditFile: 'Edit', + edit_file: 'Edit', + Grep: 'Grep', + grep: 'Grep', + Glob: 'Glob', + glob: 'Glob', + Agent: 'Agent', + Task: 'Agent', +} + +function asObject(value: unknown): JsonObject | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as JsonObject + : null +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function nonNegativeNumber(value: unknown): number { + const number = typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() ? Number(value) : NaN + return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : 0 +} + +function timestampIso(value: unknown): string { + if (typeof value === 'string') { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? '' : date.toISOString() + } + if (typeof value !== 'number' || !Number.isFinite(value)) return '' + const milliseconds = value > 1_000_000_000_000 ? value : value * 1000 + const date = new Date(milliseconds) + return Number.isNaN(date.getTime()) ? '' : date.toISOString() +} + +function turnIdFromStep(value: unknown): string { + const turnStep = stringValue(value) + if (!turnStep) return '' + return turnStep.split('.', 1)[0] ?? '' +} + +function inputText(value: unknown): string { + if (typeof value === 'string') return value + if (!Array.isArray(value)) return '' + return value + .map(part => { + const record = asObject(part) + return record?.['type'] === 'text' ? stringValue(record['text']) : '' + }) + .filter(Boolean) + .join('\n') +} + +function toolDetails(value: unknown): { name: string; bashCommands: string[] } | null { + const event = asObject(value) + if (!event || stringValue(event['type']) !== 'tool.call') return null + const rawName = stringValue(event['name']) + if (!rawName) return null + const name = kimicodeToolNameMap[rawName] ?? rawName + + let args = asObject(event['args']) + if (!args && typeof event['args'] === 'string') { + try { + args = asObject(JSON.parse(event['args'])) + } catch { + args = null + } + } + const command = stringValue(args?.['command']) + return { + name, + bashCommands: name === 'Bash' && command ? [command] : [], + } +} + +export type KimicodeDecodeInput = { + records: unknown[] + context: DecodeContext + seenKeys?: Set + // Extra context passed by the host when splitting the raw wire.jsonl + sessionId?: string + agentId?: string + project?: string + projectPath?: string + // Session state.json's updatedAt/createdAt (host-read, host-side I/O), used + // as the last-resort timestamp fallback when a usage record and its request + // both omit `time`. Raw values; this decoder normalizes them with the same + // `timestampIso` it uses for every other timestamp. + stateUpdatedAt?: unknown + stateCreatedAt?: unknown +} + +export type KimicodeDecodeResult = { + calls: KimicodeDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +/** + * Decode kimicode wire.jsonl records into rich, cost-free calls. A single pass + * over the lines: events set up pending state (model alias, prompt, tools); + * usage.record flushes a call. Dedup is keyed on + * `kimicode::::` against live seenKeys. + */ +export function decodeKimicode({ + records, + seenKeys: liveSeen, + sessionId = '', + agentId = '', + project = '', + projectPath = '', + stateUpdatedAt, + stateCreatedAt, +}: KimicodeDecodeInput): KimicodeDecodeResult { + const seen = liveSeen ?? new Set() + const calls: KimicodeDecodedCall[] = [] + const diagnostics: RecordDiagnostic[] = [] + const fallbackTimestamp = timestampIso(stateUpdatedAt) || timestampIso(stateCreatedAt) + + const aliasModels = new Map() + const prompts = new Map() + let currentPrompt = '' + let currentRequest: RequestContext | null = null + let pendingTools: string[] = [] + let pendingBashCommands: string[] = [] + let usageOrdinal = 0 + + for (let lineIndex = 0; lineIndex < records.length; lineIndex++) { + const line = records[lineIndex] + if (typeof line !== 'string' || !line.trim()) continue + + let record: JsonObject | null + try { + record = asObject(JSON.parse(line)) + } catch { + continue + } + if (!record) continue + + const type = stringValue(record['type']) + if (type === 'turn.prompt') { + pendingTools = [] + pendingBashCommands = [] + currentPrompt = inputText(record['input']) + continue + } + + if (type === 'llm.request') { + const model = stringValue(record['model']) + const modelAlias = stringValue(record['modelAlias']) + const turnId = turnIdFromStep(record['turnStep']) + if (model && modelAlias) aliasModels.set(modelAlias, model) + if (turnId && currentPrompt) prompts.set(turnId, currentPrompt) + currentRequest = { + model, + modelAlias, + turnId, + timestamp: timestampIso(record['time']), + } + continue + } + + if (type === 'context.append_loop_event') { + const tool = toolDetails(record['event']) + if (tool) { + pendingTools.push(tool.name) + pendingBashCommands.push(...tool.bashCommands) + } + continue + } + + if (type !== 'usage.record') continue + const usage = asObject(record['usage']) + if (!usage) continue + + const usageAlias = stringValue(record['model']) + const realModel = aliasModels.get(usageAlias) ?? (currentRequest?.model || 'kimicode-unknown') + const turnId = currentRequest?.turnId || '' + const inputTokens = nonNegativeNumber(usage['inputOther']) + const outputTokens = nonNegativeNumber(usage['output']) + const cacheReadInputTokens = nonNegativeNumber(usage['inputCacheRead']) + const cacheCreationInputTokens = nonNegativeNumber(usage['inputCacheCreation']) + const timestamp = timestampIso(record['time']) || currentRequest?.timestamp || fallbackTimestamp + if (!timestamp) { + pendingTools = [] + pendingBashCommands = [] + continue + } + + const deduplicationKey = `kimicode:${sessionId}:${agentId}:${lineIndex + 1}:${usageOrdinal}` + usageOrdinal++ + if (seen.has(deduplicationKey)) { + pendingTools = [] + pendingBashCommands = [] + continue + } + seen.add(deduplicationKey) + + calls.push({ + provider: 'kimicode', + model: realModel, + inputTokens, + outputTokens, + cacheCreationInputTokens, + cacheReadInputTokens, + cachedInputTokens: cacheReadInputTokens, + reasoningTokens: 0, + webSearchRequests: 0, + tools: pendingTools, + rawBashCommands: pendingBashCommands, + timestamp, + speed: 'standard', + deduplicationKey, + turnId: turnId || undefined, + userMessage: prompts.get(turnId) ?? currentPrompt, + sessionId, + project, + projectPath, + }) + + pendingTools = [] + pendingBashCommands = [] + } + + return { calls, diagnostics } +} diff --git a/packages/core/src/providers/kimicode/index.ts b/packages/core/src/providers/kimicode/index.ts new file mode 100644 index 00000000..93c12b65 --- /dev/null +++ b/packages/core/src/providers/kimicode/index.ts @@ -0,0 +1,5 @@ +export { decodeKimicode, kimicodeToolNameMap } from './decode.js' +export type { KimicodeDecodeInput, KimicodeDecodeResult } from './decode.js' +export { toObservations } from './observations.js' +export type { KimicodeToObservationsContext, RichKimicodeSessionDecode } from './observations.js' +export type { JsonObject, RequestContext, SessionState, KimicodeDecodedCall } from './types.js' diff --git a/packages/core/src/providers/kimicode/observations.ts b/packages/core/src/providers/kimicode/observations.ts new file mode 100644 index 00000000..510c88b9 --- /dev/null +++ b/packages/core/src/providers/kimicode/observations.ts @@ -0,0 +1,87 @@ +// Minimizing transform: rich Kimicode decode -> the strict observation envelope. +// Only opaque ids, fingerprints, enums, numbers, timestamps, and CANONICAL tool +// names cross into the output — never the user message, project path, shell +// command, or file path. + +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' + +/** One Kimicode session's rich decode, as the host holds it before minimization. */ +export interface RichKimicodeSessionDecode { + sessionId: string + /** Absolute project path (the session cwd); fingerprinted, never emitted raw. */ + projectPath: string + /** Rich, cost-free calls in decode order (as decodeKimicode emits them). */ + calls: KimicodeDecodedCall[] +} + +export interface KimicodeToObservationsContext { + /** HMAC key that scopes every fingerprint. */ + privacyKey: string + /** Provider id stamped onto sessions/calls and folded into sessionRef. */ + provider?: string +} + +// Canonical tool-name charset, mirroring core's CanonicalToolName schema. +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, + tokens: { + input: call.inputTokens, + output: call.outputTokens, + reasoning: call.reasoningTokens, + cacheRead: call.cacheReadInputTokens, + cacheCreate: call.cacheCreationInputTokens, + }, + webSearchRequests: call.webSearchRequests, + speed: call.speed, + costBasis: 'estimated', + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + } +} + +function toSessionObservation(decode: RichKimicodeSessionDecode, ctx: KimicodeToObservationsContext): SessionObservation { + const provider = ctx.provider ?? 'kimicode' + 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 Kimicode decode (one or many sessions) 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: RichKimicodeSessionDecode | RichKimicodeSessionDecode[], + ctx: KimicodeToObservationsContext, +): { 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/kimicode/types.ts b/packages/core/src/providers/kimicode/types.ts new file mode 100644 index 00000000..eebe8b53 --- /dev/null +++ b/packages/core/src/providers/kimicode/types.ts @@ -0,0 +1,44 @@ +/** + * Kimicode session record types (moved from CLI, verbatim) + rich decoded call. + */ + +export type JsonObject = Record + +export type SessionState = { + createdAt?: string + updatedAt?: string + workDir?: string +} + +export type RequestContext = { + model: string + modelAlias: string + turnId: string + timestamp: string +} + +/** + * Rich decoded call: token buckets + tool list + raw bash commands, no pricing, + * no bash base-name extraction (that's host-side). + */ +export type KimicodeDecodedCall = { + provider: 'kimicode' + model: string + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + tools: string[] + rawBashCommands: string[] + timestamp: string + speed: 'standard' + deduplicationKey: string + turnId?: string + userMessage: string + sessionId: string + project?: string + projectPath?: string +} diff --git a/packages/core/src/providers/pi/decode.ts b/packages/core/src/providers/pi/decode.ts new file mode 100644 index 00000000..a2b488d8 --- /dev/null +++ b/packages/core/src/providers/pi/decode.ts @@ -0,0 +1,207 @@ +// @codeburn/core Pi/OMP decoder: pure decode over host-supplied JSONL records. +// The host reads the .jsonl session file; this decoder extracts token buckets, +// tool calls, and user message threading with no fs, env, clock, or pricing. +// Pi and OMP share the same decode logic. + +import { basename } from 'node:path' + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { PiDecodedCall, PiEntry } from './types.js' + +// Pi/OMP tool ids mapped to the canonical vocabulary. Unknown ids pass through. +export const piToolNameMap: Record = { + bash: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + dispatch_agent: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + patch: 'Patch', +} + +// Pi/OMP have no dedicated skill tool the way Claude Code does. A native skill +// load is emitted as an ordinary `read` tool call whose path points at the +// skill's `SKILL.md` (Pi resolves skills from many roots: ~/.pi/agent/skills, +// project .pi/skills, .agents/skills, package skills/, --skill ), or, in +// newer OMP builds, at a `skill://` URI. Left untouched these inflate the +// Read tool count and leave the Skills dimension empty (issue #588). Return the +// skill name when a read is really a skill load, else null so it stays a Read. +function skillLoadName(name: string | undefined, args: Record | undefined): string | null { + if (name !== 'read') return null + const raw = args?.['path'] ?? args?.['file_path'] + if (typeof raw !== 'string') return null + const path = raw.trim() + if (path.length === 0) return null + + if (path.startsWith('skill://')) { + const rest = path.slice('skill://'.length).replace(/^\/+/, '') + const first = rest.split(/[/?#]/)[0]?.trim() ?? '' + return first.length > 0 ? first : null + } + + // Match on the SKILL.md basename, not a directory prefix, because skill roots + // live in many locations. Split on both separators so Windows paths work. + const segments = path.split(/[\\/]/).filter(Boolean) + if (segments[segments.length - 1] !== 'SKILL.md') return null + const parent = segments[segments.length - 2]?.trim() + return parent && parent.length > 0 ? parent : null +} + +export type PiDecodeInput = { + // records[i] is one raw JSONL line of the session file; the host does no + // parsing, only the read + split. + records: unknown[] + context: DecodeContext + seenKeys?: Set +} + +export type PiDecodeResult = { + calls: PiDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +// Moved verbatim from the CLI's `content-utils.ts`. A clean array (the +// overwhelming common case) is returned by reference; a string is wrapped into +// a single text block; anything else yields no blocks. +function normalizeContentBlocks( + content: T[] | string | null | undefined, +): T[] { + if (Array.isArray(content)) { + const isBlock = (b: T): boolean => b != null && typeof b === 'object' + return content.every(isBlock) ? content : content.filter(isBlock) + } + if (typeof content === 'string') return [{ type: 'text', text: content } as T] + return [] +} + +/** + * 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. + */ +export function decodePi({ + records, + context, + seenKeys: liveSeen, +}: PiDecodeInput): PiDecodeResult { + const seen = liveSeen ?? new Set() + const calls: PiDecodedCall[] = [] + const diagnostics: RecordDiagnostic[] = [] + const provider: 'pi' | 'omp' = context.providerId === 'omp' ? 'omp' : 'pi' + const sourcePath = context.sourceRef + + let sessionId = basename(sourcePath, '.jsonl') + let pendingUserMessage = '' + + for (const [lineIdx, record] of records.entries()) { + if (typeof record !== 'string') continue + + const line = record.trim() + if (!line) continue + + let entry: PiEntry + try { + entry = JSON.parse(line) as PiEntry + } catch { + continue + } + + if (entry.type === 'session') { + sessionId = entry.id ?? sessionId + continue + } + + if (entry.type !== 'message') continue + + const msg = entry.message + if (!msg) continue + + if (msg.role === 'user') { + const texts = normalizeContentBlocks(msg.content) + .filter(c => c.type === 'text') + .map(c => c.text ?? '') + .filter(Boolean) + if (texts.length > 0) pendingUserMessage = texts.join(' ') + continue + } + + if (msg.role !== 'assistant' || !msg.usage) continue + + // Coerce undefined/null token fields to 0. Pi/OMP session files + // sometimes omit individual usage fields; the destructure used to + // pass undefined into calculateCost which then returned NaN, and + // that NaN propagated into every aggregate cost total. + const input = msg.usage.input ?? 0 + const output = msg.usage.output ?? 0 + const cacheRead = msg.usage.cacheRead ?? 0 + const cacheWrite = msg.usage.cacheWrite ?? 0 + if (input === 0 && output === 0) continue + + const model = msg.model ?? 'gpt-5' + const responseId = msg.responseId ?? '' + const dedupKey = `${provider}:${sourcePath}:${responseId || entry.id || entry.timestamp || String(lineIdx)}` + + if (seen.has(dedupKey)) continue + seen.add(dedupKey) + + const toolCalls = normalizeContentBlocks(msg.content).filter(c => c.type === 'toolCall' && c.name) + + // A SKILL.md-loading read is surfaced as the `Skill` tool (not `Read`) + // and its name is recorded in `skills`. This mirrors how the Claude + // parser represents a skill invocation, so the shared classifier tags + // the turn `general` and the "Skills & Agents" breakdown picks it up, + // instead of over-counting a Read and leaving Skills empty (#588). + // Every other call stays a normal tool. + const tools: string[] = [] + const skills: string[] = [] + for (const c of toolCalls) { + const skill = skillLoadName(c.name, c.arguments) + if (skill !== null) { + skills.push(skill) + tools.push('Skill') + continue + } + tools.push(piToolNameMap[c.name!] ?? c.name!) + } + + const bashCommands = toolCalls + .filter(c => c.name === 'bash') + .flatMap(c => { + const cmd = c.arguments?.['command'] + return typeof cmd === 'string' ? [cmd] : [] + }) + + const timestamp = entry.timestamp ?? '' + + calls.push({ + provider, + model, + inputTokens: input, + outputTokens: output, + cacheCreationInputTokens: cacheWrite, + cacheReadInputTokens: cacheRead, + cachedInputTokens: cacheRead, + reasoningTokens: 0, + webSearchRequests: 0, + tools, + rawBashCommands: bashCommands, + skills: skills.length > 0 ? skills : undefined, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + }) + pendingUserMessage = '' + } + + return { calls, diagnostics } +} diff --git a/packages/core/src/providers/pi/index.ts b/packages/core/src/providers/pi/index.ts new file mode 100644 index 00000000..b006f4c8 --- /dev/null +++ b/packages/core/src/providers/pi/index.ts @@ -0,0 +1,5 @@ +export { decodePi, piToolNameMap } from './decode.js' +export type { PiDecodeInput, PiDecodeResult } from './decode.js' +export { toObservations } from './observations.js' +export type { PiToObservationsContext, RichPiSessionDecode } from './observations.js' +export type { PiEntry, PiToolCall, PiDecodedCall } from './types.js' diff --git a/packages/core/src/providers/pi/observations.ts b/packages/core/src/providers/pi/observations.ts new file mode 100644 index 00000000..3c47062c --- /dev/null +++ b/packages/core/src/providers/pi/observations.ts @@ -0,0 +1,87 @@ +// Minimizing transform: rich Pi/OMP decode -> the strict observation envelope. +// Only opaque ids, fingerprints, enums, numbers, timestamps, and CANONICAL tool +// names cross into the output — never the user message, project path, shell +// command, or file path. + +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' + +/** One Pi/OMP session's rich decode, as the host holds it before minimization. */ +export interface RichPiSessionDecode { + sessionId: string + /** Absolute project path (the session cwd); fingerprinted, never emitted raw. */ + projectPath: string + /** Rich, cost-free calls in decode order (as decodePi emits them). */ + calls: PiDecodedCall[] +} + +export interface PiToObservationsContext { + /** HMAC key that scopes every fingerprint. */ + privacyKey: string + /** Provider id stamped onto sessions/calls and folded into sessionRef. */ + provider?: string +} + +// Canonical tool-name charset, mirroring core's CanonicalToolName schema. +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, + tokens: { + input: call.inputTokens, + output: call.outputTokens, + reasoning: call.reasoningTokens, + cacheRead: call.cacheReadInputTokens, + cacheCreate: call.cacheCreationInputTokens, + }, + webSearchRequests: call.webSearchRequests, + speed: call.speed, + costBasis: 'estimated', + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + } +} + +function toSessionObservation(decode: RichPiSessionDecode, ctx: PiToObservationsContext): SessionObservation { + const provider = ctx.provider ?? 'pi' + 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 Pi/OMP decode (one or many sessions) 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: RichPiSessionDecode | RichPiSessionDecode[], + ctx: PiToObservationsContext, +): { 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/pi/types.ts b/packages/core/src/providers/pi/types.ts new file mode 100644 index 00000000..691e4de9 --- /dev/null +++ b/packages/core/src/providers/pi/types.ts @@ -0,0 +1,53 @@ +/** + * Pi/OMP session record types (moved from CLI, verbatim) + rich decoded call. + * Pi and OMP share the same session format (JSONL with message entries). + */ + +export type PiEntry = { + type: string + id?: string + timestamp?: string + cwd?: string + message?: { + role?: string + content?: Array<{ type?: string; text?: string; name?: string; arguments?: Record }> | string + model?: string + responseId?: string + usage?: { + input: number + output: number + cacheRead: number + cacheWrite: number + } + } +} + +export type PiToolCall = { + type: 'toolCall' + name?: string + arguments?: Record +} + +/** + * Rich decoded call: token buckets + tool list + raw bash commands + skills, + * no pricing, no bash base-name extraction (that's host-side). + */ +export type PiDecodedCall = { + provider: 'pi' | 'omp' + model: string + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + tools: string[] + rawBashCommands: string[] + skills?: string[] + timestamp: string + speed: 'standard' + deduplicationKey: string + userMessage: string + sessionId: string +} diff --git a/packages/core/tests/architecture-gate.test.ts b/packages/core/tests/architecture-gate.test.ts index aacfe072..1ae44105 100644 --- a/packages/core/tests/architecture-gate.test.ts +++ b/packages/core/tests/architecture-gate.test.ts @@ -74,8 +74,11 @@ const TASK_CATEGORY_NAMES = [ // `CommandFamily` coarse enum in fingerprint.ts (a bash-command bucket: // git/test/build/package/...), NOT the TaskCategory. It is fingerprint-layer // command classification, unrelated to task classification, so it is allowed. +// `'general'` in pi/decode.ts is in a comment explaining the original provider's +// skill-vs-read classification (from pi.ts); it's host-side logic explanation, +// not core classification vocabulary. // Keyed `${name} in ${rel}`; each entry is a justified false positive. -const CATEGORY_LITERAL_ALLOWLIST = new Set(['git in src/fingerprint.ts']) +const CATEGORY_LITERAL_ALLOWLIST = new Set(['git in src/fingerprint.ts', 'general in src/providers/pi/decode.ts']) // Identifiers that would signal classification or correction logic leaking into // core. (Names of the CLI-only classifier/scanner surface.) @@ -138,6 +141,12 @@ const USER_MESSAGE_ALLOWLIST = new Set([ 'src/providers/open-design/types.ts', 'src/providers/lingtai-tui/decode.ts', 'src/providers/lingtai-tui/types.ts', + 'src/providers/gemini/decode.ts', + 'src/providers/gemini/types.ts', + 'src/providers/kimicode/decode.ts', + 'src/providers/kimicode/types.ts', + 'src/providers/pi/decode.ts', + 'src/providers/pi/types.ts', ]) describe('architecture gate: no classification or free text in @codeburn/core source', () => { diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index a1886dd4..d7e01b9a 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -22,6 +22,9 @@ export default defineConfig({ 'src/providers/mux/index.ts', 'src/providers/open-design/index.ts', 'src/providers/lingtai-tui/index.ts', + 'src/providers/gemini/index.ts', + 'src/providers/kimicode/index.ts', + 'src/providers/pi/index.ts', ], format: ['esm'], target: 'node20', From ec449afa3f7857fcbeffb9ec5b96006f1f4312fe Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sun, 26 Jul 2026 17:33:42 -0700 Subject: [PATCH 2/2] test: make durable-totals live-session fixture time-of-day independent The seeded 'today' session used a fixed noon timestamp; under the suite's TZ=UTC pin any run between 00:00 and ~12:30 UTC placed it in the future, so the provider-filtered durable path (today-range ends at now) dropped it and the parity assertion failed. Seed timestamps proportionally between start-of-today and now so they are always today and always in the past. --- packages/cli/tests/cli-durable-totals.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/tests/cli-durable-totals.test.ts b/packages/cli/tests/cli-durable-totals.test.ts index 82f1e44c..27a20b24 100644 --- a/packages/cli/tests/cli-durable-totals.test.ts +++ b/packages/cli/tests/cli-durable-totals.test.ts @@ -89,8 +89,14 @@ async function seedLiveTodaySession(): Promise { const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p') await mkdir(projectDir, { recursive: true }) const now = new Date() - const ts = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0).toISOString() - const ts2 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 30, 0).toISOString() + // Timestamps must be today AND already in the past: the provider-filtered + // durable path parses a today-range ending at `now`, so a fixed clock time + // (e.g. noon under the suite's TZ=UTC pin) sits in the future for any run + // before that hour and gets range-filtered out, failing parity flakily. + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const at = (f: number) => new Date(todayStart.getTime() + (now.getTime() - todayStart.getTime()) * f) + const ts = at(0.4).toISOString() + const ts2 = at(0.6).toISOString() const line = (id: string, t: string): string => JSON.stringify({ type: 'assistant', timestamp: t,