diff --git a/packages/cli/src/providers/cursor-agent.ts b/packages/cli/src/providers/cursor-agent.ts index 8c32cfec..648dab52 100644 --- a/packages/cli/src/providers/cursor-agent.ts +++ b/packages/cli/src/providers/cursor-agent.ts @@ -4,52 +4,22 @@ import { readdir, readFile, stat } from 'fs/promises' import { join, basename } from 'path' import { homedir } from 'os' -import { openDatabase, type SqliteDatabase } from '../sqlite.js' -import { normalizeContentBlocks } from '../content-utils.js' -import { estimateTokensFromChars } from '../token-estimate.js' -import type { - Provider, - SessionSource, - SessionParser, - ParsedProviderCall, -} from './types.js' - -type ConversationSummary = { - conversationId: string - model: string | null - title: string | null - updatedAt: string | null -} +import { decodeCursorAgent } from '@codeburn/core/providers/cursor-agent' +import type { ConversationSummaryRow, CursorAgentDecodedCall } from '@codeburn/core/providers/cursor-agent' -type AssistantTurn = { - body: string - reasoning: string - tools: string[] -} - -type ParsedTurn = { - userMessage: string - assistant: AssistantTurn -} +import { openDatabase, type SqliteDatabase } from '../sqlite.js' +import { createBridgedProvider } from './bridge.js' +import type { Provider, SessionSource, ParsedProviderCall } from './types.js' -const CURSOR_AGENT_COST_MODEL = 'claude-sonnet-4-5' -const MAX_USER_TEXT_LENGTH = 500 -const DIGITS_ONLY = /^\d+$/ -const UUID_LIKE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i -const USER_MARKER = /^\s*user:\s*/i -const ASSISTANT_MARKER = /^\s*A:\s*/ -const THINKING_MARKER = /^\s*\[Thinking\]\s*/ -const TOOL_CALL_MARKER = /^\s*\[Tool call\]\s*(.+?)\s*$/i -const TOOL_RESULT_MARKER = /^\s*\[Tool result\]\b/i -const USER_QUERY_OPEN = '' -const USER_QUERY_CLOSE = '' -const warnedUnrecognizedTranscripts = new Set() const CONVERSATION_SUMMARY_QUERY = ` SELECT conversationId, model, title, updatedAt FROM conversation_summaries WHERE conversationId = ? ` +const UUID_LIKE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const DIGITS_ONLY = /^\d+$/ + const modelDisplayNames: Record = { 'claude-4.5-opus-high-thinking': 'Opus 4.5 (Thinking)', 'claude-4-opus': 'Opus 4', @@ -65,6 +35,8 @@ const modelDisplayNames: Record = { default: 'Auto (Sonnet est.)', } +const warnedUnrecognizedTranscripts = new Set() + function getCursorAgentBaseDir(baseDirOverride?: string): string { if (baseDirOverride) return baseDirOverride // Windows paths unverified; tracked as Open Question 3 in issue #55. @@ -79,38 +51,6 @@ function getAttributionDbPath(baseDir: string): string { return join(baseDir, 'ai-tracking', 'ai-code-tracking.db') } -function estimateTokens(charCount: number): number { - if (charCount <= 0) return 0 - return estimateTokensFromChars(charCount) -} - -function parseToolName(raw: string): string { - const clean = raw.trim() - if (clean.length === 0) return 'unknown' - return clean.toLowerCase().replace(/\s+/g, '-') -} - -function normalizeTimestamp(raw: string | number | null | undefined): string | null { - if (raw === null || raw === undefined) return null - if (typeof raw === 'string') { - const trimmed = raw.trim() - if (trimmed.length === 0) return null - if (DIGITS_ONLY.test(trimmed)) { - const num = Number(trimmed) - if (!Number.isNaN(num)) { - const ms = num < 1e12 ? num * 1000 : num - return new Date(ms).toISOString() - } - } - const parsed = new Date(trimmed) - if (!Number.isNaN(parsed.getTime())) return parsed.toISOString() - return null - } - - const ms = raw < 1e12 ? raw * 1000 : raw - return new Date(ms).toISOString() -} - function prettifyProjectId(raw: string): string { if (!raw) return raw @@ -129,15 +69,6 @@ function prettifyProjectId(raw: string): string { return raw } -function resolveModel(raw: string | null | undefined): string { - if (!raw || raw === 'default') return 'cursor-agent-auto' - return raw -} - -function costModel(model: string): string { - return model === 'cursor-agent-auto' ? CURSOR_AGENT_COST_MODEL : model -} - function transcriptStem(transcriptPath: string): string { const name = basename(transcriptPath) if (name.endsWith('.jsonl')) return name.slice(0, -'.jsonl'.length) @@ -215,271 +146,29 @@ async function appendTranscriptSources( } } -function extractUserQuery(userBlock: string): string { - const chunks: string[] = [] - let cursor = 0 - - while (cursor < userBlock.length) { - const openIndex = userBlock.indexOf(USER_QUERY_OPEN, cursor) - if (openIndex === -1) break - const start = openIndex + USER_QUERY_OPEN.length - const closeIndex = userBlock.indexOf(USER_QUERY_CLOSE, start) - if (closeIndex === -1) { - chunks.push(userBlock.slice(start).trim()) - break - } - chunks.push(userBlock.slice(start, closeIndex).trim()) - cursor = closeIndex + USER_QUERY_CLOSE.length - } - - const combined = chunks.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim() - return combined.slice(0, MAX_USER_TEXT_LENGTH) -} - -function parseJsonlTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolean } { - const lines = raw.split(/\r?\n/).filter(l => l.trim()) - if (lines.length === 0) return { turns: [], recognized: false } - - const turns: ParsedTurn[] = [] - let currentUserMessage = '' - - for (const line of lines) { - let entry: { role?: string; message?: { content?: Array<{ type?: string; text?: string; name?: string }> } } - try { - entry = JSON.parse(line) - } catch { - continue - } - - if (entry.role === 'user') { - const texts = normalizeContentBlocks(entry.message?.content) - .filter(c => c.type === 'text') - .map(c => c.text ?? '') - const combined = texts.join(' ') - currentUserMessage = extractUserQuery(combined) || combined.slice(0, MAX_USER_TEXT_LENGTH) - continue - } - - if (entry.role === 'assistant' && currentUserMessage) { - const content = normalizeContentBlocks(entry.message?.content) - const bodyParts: string[] = [] - const tools: string[] = [] - - for (const block of content) { - if (block.type === 'text' && block.text) { - bodyParts.push(block.text) - } else if (block.type === 'tool_use' && block.name) { - tools.push(`cursor:${block.name.toLowerCase()}`) - } - } - - turns.push({ - userMessage: currentUserMessage, - assistant: { - body: bodyParts.join('\n').trim(), - reasoning: '', - tools, - }, - }) - currentUserMessage = '' - } - } - - return { turns, recognized: turns.length > 0 } -} - -function parseTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolean } { - const lines = raw.split(/\r?\n/) - let recognized = false - - const pendingUsers: string[] = [] - const turns: ParsedTurn[] = [] - - let active: 'none' | 'user' | 'assistant' = 'none' - let userLines: string[] = [] - let assistantLines: string[] = [] - - const flushUser = () => { - if (userLines.length === 0) return - const userQuery = extractUserQuery(userLines.join('\n')) - if (userQuery.length > 0) pendingUsers.push(userQuery) - userLines = [] - } - - const flushAssistant = () => { - if (assistantLines.length === 0) return - - let output = '' - let reasoning = '' - const toolsByTurn = new Map() - - for (const line of assistantLines) { - if (TOOL_RESULT_MARKER.test(line)) continue - - const thinkingMatch = line.match(THINKING_MARKER) - if (thinkingMatch) { - const body = line.replace(THINKING_MARKER, '').trim() - if (body.length > 0) reasoning += `${body}\n` - continue - } - - const toolMatch = line.match(TOOL_CALL_MARKER) - if (toolMatch) { - const parsedTool = parseToolName(toolMatch[1] ?? '') - const toolKey = `cursor:${parsedTool}` - toolsByTurn.set(toolKey, true) - continue - } - - output += `${line}\n` - } - - if (pendingUsers.length > 0) { - const userMessage = pendingUsers.shift()! - const tools = Array.from(toolsByTurn.keys()) - turns.push({ - userMessage, - assistant: { - body: output.trim(), - reasoning: reasoning.trim(), - tools, - }, - }) - } - - assistantLines = [] - } - - for (const line of lines) { - if (USER_MARKER.test(line)) { - recognized = true - if (active === 'user') flushUser() - if (active === 'assistant') flushAssistant() - active = 'user' - userLines = [line.replace(USER_MARKER, '')] - continue - } - - if (ASSISTANT_MARKER.test(line)) { - recognized = true - if (active === 'user') flushUser() - if (active === 'assistant') flushAssistant() - active = 'assistant' - assistantLines = [line.replace(ASSISTANT_MARKER, '')] - continue - } - - if (active === 'user') { - userLines.push(line) - continue - } - - if (active === 'assistant') { - assistantLines.push(line) - } - } - - if (active === 'user') flushUser() - if (active === 'assistant') flushAssistant() - - return { turns, recognized } -} - -function createParser( - source: SessionSource, - seenKeys: Set, - dbPath: string, - summariesByConversationId: Map, -): SessionParser { +// 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. Cursor Agent transcripts +// never carry shell commands, so `bashCommands` is always empty. +function toProviderCall(rich: CursorAgentDecodedCall): ParsedProviderCall { return { - async *parse(): AsyncGenerator { - const conversationId = toConversationId(source.path) - - let summary = summariesByConversationId.get(conversationId) - let db: SqliteDatabase | null = null - - try { - if (!summary) { - if (existsSync(dbPath)) { - try { - db = openDatabase(dbPath) - const rows = db.query<{ - conversationId: string - model: string | null - title: string | null - updatedAt: string | number | null - }>(CONVERSATION_SUMMARY_QUERY, [conversationId]) - - if (rows.length > 0) { - const row = rows[0]! - summary = { - conversationId: row.conversationId, - model: row.model, - title: row.title, - updatedAt: normalizeTimestamp(row.updatedAt), - } - summariesByConversationId.set(conversationId, summary) - } - } catch { - summary = undefined - } - } - } - - const transcript = await readFile(source.path, 'utf-8') - const isJsonl = source.path.endsWith('.jsonl') - const parsed = isJsonl ? parseJsonlTranscript(transcript) : parseTranscript(transcript) - - if (!parsed.recognized) { - if (!warnedUnrecognizedTranscripts.has(source.path)) { - warnedUnrecognizedTranscripts.add(source.path) - process.stderr.write(`codeburn: skipped ${basename(source.path)}: unrecognized cursor-agent transcript format\n`) - } - return - } - - let timestamp = summary?.updatedAt ?? null - if (!timestamp) { - const fileStat = await stat(source.path) - timestamp = fileStat.mtime.toISOString() - } - - const model = resolveModel(summary?.model ?? null) - - for (let turnIndex = 0; turnIndex < parsed.turns.length; turnIndex++) { - const turn = parsed.turns[turnIndex]! - const inputTokens = estimateTokens(turn.userMessage.length) - const outputTokens = estimateTokens(turn.assistant.body.length) - const reasoningTokens = estimateTokens(turn.assistant.reasoning.length) - const deduplicationKey = `cursor-agent:${conversationId}:${turnIndex}` - - if (seenKeys.has(deduplicationKey)) continue - seenKeys.add(deduplicationKey) - - yield { - provider: 'cursor-agent', - model, - inputTokens, - outputTokens, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens, - webSearchRequests: 0, - costBasis: 'estimated', - tools: turn.assistant.tools, - bashCommands: [], - timestamp, - speed: 'standard', - deduplicationKey, - userMessage: turn.userMessage, - sessionId: conversationId, - } - } - } finally { - db?.close() - } - }, + provider: 'cursor-agent', + 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: [], + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + userMessage: rich.userMessage, + sessionId: rich.sessionId, } } @@ -487,9 +176,9 @@ export function createCursorAgentProvider(baseDirOverride?: string): Provider { const baseDir = getCursorAgentBaseDir(baseDirOverride) const projectsDir = getProjectsDir(baseDir) const dbPath = getAttributionDbPath(baseDir) - const summariesByConversationId = new Map() + const summariesByConversationId = new Map() - return { + return createBridgedProvider({ name: 'cursor-agent', displayName: 'Cursor Agent', @@ -527,10 +216,76 @@ export function createCursorAgentProvider(baseDirOverride?: string): Provider { return sources }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createParser(source, seenKeys, dbPath, summariesByConversationId) + // I/O adapter: open the sqlite database host-side, read the conversation + // summary (cached per conversation id, as the pre-migration parser did), + // read the transcript file, and return the plain record objects for the + // core decoder. + async readRecords(source: SessionSource): Promise { + const conversationId = toConversationId(source.path) + + let summary = summariesByConversationId.get(conversationId) + let db: SqliteDatabase | null = null + + try { + if (!summary && existsSync(dbPath)) { + try { + db = openDatabase(dbPath) + const rows = db.query<{ + conversationId: string + model: string | null + title: string | null + updatedAt: string | number | null + }>(CONVERSATION_SUMMARY_QUERY, [conversationId]) + + if (rows.length > 0) { + const row = rows[0]! + summary = { + conversationId: row.conversationId, + model: row.model, + title: row.title, + updatedAt: typeof row.updatedAt === 'number' + ? new Date(row.updatedAt < 1e12 ? row.updatedAt * 1000 : row.updatedAt).toISOString() + : (row.updatedAt ?? null), + } + summariesByConversationId.set(conversationId, summary) + } + } catch { + summary = undefined + } + } + } finally { + db?.close() + } + + const transcript = await readFile(source.path, 'utf-8') + const fileStat = await stat(source.path) + + return [{ + summary: summary ?? null, + transcript, + transcriptPath: source.path, + fileMtime: fileStat.mtime.toISOString(), + conversationId, + }] }, - } + + // The core decoder reports a transcript that parsed to nothing recognizable + // as a record diagnostic, which the bridge discards. The pre-migration + // decode printed one warning per such transcript path, so that notice is + // re-emitted here rather than silently dropped. + decode(input) { + const { calls, diagnostics } = decodeCursorAgent(input) + if (diagnostics.length > 0) { + const path = input.context.sourceRef + if (!warnedUnrecognizedTranscripts.has(path)) { + warnedUnrecognizedTranscripts.add(path) + process.stderr.write(`codeburn: skipped ${basename(path)}: unrecognized cursor-agent transcript format\n`) + } + } + return { calls } + }, + toProviderCall, + }) } export const cursor_agent = createCursorAgentProvider() diff --git a/packages/cli/src/providers/devin.ts b/packages/cli/src/providers/devin.ts index b41e7bb4..40d6233b 100644 --- a/packages/cli/src/providers/devin.ts +++ b/packages/cli/src/providers/devin.ts @@ -2,161 +2,15 @@ import { readdir, stat } from "fs/promises"; import { basename, join } from "path"; import { homedir } from "os"; +import { decodeDevin } from "@codeburn/core/providers/devin"; +import type { DevinDecodedCall, DevinSessionMetadata } from "@codeburn/core/providers/devin"; + import { getShortModelName } from "../models.js"; import { openDatabase } from "../sqlite.js"; import { readConfig } from "../config.js"; -import type { - Provider, - SessionParser, - SessionSource, - ParsedProviderCall, -} from "./types.js"; import { readSessionFile } from "../fs-utils.js"; -import { isPositiveNumber, safeNumber } from "../parser.js"; - -type AgentTrajectory = { - schema_version: string; - session_id?: string; - agent: Agent; - steps: StepType[]; - final_metrics?: FinalMetrics; -}; - -type FinalMetrics = { - total_prompt_tokens?: number; - total_completion_tokens?: number; - total_cached_tokens?: number; - total_steps?: number; -}; - -type DevinAgentExtra = { - backend?: string; - permission_mode?: string; -}; - -type Agent = { - name: string; - version: string; - model_name?: string; - tool_definitions?: unknown; - extra?: Extra; -}; - -type ToolCall = { - tool_call_id: string; - function_name: string; - arguments: unknown; -}; - -type DevinMetadata = { - created_at?: string; - committed_acu_cost?: number; - generation_model?: string; - is_user_input?: boolean; - num_tokens?: number; - request_id?: string; - finish_reason?: string; - metrics?: { - input_tokens?: number; - output_tokens?: number; - cache_creation_tokens?: number; - cache_read_tokens?: number; - tokens_per_sec?: number; - total_time_ms?: number; - ttft_ms?: number; - tpot_ms?: number; - }; -}; - -type ContentPart = ContentPartText | ContentPartImage; - -type ContentPartText = { - type: "text"; - text: string; -}; - -type ContentPartImage = { - type: "image"; - source: ImageSource; -}; - -function isTextContentPart( - contentPart: ContentPart, -): contentPart is ContentPartText { - return contentPart.type === "text"; -} - -type ImageSource = { - media_type: string; - path: string; -}; - -type Step = { - step_id: number; - timestamp?: string; - source: string; - model_name?: string; - message: string | Array; - tool_calls?: Array; - extra?: StepExtra; - observation?: Observation; - metrics?: Metrics; -}; - -type DevinTelemetry = { - source?: string; - operation?: string; -}; - -type DevinStepExtra = { - committed_acu_cost?: number; - generation_model?: string; - telemetry?: DevinTelemetry; -}; - -type Observation = { - results: Array; -}; - -type ObservationResult = { - source_call_id?: string; - content?: string | Array; -}; - -type Metrics = { - prompt_tokens?: number; - completion_tokens?: number; - cached_tokens?: number; - extra?: Extra; -}; - -type DevinMetricsExtra = { - cache_creation_input_tokens?: number; -}; - -type DevinStep = Step & { - metadata?: DevinMetadata; -}; - -type DevinAgentTrajectory = AgentTrajectory; - -type DevinSessionMetadata = { - id: string; - workingDirectory: string; - model: string; - title?: string; - createdAt: string; - lastActivityAt: string; - hidden: boolean; -}; - -type DevinUsage = { - committedAcuCost: number; - inputTokens: number; - outputTokens: number; - cacheCreationInputTokens: number; - cacheReadInputTokens: number; -}; +import { createBridgedProvider } from "./bridge.js"; +import type { Provider, SessionSource, ParsedProviderCall } from "./types.js"; const DEFAULT_DEVIN_CLI_DIR = join( homedir(), @@ -166,152 +20,12 @@ const DEFAULT_DEVIN_CLI_DIR = join( "cli", ); -const DEFAULT_MODEL_NAME = "devin"; const DEVIN_PROVIDER_NAME = "devin"; const DEVIN_PROVIDER_DISPLAY_NAME = "Devin"; const DEVIN_TRANSCRIPTS_SUBDIR = "transcripts"; const DEVIN_SESSIONS_DB = "sessions.db"; const DEVIN_EFFORT_TIERS = new Set(["xhigh", "high", "medium", "low"]); -function parseTranscript(raw: string): DevinAgentTrajectory | null { - try { - return JSON.parse(raw) as DevinAgentTrajectory; - } catch { - return null; - } -} - -function parseNumericTimestamp(value: number): string { - const millis = value < 10_000_000_000 ? value * 1000 : value; - return new Date(millis).toISOString(); -} - -function getCommittedAcuCost(step: DevinStep): number { - const acuCost = [ - step.metadata?.committed_acu_cost, - step.extra?.committed_acu_cost, - ].filter((cost) => isPositiveNumber(cost)); - - return acuCost.shift() || 0; -} - -function hasAnyTokenField( - metrics: Metrics | null | undefined, -): boolean { - if (!metrics) return false; - return [ - metrics.prompt_tokens, - metrics.completion_tokens, - metrics.cached_tokens, - metrics.extra?.cache_creation_input_tokens, - ].some((value) => value != null); -} - -function getMetricsFromStep( - step: DevinStep, -): Metrics | null { - // Prefer step.metrics (standard ATIF v1.7) only when it actually carries - // token fields; a present-but-empty metrics object must not shadow the - // legacy metadata.metrics location. - if (hasAnyTokenField(step.metrics)) { - return step.metrics ?? null; - } - - if (step.metadata) { - return getDevinMetricsFromMetadata(step.metadata); - } - - return step.metrics ?? null; -} - -function getDevinMetricsFromMetadata( - metadata: DevinMetadata, -): Metrics { - return { - prompt_tokens: metadata.metrics?.input_tokens, - completion_tokens: metadata.metrics?.output_tokens, - cached_tokens: metadata.metrics?.cache_read_tokens, - extra: { - cache_creation_input_tokens: metadata.metrics?.cache_creation_tokens, - }, - }; -} - -function getUsage(step: DevinStep): DevinUsage | null { - const committedAcuCost = getCommittedAcuCost(step); - const metrics = getMetricsFromStep(step); - - const hasAnyUsage = [ - committedAcuCost, - metrics?.prompt_tokens, - metrics?.completion_tokens, - metrics?.extra?.cache_creation_input_tokens, - metrics?.cached_tokens, - ].some((x) => isPositiveNumber(x)); - - if (!hasAnyUsage) return null; - - return { - committedAcuCost, - inputTokens: safeNumber(metrics?.prompt_tokens), - outputTokens: safeNumber(metrics?.completion_tokens), - cacheCreationInputTokens: safeNumber( - metrics?.extra?.cache_creation_input_tokens, - ), - cacheReadInputTokens: safeNumber(metrics?.cached_tokens), - }; -} - -function getSessionId( - source: SessionSource, - transcript: DevinAgentTrajectory, -): string { - const fromTranscript = transcript.session_id?.trim(); - return fromTranscript || basename(source.path, ".json"); -} - -function projectNameFromPath(path: string): string { - const normalized = path.trim().replace(/[/\\]+$/, ""); - return normalized.split(/[/\\]/).filter(Boolean).pop() ?? path; -} - -function getProjectName( - source: SessionSource, - session: DevinSessionMetadata | null, -): string { - if (session?.workingDirectory) - return projectNameFromPath(session.workingDirectory); - if (session?.title) return session.title; - return source.project; -} - -function getProjectPath( - session: DevinSessionMetadata | null, -): string | undefined { - return session?.workingDirectory; -} - -function getTimestamp( - step: DevinStep, - session: DevinSessionMetadata | null, -): string | undefined { - return [ - step.metadata?.created_at, - session?.lastActivityAt, - session?.createdAt, - ] - .filter(Boolean) - .shift(); -} - -function firstPresentString(...values: Array): string | undefined { - for (const value of values) { - const trimmed = value?.trim(); - if (trimmed) return trimmed; - } - return undefined; -} - function getFriendlyGptName(model: string): string { const shortName = getShortModelName(model); const match = model.match(/^gpt-(\d+(?:\.\d+)*)(?:-(.+))?$/); @@ -361,56 +75,24 @@ function getDevinDisplayModelName( return getShortModelName(generationModel); } -function getModelName( - transcript: DevinAgentTrajectory, - step: DevinStep, - session: DevinSessionMetadata | null, -): string { - const generationModel = firstPresentString( - step.metadata?.generation_model, - step.extra?.generation_model, - ); - const modelName = firstPresentString( - step.model_name, - transcript.agent?.model_name, - session?.model, - ) ?? DEFAULT_MODEL_NAME; - - return getDevinDisplayModelName(generationModel, modelName); -} - -function getToolNames(step: DevinStep): string[] { - return (step.tool_calls ?? []).map((call) => call.function_name); -} - -function normalizeContentPartMessage(contentPart: ContentPart) { - if (isTextContentPart(contentPart)) { - return contentPart.text; - } else { - return contentPart.source.path; - } +function parseNumericTimestamp(value: number): string { + const millis = value < 10_000_000_000 ? value * 1000 : value; + return new Date(millis).toISOString(); } -function normalizeStepMessage(message: string | Array): string { - if (Array.isArray(message)) { - return message.map((x) => normalizeContentPartMessage(x).trim()).join(" "); - } - return message.trim(); +function projectNameFromPath(path: string): string { + const normalized = path.trim().replace(/[/\\]+$/, ""); + return normalized.split(/[/\\]/).filter(Boolean).pop() ?? path; } -function getFirstUserMessageBeforeStep( - steps: DevinStep[], - index: number, -): string | null { - for (let i = index - 1; i >= 0; i--) { - const step = steps[i]; - if (!step?.metadata?.is_user_input) continue; - const message = step.message - ? normalizeStepMessage(step.message) - : undefined; - if (message) return message; - } - return null; +function getProjectName( + source: SessionSource, + session: DevinSessionMetadata | null, +): string { + if (session?.workingDirectory) + return projectNameFromPath(session.workingDirectory); + if (session?.title) return session.title; + return source.project; } function loadSessionMetadata( @@ -454,86 +136,51 @@ function loadSessionMetadata( async function getCostFactor(): Promise { const configRate = (await readConfig()).devin?.acuUsdRate; - return isPositiveNumber(configRate) ? configRate : null; + return typeof configRate === 'number' && Number.isFinite(configRate) && configRate > 0 ? configRate : null; } -class DevinSessionParser implements SessionParser { - constructor( - private source: SessionSource, - private seenKeys: Set, - private sessionMetadata: Map, - ) {} - - async *parse(): AsyncGenerator { - const raw = await readSessionFile(this.source.path); - if (!raw) return; - - const transcript = parseTranscript(raw); - if (!transcript?.steps) return; - - const sessionId = getSessionId(this.source, transcript); - const session = this.sessionMetadata.get(sessionId) ?? null; - if (session?.hidden) return; - - const project = getProjectName(this.source, session); - const projectPath = getProjectPath(session); - const costFactor = await getCostFactor(); - if (costFactor === null) return; - - for (let index = 0; index < transcript.steps.length; index++) { - const step = transcript.steps[index]; - if (step.metadata?.is_user_input) continue; - - const usage = getUsage(step); - if (!usage) continue; - - const timestamp = getTimestamp(step, session) ?? ""; - - const deduplicationKey = `devin:${sessionId}:${step.step_id}`; - - if (this.seenKeys.has(deduplicationKey)) continue; - this.seenKeys.add(deduplicationKey); - - const model = getModelName(transcript, step, session); - const tools = getToolNames(step); - const userMessage = - getFirstUserMessageBeforeStep(transcript.steps, index) ?? ""; - - yield { - provider: DEVIN_PROVIDER_NAME, - model, - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - cacheCreationInputTokens: usage.cacheCreationInputTokens, - cacheReadInputTokens: usage.cacheReadInputTokens, - cachedInputTokens: usage.cacheReadInputTokens, - reasoningTokens: 0, - webSearchRequests: 0, - costUSD: usage.committedAcuCost * costFactor, - tools, - bashCommands: [], - timestamp, - speed: "standard", - deduplicationKey, - userMessage, - sessionId, - project, - projectPath, - }; - } - } +function toProviderCall(rich: DevinDecodedCall, costFactor: number): ParsedProviderCall { + return { + provider: DEVIN_PROVIDER_NAME, + model: getDevinDisplayModelName(rich.generationModel, rich.modelName), + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + // Devin prices itself (committed ACU x the configured USD rate), so the + // call carries no `costBasis` marker and the pricing pass leaves it alone. + costUSD: rich.committedAcuCost * costFactor, + tools: rich.tools, + bashCommands: rich.rawBashCommands, + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + userMessage: rich.userMessage, + sessionId: rich.sessionId, + project: rich.project, + projectPath: rich.projectPath, + }; } export function createDevinProvider(cliDir: string): Provider { const sessionsDbPath = join(cliDir, DEVIN_SESSIONS_DB); let sessionMetadata: Map | null = null; + let costFactor: number | null | undefined; const getSessionMetadata = () => { if (!sessionMetadata) sessionMetadata = loadSessionMetadata(sessionsDbPath); return sessionMetadata; }; - return { + const getCachedCostFactor = async (): Promise => { + if (costFactor === undefined) costFactor = await getCostFactor(); + return costFactor; + }; + + return createBridgedProvider({ name: DEVIN_PROVIDER_NAME, displayName: DEVIN_PROVIDER_DISPLAY_NAME, @@ -546,7 +193,7 @@ export function createDevinProvider(cliDir: string): Provider { }, async discoverSessions(): Promise { - if ((await getCostFactor()) === null) return []; + if ((await getCachedCostFactor()) === null) return []; const transcriptsDir = join(cliDir, DEVIN_TRANSCRIPTS_SUBDIR); const entries = await readdir(transcriptsDir).catch(() => []); @@ -582,13 +229,35 @@ export function createDevinProvider(cliDir: string): Provider { return sources; }, - createSessionParser( - source: SessionSource, - seenKeys: Set, - ): SessionParser { - return new DevinSessionParser(source, seenKeys, getSessionMetadata()); + async readRecords(source: SessionSource): Promise { + const factor = await getCachedCostFactor(); + if (factor === null) return null; + + const raw = await readSessionFile(source.path); + if (!raw) return null; + + let transcript: { session_id?: string; agent?: unknown; steps?: unknown[] } | null = null; + try { + transcript = JSON.parse(raw) as { session_id?: string; agent?: unknown; steps?: unknown[] }; + } catch { + return null; + } + if (!transcript || Array.isArray(transcript) || !Array.isArray(transcript.steps)) return null; + + const sessionId = transcript.session_id?.trim() || basename(source.path, '.json'); + const session = getSessionMetadata().get(sessionId) ?? null; + + return [{ transcript, session, project: source.project, sessionId }]; }, - }; + + decode: decodeDevin, + + toProviderCall(rich: DevinDecodedCall): ParsedProviderCall { + // readRecords returns null unless the configured ACU rate resolved, so a + // call only reaches this point once `costFactor` is a positive number. + return toProviderCall(rich, costFactor!); + }, + }); } export const devin = createDevinProvider(DEFAULT_DEVIN_CLI_DIR); diff --git a/packages/cli/src/providers/hermes.ts b/packages/cli/src/providers/hermes.ts index 4ce303d2..4ac1cd72 100644 --- a/packages/cli/src/providers/hermes.ts +++ b/packages/cli/src/providers/hermes.ts @@ -2,46 +2,13 @@ import { readdir, stat } from 'fs/promises' import { basename, dirname, join } from 'path' import { homedir } from 'os' +import { decodeHermes, mapToolName } from '@codeburn/core/providers/hermes' +import type { HermesDecodedCall, HermesMessageRow, HermesSessionRow } from '@codeburn/core/providers/hermes' + import { getShortModelName } from '../models.js' import { isSqliteAvailable, getSqliteLoadError, openDatabase, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js' -import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' -import type { ToolCall } from '../types.js' - -type HermesSessionRow = { - id: string - source: string | null - model: string | null - cwd: string | null - billing_provider: string | null - input_tokens: number | null - output_tokens: number | null - cache_read_tokens: number | null - cache_write_tokens: number | null - reasoning_tokens: number | null - estimated_cost_usd: number | null - actual_cost_usd: number | null - api_call_count: number | null - tool_call_count: number | null - started_at: number | null - ended_at: number | null - title: string | null -} - -type HermesMessageRow = { - id: number | null - role: string - content: string | null - tool_calls: string | null - tool_name: string | null - timestamp: number | null -} - -type HermesToolCall = { - function?: { - name?: string - arguments?: string - } -} +import { createBridgedProvider } from './bridge.js' +import type { Provider, SessionSource, ParsedProviderCall } from './types.js' type ProfileDb = { dbPath: string @@ -54,35 +21,6 @@ type TableInfoRow = { type TableColumn = keyof HermesSessionRow | keyof HermesMessageRow -const toolNameMap: Record = { - terminal: 'Bash', - execute_code: 'CodeExecution', - read_file: 'Read', - search_files: 'Grep', - write_file: 'Write', - patch: 'Edit', - browser_navigate: 'Browser', - browser_click: 'Browser', - browser_type: 'Browser', - browser_press: 'Browser', - browser_scroll: 'Browser', - browser_snapshot: 'Browser', - browser_vision: 'Vision', - browser_console: 'Browser', - browser_get_images: 'Browser', - web_search: 'WebSearch', - web_extract: 'WebFetch', - delegate_task: 'Agent', - vision_analyze: 'Vision', - process: 'Bash', - todo: 'TodoWrite', - skill_view: 'Skill', - skill_manage: 'Skill', - skills_list: 'Skill', - memory: 'Memory', - session_search: 'SessionSearch', -} - function getHermesHome(override?: string): string { return override ?? process.env['HERMES_HOME'] ?? join(homedir(), '.hermes') } @@ -146,6 +84,10 @@ function getSessionColumns(db: SqliteDatabase): Set { return new Set(db.query('PRAGMA table_info(sessions)').map(row => row.name)) } +function getMessageColumns(db: SqliteDatabase): Set { + return new Set(db.query('PRAGMA table_info(messages)').map(row => row.name)) +} + function numberColumn(columns: Set, name: TableColumn): string { return columns.has(name) ? `coalesce(${name}, 0) AS ${name}` : `0 AS ${name}` } @@ -154,10 +96,6 @@ function nullableColumn(columns: Set, name: TableColumn): string { return columns.has(name) ? name : `NULL AS ${name}` } -function getMessageColumns(db: SqliteDatabase): Set { - return new Set(db.query('PRAGMA table_info(messages)').map(row => row.name)) -} - function usageExpression(columns: Set): string { const usageColumns: Array = [ 'input_tokens', @@ -172,96 +110,6 @@ function usageExpression(columns: Set): string { return parts.length > 0 ? parts.join(' + ') : '0' } -function parseTimestamp(raw: number | null): string { - if (raw == null) return '' - const ms = raw < 1e12 ? raw * 1000 : raw - return new Date(ms).toISOString() -} - -function firstUserMessage(messages: HermesMessageRow[]): string { - const msg = messages.find(m => m.role === 'user' && typeof m.content === 'string' && m.content.trim().length > 0) - return Array.from(msg?.content ?? '').slice(0, 500).join('') -} - -function mapToolName(raw: string): string { - // Composio MCP tools are matched first — the generic mcp_ prefix on line - // below would also match composio names, so order matters here. - if (raw.startsWith('mcp_composio_')) return 'MCP' - if (raw.startsWith('mcp_') || raw.startsWith('mcp__')) return raw - if (raw.startsWith('browser_')) return 'Browser' - return toolNameMap[raw] ?? raw -} - -function parseToolCalls(raw: string | null): HermesToolCall[] { - if (!raw) return [] - try { - const parsed = JSON.parse(raw) as unknown - return Array.isArray(parsed) ? parsed as HermesToolCall[] : [] - } catch { - return [] - } -} - -function collectTools(messages: HermesMessageRow[]): { tools: string[]; toolSequence: ToolCall[][]; bashCommands: string[] } { - const tools: string[] = [] - const toolSequence: ToolCall[][] = [] - const bashCommands: string[] = [] - - for (const msg of messages) { - if (msg.role === 'assistant') { - const currentTurnTools: ToolCall[] = [] - for (const call of parseToolCalls(msg.tool_calls)) { - const rawName = call.function?.name ?? '' - if (!rawName) continue - const mapped = mapToolName(rawName) - tools.push(mapped) - const toolCall: ToolCall = { tool: mapped } - const rawArgs = call.function?.arguments - if (rawArgs) { - try { - const args = JSON.parse(rawArgs) as Record - const file = args['path'] ?? args['file_path'] - if (typeof file === 'string') toolCall.file = file - const command = args['command'] - if (typeof command === 'string') { - toolCall.command = command - bashCommands.push(command) - } - } catch { - // Ignore malformed arguments from historical sessions. - } - } - currentTurnTools.push(toolCall) - } - if (currentTurnTools.length > 0) { - toolSequence.push(currentTurnTools) - } - } else if (msg.role === 'tool' && msg.tool_name) { - tools.push(mapToolName(msg.tool_name)) - } - } - - return { - tools: [...new Set(tools)], - toolSequence: toolSequence.length > 0 ? toolSequence : [], - bashCommands, - } -} - -function inferProject(messages: HermesMessageRow[], fallback: string): { project: string; projectPath?: string } { - const cwdPattern = /^Current working directory:\s*([a-zA-Z]:\\[^\r\n`"]+|\/[^\r\n`"\\]+)/m - for (const msg of messages) { - if (msg.role !== 'user' && msg.role !== 'system') continue - const text = msg.content ?? '' - const match = cwdPattern.exec(text) - if (match?.[1]) { - const projectPath = match[1].trim() - return { project: sanitizeProject(projectPath), projectPath } - } - } - return { project: fallback } -} - async function discoverFromDb(dbPath: string, profile: string): Promise { let db: SqliteDatabase try { @@ -303,16 +151,76 @@ async function discoverFromDb(dbPath: string, profile: string): Promise, hermesHome: string): SessionParser { +// Map one rich, cost-free decoder call into the host's ParsedProviderCall. Cost +// re-enters here: a provider-recorded dollar figure becomes `costBasis: 'measured'` +// and `costUSD`; otherwise the parser.ts pricing pass estimates from token +// buckets (`costBasis: 'estimated'`). 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: HermesDecodedCall): ParsedProviderCall { + const measured = rich.recordedCost !== undefined return { - async *parse(): AsyncGenerator { + provider: 'hermes', + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + ...(measured + ? { costUSD: rich.recordedCost, costBasis: 'measured' as const, costIsEstimated: false } + : { costBasis: 'estimated' as const, costIsEstimated: true }), + tools: rich.tools, + bashCommands: rich.rawBashCommands, + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + turnId: rich.turnId, + toolSequence: rich.toolSequence, + userMessage: rich.userMessage, + sessionId: rich.sessionId, + project: rich.project, + projectPath: rich.projectPath, + } +} + +export function createHermesProvider(hermesHomeOverride?: string): Provider { + const hermesHome = getHermesHome(hermesHomeOverride) + + return createBridgedProvider({ + name: 'hermes', + displayName: 'Hermes Agent', + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return mapToolName(rawTool) + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + const dbs = await findStateDbs(hermesHome) + const sessions: SessionSource[] = [] + for (const { dbPath, profile } of dbs) { + sessions.push(...await discoverFromDb(dbPath, profile)) + } + return sessions + }, + + // I/O adapter: open the sqlite database host-side, run the session + message + // queries, and return the plain row objects for the core decoder. + async readRecords(source: SessionSource): Promise { if (!isSqliteAvailable()) { process.stderr.write(getSqliteLoadError() + '\n') - return + return null } const decoded = decodeSourcePath(source.path) - if (!decoded) return + if (!decoded) return null const profile = parseProfileName(decoded.dbPath, hermesHome) let db: SqliteDatabase @@ -320,12 +228,11 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: db = openDatabase(decoded.dbPath) } catch (err) { process.stderr.write(`codeburn: cannot open Hermes database: ${err instanceof Error ? err.message : err}\n`) - return + return null } - let result: ParsedProviderCall | undefined try { - if (!validateSchema(db)) return + if (!validateSchema(db)) return null const columns = getSessionColumns(db) const rows = db.query( `SELECT id, @@ -350,7 +257,7 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: [decoded.sessionId], ) const row = rows[0] - if (!row) return + if (!row) return null const messageColumns = getMessageColumns(db) const orderColumns = ['timestamp', 'id'].filter(name => messageColumns.has(name)) @@ -368,105 +275,21 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: [decoded.sessionId], ) - const inputTokens = row.input_tokens ?? 0 - const outputTokens = row.output_tokens ?? 0 - const cacheReadTokens = row.cache_read_tokens ?? 0 - const cacheWriteTokens = row.cache_write_tokens ?? 0 - const reasoningTokens = row.reasoning_tokens ?? 0 - if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens === 0) return - - const model = row.model ?? 'unknown' - const { tools, toolSequence, bashCommands } = collectTools(messages) - // Hermes records the session's working directory in sessions.cwd. - // Prefer it; fall back to scraping a "Current working directory:" line - // from the transcript (older builds), then to the profile name. - const cwd = row.cwd?.trim() - const projectInfo = cwd - ? { project: sanitizeProject(cwd), projectPath: cwd } - : inferProject(messages, sanitizeProject(profile)) - const timestamp = parseTimestamp(row.started_at) - const dedupKey = `hermes:${profile}:${row.id}` - if (seenKeys.has(dedupKey)) return - seenKeys.add(dedupKey) - - // Hermes bills reasoning tokens at the output rate (same as Gemini). - // When Hermes stored an actual or estimated cost, pass it as measured; - // otherwise the pricing pass will estimate from token buckets. - const recordedCost = - (row.actual_cost_usd ?? 0) > 0 ? row.actual_cost_usd! - : (row.estimated_cost_usd ?? 0) > 0 ? row.estimated_cost_usd! - : null - const costIsEstimated = recordedCost === null - - result = { - provider: 'hermes', - model, - inputTokens, - outputTokens, - cacheCreationInputTokens: cacheWriteTokens, - cacheReadInputTokens: cacheReadTokens, - cachedInputTokens: cacheReadTokens, - reasoningTokens, - webSearchRequests: 0, - ...(recordedCost !== null - ? { costUSD: recordedCost, costBasis: 'measured' as const } - : { costBasis: 'estimated' as const }), - costIsEstimated, - tools, - bashCommands, - timestamp, - speed: 'standard', - deduplicationKey: dedupKey, - turnId: `${row.id}:session`, - toolSequence: toolSequence.length > 0 ? toolSequence : undefined, - userMessage: firstUserMessage(messages), - sessionId: row.id, - project: projectInfo.project, - projectPath: projectInfo.projectPath, - } + return [{ session: row, messages, profile }] } catch (err) { // A transient lock on the live state.db must propagate so the caller // retries, not get swallowed into an empty (negatively cached) result. if (isSqliteBusyError(err)) throw err process.stderr.write(`codeburn: error querying Hermes database: ${err instanceof Error ? err.message : err}\n`) - return + return null } finally { db.close() } - - if (result) yield result - }, - } -} - -export function createHermesProvider(hermesHomeOverride?: string): Provider { - const hermesHome = getHermesHome(hermesHomeOverride) - return { - name: 'hermes', - displayName: 'Hermes Agent', - - modelDisplayName(model: string): string { - return getShortModelName(model) - }, - - toolDisplayName(rawTool: string): string { - return mapToolName(rawTool) - }, - - async discoverSessions(): Promise { - if (!isSqliteAvailable()) return [] - const dbs = await findStateDbs(hermesHome) - const sessions: SessionSource[] = [] - for (const { dbPath, profile } of dbs) { - sessions.push(...await discoverFromDb(dbPath, profile)) - } - return sessions }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createParser(source, seenKeys, hermesHome) - }, - } + decode: decodeHermes, + toProviderCall, + }) } export const hermes = createHermesProvider() diff --git a/packages/cli/src/providers/quickdesk.ts b/packages/cli/src/providers/quickdesk.ts index 69040873..db977756 100644 --- a/packages/cli/src/providers/quickdesk.ts +++ b/packages/cli/src/providers/quickdesk.ts @@ -2,10 +2,18 @@ import { readdir, readFile, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, isAbsolute, join, resolve } from 'node:path' -import { estimateTokensFromChars } from '../token-estimate.js' +import { decodeQuickdesk, quickdeskToolNameMap } from '@codeburn/core/providers/quickdesk' +import type { + QuickdeskDatabaseInput, + QuickdeskDecodedCall, + QuickdeskMetricsInput, + QuickdeskSessionMetadata, +} from '@codeburn/core/providers/quickdesk' + import { blobToText, isSqliteAvailable, openDatabase } from '../sqlite.js' import type { SqliteDatabase } from '../sqlite.js' -import type { ParsedProviderCall, ProbeRoot, Provider, SessionParser, SessionSource } from './types.js' +import { createBridgedProvider } from './bridge.js' +import type { ParsedProviderCall, ProbeRoot, Provider, SessionSource } from './types.js' const METRICS_FILE_RE = /^metrics-(\d{4})-(\d{2})-(\d{2})\.jsonl$/ @@ -14,48 +22,11 @@ const modelDisplayNames: Record = { 'claude-sonnet-4-6': 'Sonnet 4.6', } -const toolNameMap: Record = { - readFile: 'Read', - read_file: 'Read', - writeFile: 'Edit', - write_file: 'Edit', - editFile: 'Edit', - edit_file: 'Edit', - runCommand: 'Bash', - run_command: 'Bash', - executeBash: 'Bash', - shell: 'Bash', - grep: 'Grep', - searchFiles: 'Grep', - search_files: 'Grep', -} - type ProfileBase = { path: string profile: string } -type MetricsRecord = { - record: Record -} - -type SessionMetadata = { - id: string - title: string - agentMode: string - createdAt?: number - deleted: boolean - firstUserMessage: string - inputChars: number - outputChars: number - tools: string[] -} - -type DatabaseSnapshot = { - sessions: Map - canEstimate: boolean -} - type SqliteMasterRow = { name?: unknown } @@ -97,7 +68,7 @@ function finiteNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined } -function nonNegativeNumber(value: unknown): number | undefined { +function timestampSeconds(value: unknown): number | undefined { const number = finiteNumber(value) return number !== undefined && number >= 0 ? number : undefined } @@ -217,12 +188,7 @@ function toolNames(value: unknown): string[] { } function uniqueMappedTools(values: string[]): string[] { - return [...new Set(values.map(value => toolNameMap[value] ?? value).filter(Boolean))] -} - -function timestampSeconds(value: unknown): number | undefined { - const number = finiteNumber(value) - return number !== undefined && number >= 0 ? number : undefined + return [...new Set(values.map(value => quickdeskToolNameMap[value] ?? value).filter(Boolean))] } function unixSecondsIso(value: number): string | null { @@ -230,8 +196,8 @@ function unixSecondsIso(value: number): string | null { return Number.isNaN(date.getTime()) ? null : date.toISOString() } -function loadDatabaseSnapshot(basePath: string): DatabaseSnapshot { - const empty: DatabaseSnapshot = { sessions: new Map(), canEstimate: false } +function loadDatabaseSnapshot(basePath: string): { sessions: Map; canEstimate: boolean } { + const empty: { sessions: Map; canEstimate: boolean } = { sessions: new Map(), canEstimate: false } if (!isSqliteAvailable()) return empty let db: SqliteDatabase @@ -257,7 +223,7 @@ function loadDatabaseSnapshot(basePath: string): DatabaseSnapshot { FROM sessions`, ) - const sessions = new Map() + const sessions = new Map() for (const row of sessionRows) { const id = stringValue(row.id) if (!id) continue @@ -319,7 +285,7 @@ function loadDatabaseSnapshot(basePath: string): DatabaseSnapshot { } } -async function readMetricsRecords(path: string): Promise { +async function readMetricsRecords(path: string): Promise }>> { let contents: string try { contents = await readFile(path, 'utf8') @@ -327,7 +293,7 @@ async function readMetricsRecords(path: string): Promise { return [] } - const records: MetricsRecord[] = [] + const records: Array<{ record: Record }> = [] const lines = contents.split(/\r?\n/) for (let index = 0; index < lines.length; index++) { const line = lines[index]!.trim() @@ -342,61 +308,15 @@ async function readMetricsRecords(path: string): Promise { return records } -function usageRecord(record: Record): boolean { - return Boolean(stringValue(record['Model'])) - && nonNegativeNumber(record['InputTokens']) !== undefined - && nonNegativeNumber(record['OutputTokens']) !== undefined -} - -function sessionId(record: Record): string { - return stringValue(record['session_id']) -} - -function toolsKey(record: Record): string { - return sessionId(record) -} - -function collectMetricTools(records: MetricsRecord[]): Map { - const tools = new Map() - for (const { record } of records) { - const key = toolsKey(record) - const tool = stringValue(record['ToolName']) - if (!key || !tool) continue - const current = tools.get(key) ?? [] - current.push(toolNameMap[tool] ?? tool) - tools.set(key, current) - } - for (const [key, values] of tools) tools.set(key, [...new Set(values)]) - return tools -} - -function fallbackTimestamp(path: string): string | null { - const match = METRICS_FILE_RE.exec(basename(path)) - if (!match) return null - const year = Number(match[1]) - const month = Number(match[2]) - const day = Number(match[3]) - const date = new Date(Date.UTC(year, month - 1, day)) - if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null - return date.toISOString() -} - -function metricsTimestamp(record: Record, path: string): string | null { - const aws = asRecord(record['_aws']) - const timestampMs = finiteNumber(aws?.['Timestamp']) - if (timestampMs !== undefined) { - const date = new Date(timestampMs) - if (!Number.isNaN(date.getTime())) return date.toISOString() - } - return fallbackTimestamp(path) -} - async function metricSessionIds(basePath: string): Promise> { const ids = new Set() for (const path of await metricsFiles(basePath)) { for (const { record } of await readMetricsRecords(path)) { - if (!usageRecord(record)) continue - const id = sessionId(record) + const model = stringValue(record['Model']) + const inputTokens = nonNegativeNumber(record['InputTokens']) + const outputTokens = nonNegativeNumber(record['OutputTokens']) + if (!model || inputTokens === undefined || outputTokens === undefined) continue + const id = stringValue(record['session_id']) if (id) ids.add(id) } } @@ -416,141 +336,107 @@ function basePathFor(source: SessionSource): string { return resolve(source.path, '..', '..') } -function commonCallFields(source: SessionSource, basePath: string) { +function nonNegativeNumber(value: unknown): number | undefined { + const number = finiteNumber(value) + return number !== undefined && number >= 0 ? number : undefined +} + +// Map one rich, cost-free decoder call into the host's ParsedProviderCall. Cost +// re-enters here: a provider-reported dollar figure becomes `costBasis: 'measured'` +// and `costUSD`; otherwise the parser.ts pricing pass estimates from token buckets +// (`costBasis: 'estimated'`). +function toProviderCall(rich: QuickdeskDecodedCall): ParsedProviderCall { + const measured = rich.recordedCost !== undefined return { provider: 'quickdesk', - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens: 0, - webSearchRequests: 0, - bashCommands: [] as string[], - speed: 'standard' as const, - project: source.project, - projectPath: basePath, + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + ...(measured + ? { costUSD: rich.recordedCost, costBasis: 'measured' as const, costIsEstimated: false } + : { costBasis: 'estimated' as const, costIsEstimated: true }), + tools: rich.tools, + bashCommands: rich.rawBashCommands, + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + userMessage: rich.userMessage, + sessionId: rich.sessionId, + project: rich.project, + projectPath: rich.projectPath, } } -function createMetricsParser(source: SessionSource, seenKeys: Set): SessionParser { - return { - async *parse(): AsyncGenerator { - const records = await readMetricsRecords(source.path) +function isDatabaseSource(source: SessionSource): boolean { + return source.sourceId === 'sessions-db' || basename(source.path) === 'sessions.db' +} + +export function createQuickdeskProvider(): Provider { + return createBridgedProvider({ + name: 'quickdesk', + displayName: 'Quick Desktop', + durableSources: true, + + modelDisplayName(model: string): string { + if (model === 'quickdesk-auto') return 'Quick Desktop (auto)' + return modelDisplayNames[model] ?? model + }, + + toolDisplayName(rawTool: string): string { + return quickdeskToolNameMap[rawTool] ?? rawTool + }, + + async probeRoots(): Promise { + return (await resolveProfileBases()).map(base => ({ path: base.path, label: base.profile })) + }, + + async discoverSessions(): Promise { + return discoverSources() + }, + + // I/O adapter: read the discovered source host-side. Metrics files are parsed + // into JSONL records plus the linked sqlite snapshot; the sessions.db itself is + // read into plain session metadata rows. The core decoder is pure over these + // composites. + async readRecords(source: SessionSource): Promise { const basePath = basePathFor(source) - const linkedTools = collectMetricTools(records) - const snapshot = loadDatabaseSnapshot(basePath) - const fileId = basename(source.path) - - for (const { record } of records) { - if (!usageRecord(record)) continue - const model = stringValue(record['Model']) - const inputTokens = nonNegativeNumber(record['InputTokens'])! - const outputTokens = nonNegativeNumber(record['OutputTokens'])! - const timestamp = metricsTimestamp(record, source.path) - if (!timestamp) continue - - const linkedSessionId = sessionId(record) - const metadata = linkedSessionId ? snapshot.sessions.get(linkedSessionId) : undefined - if (metadata?.deleted) continue - - const fallbackId = `${source.project}:${fileId}` - const deduplicationKey = `quickdesk:${linkedSessionId || fallbackId}:${timestamp}:${model}:${inputTokens}:${outputTokens}` - if (seenKeys.has(deduplicationKey)) continue - seenKeys.add(deduplicationKey) - - const recordedCost = nonNegativeNumber(record['CostUSD']) - const costIsEstimated = recordedCost === undefined - const metricTools = linkedTools.get(toolsKey(record)) ?? [] - const tools = uniqueMappedTools([...metricTools, ...(metadata?.tools ?? [])]) - - yield { - ...commonCallFields(source, basePath), - model, - inputTokens, - outputTokens, - // Provider-reported cost passes through as 'measured'; otherwise the - // pricing pass computes it from the token buckets ('estimated'). - ...(recordedCost !== undefined - ? { costUSD: recordedCost, costBasis: 'measured' as const } - : { costBasis: 'estimated' as const }), - costIsEstimated, - tools, - timestamp, - deduplicationKey, - userMessage: metadata?.firstUserMessage ?? '', - sessionId: linkedSessionId || fileId, + const project = source.project + + if (isDatabaseSource(source)) { + const snapshot = loadDatabaseSnapshot(basePath) + if (!snapshot.canEstimate) return null + const meteredSessionIds = await allMetricSessionIds() + const input: QuickdeskDatabaseInput = { + variant: 'database', + sessions: [...snapshot.sessions.values()], + meteredSessionIds, + project, + projectPath: basePath, } + return [input] } - }, - } -} -function createDatabaseParser(source: SessionSource, seenKeys: Set): SessionParser { - return { - async *parse(): AsyncGenerator { - const basePath = basePathFor(source) + const records = await readMetricsRecords(source.path) const snapshot = loadDatabaseSnapshot(basePath) - if (!snapshot.canEstimate) return - const meteredSessions = await allMetricSessionIds() - - for (const metadata of snapshot.sessions.values()) { - if (metadata.deleted || meteredSessions.has(metadata.id) || metadata.createdAt === undefined) continue - const createdAtSeconds = metadata.createdAt > 1_000_000_000_000 - ? metadata.createdAt / 1000 - : metadata.createdAt - const timestamp = unixSecondsIso(createdAtSeconds) - if (!timestamp) continue - const inputTokens = estimateTokensFromChars(metadata.inputChars) - const outputTokens = estimateTokensFromChars(metadata.outputChars) - if (inputTokens + outputTokens === 0) continue - - const deduplicationKey = `quickdesk-est:${metadata.id}` - if (seenKeys.has(deduplicationKey)) continue - seenKeys.add(deduplicationKey) - const model = 'quickdesk-auto' - - yield { - ...commonCallFields(source, basePath), - model, - inputTokens, - outputTokens, - costBasis: 'estimated', - costIsEstimated: true, - tools: metadata.tools, - timestamp, - deduplicationKey, - userMessage: metadata.firstUserMessage, - sessionId: metadata.id, - } + const input: QuickdeskMetricsInput = { + variant: 'metrics', + records, + sessions: snapshot.sessions, + project, + projectPath: basePath, + fileId: basename(source.path), } + return [input] }, - } -} -export const quickdesk: Provider = { - name: 'quickdesk', - displayName: 'Quick Desktop', - durableSources: true, - - modelDisplayName(model: string): string { - if (model === 'quickdesk-auto') return 'Quick Desktop (auto)' - return modelDisplayNames[model] ?? model - }, - - toolDisplayName(rawTool: string): string { - return toolNameMap[rawTool] ?? rawTool - }, - - async probeRoots(): Promise { - return (await resolveProfileBases()).map(base => ({ path: base.path, label: base.profile })) - }, - - async discoverSessions(): Promise { - return discoverSources() - }, - - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return source.sourceId === 'sessions-db' || basename(source.path) === 'sessions.db' - ? createDatabaseParser(source, seenKeys) - : createMetricsParser(source, seenKeys) - }, + decode: decodeQuickdesk, + toProviderCall, + }) } + +export const quickdesk = createQuickdeskProvider() diff --git a/packages/cli/src/providers/warp.ts b/packages/cli/src/providers/warp.ts index 07d9ef05..9c6954c0 100644 --- a/packages/cli/src/providers/warp.ts +++ b/packages/cli/src/providers/warp.ts @@ -1,76 +1,24 @@ import { join } from 'path' import { homedir } from 'os' +import { decodeWarp } from '@codeburn/core/providers/warp' +import type { WarpBlockRow, WarpConversationRow, WarpDecodedCall, WarpQueryRow } from '@codeburn/core/providers/warp' + import { extractBashCommands } from '../bash-utils.js' import { getShortModelName } from '../models.js' import { blobToText, getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js' -import { estimateTokensFromChars } from '../token-estimate.js' -import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' -import { safeNumber } from '../parser.js' - -const WARP_GROUP_CONTAINER = '2BBY89MBSN.dev.warp' -const WARP_STABLE_BUNDLE_ID = 'dev.warp.Warp-Stable' -const WARP_PREVIEW_BUNDLE_ID = 'dev.warp.Warp-Preview' -const PRIMARY_AGENT_CATEGORY = 'primary_agent' -const modelAliases: Record = { - 'Claude Sonnet 4.6': 'claude-sonnet-4-6', - 'Claude Sonnet 4.5': 'claude-sonnet-4-5', - 'Claude Haiku 4.5': 'claude-haiku-4-5', - 'Claude Opus 4.6': 'claude-opus-4-6', - 'GPT-5.3 Codex (low reasoning)': 'gpt-5.3-codex', - 'GPT-5.3 Codex (medium reasoning)': 'gpt-5.3-codex', - 'GPT-5.3 Codex (high reasoning)': 'gpt-5.3-codex', - 'GPT-5.3 Codex (extra high reasoning)': 'gpt-5.3-codex', - 'auto-efficient': 'warp-auto-efficient', - 'auto-powerful': 'warp-auto-powerful', -} +import { createBridgedProvider } from './bridge.js' +import type { ParsedProviderCall, Provider, SessionSource } from './types.js' -type WarpConversationRow = { - conversation_id: string - conversation_data: string - last_modified_at: string | null -} - -type WarpQueryRow = { - exchange_id: string - conversation_id: string - start_ts: string - input: string - working_directory: string | null - output_status: string - model_id: string - planning_model_id: string - coding_model_id: string -} - -type WarpBlockRow = { +type RawWarpBlockRow = { block_id: string start_ts: string | null stylized_command: Uint8Array | string | null } -type WarpTokenUsageEntry = { - model_id?: string - warp_tokens?: number - byok_tokens?: number - warp_token_usage_by_category?: Record - byok_token_usage_by_category?: Record -} - -type WarpConversationData = { - conversation_usage_metadata?: { - token_usage?: WarpTokenUsageEntry[] - } -} - -type ParsedExchange = WarpQueryRow & { - startMs: number -} - -type ExchangeToolInfo = { - tools: string[] - bashCommands: string[] -} +const WARP_GROUP_CONTAINER = '2BBY89MBSN.dev.warp' +const WARP_STABLE_BUNDLE_ID = 'dev.warp.Warp-Stable' +const WARP_PREVIEW_BUNDLE_ID = 'dev.warp.Warp-Preview' function sanitizeProject(path: string): string { return path.replace(/^\/+/, '').replace(/\//g, '-') @@ -95,203 +43,12 @@ function getDbCandidates(dbPathOverride?: string): string[] { return [warpDbPath(WARP_STABLE_BUNDLE_ID), warpDbPath(WARP_PREVIEW_BUNDLE_ID)] } -function normalizeModel(rawModel: string): string { - const model = rawModel.trim() - if (!model) return model - return modelAliases[model] ?? model -} - function modelDisplayName(model: string): string { if (model === 'warp-auto-efficient') return 'Warp Auto (efficient)' if (model === 'warp-auto-powerful') return 'Warp Auto (powerful)' return getShortModelName(model) } -function parseTimestamp(raw: string | null | undefined): number | null { - if (!raw) return null - const trimmed = raw.trim() - if (!trimmed) return null - const withT = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T') - const lastPlus = withT.lastIndexOf('+') - const lastMinus = withT.lastIndexOf('-') - const hasOffset = lastPlus > 9 || lastMinus > 9 - const hasTimezone = withT.endsWith('Z') || hasOffset - const normalized = hasTimezone ? withT : `${withT}Z` - const ms = Date.parse(normalized) - return Number.isNaN(ms) ? null : ms -} - -function parseJsonString(raw: string): string { - try { - const parsed = JSON.parse(raw) as unknown - return typeof parsed === 'string' ? parsed : raw - } catch { - return raw - } -} - -function isFinalStatus(rawStatus: string): boolean { - const status = parseJsonString(rawStatus) - return status === 'Completed' || status === 'Cancelled' || status === 'Failed' -} - -function extractCategoryTokens(categories: Record | undefined, key: string): number { - if (!categories) return 0 - return safeNumber(categories[key]) -} - -function extractTokenBudget(rawConversationData: string): { tokenBudget: number; dominantModel: string } { - let conversationData: WarpConversationData - try { - conversationData = JSON.parse(rawConversationData) as WarpConversationData - } catch { - return { tokenBudget: 0, dominantModel: '' } - } - - const entries = conversationData.conversation_usage_metadata?.token_usage ?? [] - let primaryTotal = 0 - let fallbackTotal = 0 - let dominantPrimaryTokens = 0 - let dominantFallbackTokens = 0 - let dominantModel = '' - - for (const entry of entries) { - const primaryTokens = - extractCategoryTokens(entry.warp_token_usage_by_category, PRIMARY_AGENT_CATEGORY) + - extractCategoryTokens(entry.byok_token_usage_by_category, PRIMARY_AGENT_CATEGORY) - const entryTotal = safeNumber(entry.warp_tokens) + safeNumber(entry.byok_tokens) - - primaryTotal += primaryTokens - fallbackTotal += entryTotal - - if (primaryTokens > dominantPrimaryTokens) { - dominantPrimaryTokens = primaryTokens - dominantModel = typeof entry.model_id === 'string' ? entry.model_id : dominantModel - } - - if (dominantPrimaryTokens === 0 && entryTotal > dominantFallbackTokens) { - dominantFallbackTokens = entryTotal - dominantModel = typeof entry.model_id === 'string' ? entry.model_id : dominantModel - } - } - - const tokenBudget = primaryTotal > 0 ? primaryTotal : fallbackTotal - return { tokenBudget: Math.max(0, Math.round(tokenBudget)), dominantModel: normalizeModel(dominantModel) } -} - -function extractUserMessage(rawInput: string): string { - try { - const parsed = JSON.parse(rawInput) as unknown - if (!Array.isArray(parsed)) return '' - for (const item of parsed) { - if (!item || typeof item !== 'object') continue - const query = (item as { Query?: { text?: unknown } }).Query - if (!query || typeof query !== 'object') continue - if (typeof query.text === 'string' && query.text.trim()) return query.text - } - return '' - } catch { - return '' - } -} - -function estimateWeight(rawInput: string): number { - const userMessage = extractUserMessage(rawInput) - const source = userMessage || rawInput - const tokens = estimateTokensFromChars(source.length) - return Math.max(1, tokens) -} - -function allocateTokens(weights: number[], tokenBudget: number): number[] { - if (weights.length === 0) return [] - const normalizedWeights = weights.map(w => Math.max(0, Math.round(w))) - const totalWeight = normalizedWeights.reduce((sum, weight) => sum + weight, 0) - const budget = Math.max(0, Math.round(tokenBudget)) - - if (budget === 0) return normalizedWeights.map(() => 0) - if (totalWeight === 0) { - const even = Math.floor(budget / normalizedWeights.length) - const allocated = normalizedWeights.map(() => even) - let remainder = budget - even * normalizedWeights.length - let index = 0 - while (remainder > 0) { - allocated[index] = (allocated[index] ?? 0) + 1 - remainder-- - index = (index + 1) % normalizedWeights.length - } - return allocated - } - - const rawAllocation = normalizedWeights.map(weight => (budget * weight) / totalWeight) - const allocated = rawAllocation.map(value => Math.floor(value)) - let remainder = budget - allocated.reduce((sum, value) => sum + value, 0) - - const byLargestFraction = rawAllocation - .map((value, index) => ({ index, fraction: value - Math.floor(value) })) - .sort((a, b) => b.fraction - a.fraction) - - let pointer = 0 - while (remainder > 0 && byLargestFraction.length > 0) { - const index = byLargestFraction[pointer]!.index - allocated[index] = (allocated[index] ?? 0) + 1 - remainder-- - pointer = (pointer + 1) % byLargestFraction.length - } - - return allocated -} - -function resolveModelForExchange(exchange: WarpQueryRow, dominantModel: string): string { - const candidate = - exchange.model_id.trim() || - exchange.coding_model_id.trim() || - exchange.planning_model_id.trim() || - dominantModel || - 'warp-auto-efficient' - const normalized = normalizeModel(candidate) - if ((normalized === 'warp-auto-efficient' || normalized === 'warp-auto-powerful') && dominantModel) { - return dominantModel - } - return normalized -} - -function assignCommandBlocksToExchanges( - blocks: WarpBlockRow[], - exchanges: ParsedExchange[], -): Map { - const toolsByExchange = new Map() - - function getOrCreate(exchangeId: string): ExchangeToolInfo { - const existing = toolsByExchange.get(exchangeId) - if (existing) return existing - const created: ExchangeToolInfo = { tools: [], bashCommands: [] } - toolsByExchange.set(exchangeId, created) - return created - } - - for (const block of blocks) { - const blockStartMs = parseTimestamp(block.start_ts) - if (blockStartMs === null) continue - - let targetExchange: ParsedExchange | null = null - for (const exchange of exchanges) { - if (exchange.startMs > blockStartMs) break - targetExchange = exchange - } - if (!targetExchange) continue - - const info = getOrCreate(targetExchange.exchange_id) - if (!info.tools.includes('Bash')) info.tools.push('Bash') - - const commandText = blobToText(block.stylized_command) - for (const command of extractBashCommands(commandText)) { - if (!info.bashCommands.includes(command)) info.bashCommands.push(command) - } - } - - return toolsByExchange -} - function decodeSourcePath(path: string): { dbPath: string; conversationId: string } { const splitIndex = path.lastIndexOf(':') if (splitIndex <= 0) return { dbPath: path, conversationId: '' } @@ -312,113 +69,33 @@ function validateSchema(db: SqliteDatabase): boolean { } } -function createParser(source: SessionSource, seenKeys: Set): SessionParser { +// 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: WarpDecodedCall): ParsedProviderCall { return { - async *parse(): AsyncGenerator { - if (!isSqliteAvailable()) { - process.stderr.write(getSqliteLoadError() + '\n') - return - } - - const { dbPath, conversationId } = decodeSourcePath(source.path) - if (!conversationId) return - - let db: SqliteDatabase - try { - db = openDatabase(dbPath) - } catch (err) { - process.stderr.write(`codeburn: cannot open Warp database: ${err instanceof Error ? err.message : err}\n`) - return - } - - try { - if (!validateSchema(db)) return - - const conversations = db.query( - `SELECT conversation_id, conversation_data, last_modified_at - FROM agent_conversations - WHERE conversation_id = ? - LIMIT 1`, - [conversationId], - ) - if (conversations.length === 0) return - - const exchanges = db.query( - `SELECT exchange_id, conversation_id, start_ts, input, working_directory, output_status, model_id, planning_model_id, coding_model_id - FROM ai_queries - WHERE conversation_id = ? - ORDER BY start_ts ASC`, - [conversationId], - ) - - const parsedExchanges: ParsedExchange[] = [] - for (const exchange of exchanges) { - if (!isFinalStatus(exchange.output_status)) continue - const startMs = parseTimestamp(exchange.start_ts) - if (startMs === null) continue - parsedExchanges.push({ ...exchange, startMs }) - } - if (parsedExchanges.length === 0) return - - const blocks = db.query( - `SELECT block_id, start_ts, CAST(stylized_command AS BLOB) AS stylized_command - FROM blocks - WHERE ai_metadata IS NOT NULL - AND ai_metadata <> '' - AND json_extract(ai_metadata, '$.conversation_id') = ? - ORDER BY start_ts ASC`, - [conversationId], - ) - - const { tokenBudget, dominantModel } = extractTokenBudget(conversations[0]!.conversation_data) - const weights = parsedExchanges.map(exchange => estimateWeight(exchange.input)) - const fallbackBudget = weights.reduce((sum, weight) => sum + weight, 0) - const allocatedTokens = allocateTokens(weights, tokenBudget > 0 ? tokenBudget : fallbackBudget) - const toolsByExchange = assignCommandBlocksToExchanges(blocks, parsedExchanges) - - for (let index = 0; index < parsedExchanges.length; index++) { - const exchange = parsedExchanges[index]! - const deduplicationKey = `warp:${conversationId}:${exchange.exchange_id}` - if (seenKeys.has(deduplicationKey)) continue - - const timestamp = new Date(exchange.startMs).toISOString() - const model = resolveModelForExchange(exchange, dominantModel) - const inputTokens = allocatedTokens[index] ?? 0 - const exchangeTools = toolsByExchange.get(exchange.exchange_id) ?? { tools: [], bashCommands: [] } - const userMessage = extractUserMessage(exchange.input).slice(0, 500) - const projectPath = exchange.working_directory?.trim() || undefined - const project = projectPath ? sanitizeProject(projectPath) : source.project - - seenKeys.add(deduplicationKey) - yield { - provider: 'warp', - model, - inputTokens, - // Warp exposes only conversation-level usage totals in these tables, - // so we cannot reliably split per-exchange input vs output tokens. - outputTokens: 0, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens: 0, - webSearchRequests: 0, - costBasis: 'estimated', - costIsEstimated: true, - tools: exchangeTools.tools, - bashCommands: exchangeTools.bashCommands, - timestamp, - speed: 'standard', - deduplicationKey, - userMessage, - sessionId: conversationId, - project, - projectPath, - } - } - } finally { - db.close() - } - }, + provider: 'warp', + 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, + bashCommands: [...new Set(rich.rawBashCommands.flatMap(c => extractBashCommands(c)))], + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + userMessage: rich.userMessage, + sessionId: rich.sessionId, + project: rich.project, + projectPath: rich.projectPath, } } @@ -467,7 +144,7 @@ async function discoverFromDb(dbPath: string): Promise { } export function createWarpProvider(dbPathOverride?: string): Provider { - return { + return createBridgedProvider({ name: 'warp', displayName: 'Warp', @@ -490,10 +167,71 @@ export function createWarpProvider(dbPathOverride?: string): Provider { return sessions }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createParser(source, seenKeys) + // I/O adapter: open the sqlite database host-side, run the conversation + + // exchange + block queries, textualize command BLOBs, and return the plain + // row objects for the core decoder. + async readRecords(source: SessionSource): Promise { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return null + } + + const { dbPath, conversationId } = decodeSourcePath(source.path) + if (!conversationId) return null + + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch (err) { + process.stderr.write(`codeburn: cannot open Warp database: ${err instanceof Error ? err.message : err}\n`) + return null + } + + try { + if (!validateSchema(db)) return null + + const conversations = db.query( + `SELECT conversation_id, conversation_data, last_modified_at + FROM agent_conversations + WHERE conversation_id = ? + LIMIT 1`, + [conversationId], + ) + if (conversations.length === 0) return null + + const exchanges = db.query( + `SELECT exchange_id, conversation_id, start_ts, input, working_directory, output_status, model_id, planning_model_id, coding_model_id + FROM ai_queries + WHERE conversation_id = ? + ORDER BY start_ts ASC`, + [conversationId], + ) + + const rawBlocks = db.query( + `SELECT block_id, start_ts, CAST(stylized_command AS BLOB) AS stylized_command + FROM blocks + WHERE ai_metadata IS NOT NULL + AND ai_metadata <> '' + AND json_extract(ai_metadata, '$.conversation_id') = ? + ORDER BY start_ts ASC`, + [conversationId], + ) + + const blocks: WarpBlockRow[] = rawBlocks.map(block => ({ + block_id: block.block_id, + start_ts: block.start_ts, + stylized_command: blobToText(block.stylized_command), + })) + + return [{ conversationId, conversation: conversations[0]!, exchanges, blocks, sourceProject: source.project }] + } finally { + db.close() + } }, - } + + decode: decodeWarp, + toProviderCall, + }) } export const warp = createWarpProvider() diff --git a/packages/cli/tests/providers/cursor-agent-bridge.test.ts b/packages/cli/tests/providers/cursor-agent-bridge.test.ts new file mode 100644 index 00000000..c7c28bc8 --- /dev/null +++ b/packages/cli/tests/providers/cursor-agent-bridge.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createCursorAgentProvider } from '../../src/providers/cursor-agent.js' +import { priceProviderCall } from '../../src/pricing-pass.js' +import { estimateTokensFromChars } from '../../src/token-estimate.js' +import type { ParsedProviderCall, Provider, SessionSource } from '../../src/providers/types.js' +import { isSqliteAvailable } from '../../src/sqlite.js' + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +const FIXED_UUID = '123e4567-e89b-12d3-a456-426614174000' + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tempRoots: string[] = [] + +beforeEach(() => { + tempRoots = [] +}) + +afterEach(async () => { + await Promise.all(tempRoots.filter(existsSync).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +async function makeBaseDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'cursor-agent-bridge-test-')) + tempRoots.push(dir) + return dir +} + +async function collect(provider: Provider, source: SessionSource): Promise { + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + return calls +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = require('node:sqlite') + const db = new Database(dbPath) + fn(db) + db.close() +} + +async function buildFixture(baseDir: string): Promise { + const transcriptDir = join(baseDir, 'projects', 'my-proj', 'agent-transcripts') + const aiTrackingDir = join(baseDir, 'ai-tracking') + await mkdir(transcriptDir, { recursive: true }) + await mkdir(aiTrackingDir, { recursive: true }) + + const userText = 'explain parser output' + const assistantText = 'first line\nsecond line' + const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`) + + await writeFile( + transcriptPath, + `user:\n${userText}\nA:\n${assistantText}\n`, + ) + + const dbPath = join(aiTrackingDir, 'ai-code-tracking.db') + withTestDb(dbPath, (db) => { + db.exec('CREATE TABLE conversation_summaries (conversationId TEXT, title TEXT, tldr TEXT, model TEXT, mode TEXT, updatedAt INTEGER)') + db.prepare('INSERT INTO conversation_summaries (conversationId, title, tldr, model, mode, updatedAt) VALUES (?, ?, ?, ?, ?, ?)') + .run(FIXED_UUID, 'Demo title', '', 'claude-4.6-sonnet', 'agent', 1735689600000) + }) +} + +describe('cursor-agent bridge — fixture parity', () => { + it('the bridged provider reproduces the pre-migration decode byte-for-byte', async () => { + const baseDir = await makeBaseDir() + await buildFixture(baseDir) + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const raw = await collect(provider, source) + + // Golden captured from the unmodified cursor-agent provider over this fixture. + const userText = 'explain parser output' + const assistantText = 'first line\nsecond line' + const GOLDEN: ParsedProviderCall[] = [ + { + provider: 'cursor-agent', + model: 'claude-4.6-sonnet', + inputTokens: estimateTokensFromChars(userText.length), + outputTokens: estimateTokensFromChars(assistantText.length), + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + tools: [], + bashCommands: [], + timestamp: '2025-01-01T00:00:00.000Z', + speed: 'standard', + deduplicationKey: `cursor-agent:${FIXED_UUID}:0`, + userMessage: userText, + sessionId: FIXED_UUID, + }, + ] + + expect(raw).toEqual(GOLDEN) + }) + + it('derives a sha1 session id host-side for a non-uuid transcript filename', async () => { + // The uuid-stem-vs-sha1 choice is host-side; the decoder consumes the id + // the host derived, so this arm is pinned here rather than in core. + const baseDir = await makeBaseDir() + const transcriptDir = join(baseDir, 'projects', 'my-proj', 'agent-transcripts') + await mkdir(transcriptDir, { recursive: true }) + await writeFile( + join(transcriptDir, 'not-a-uuid.txt'), + 'user:\nhello\nA:\nworld\n', + ) + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const calls = await collect(provider, source) + + expect(calls).toHaveLength(1) + expect(calls[0]!.sessionId).toMatch(/^[0-9a-f]{16}$/) + expect(calls[0]!.deduplicationKey).toBe(`cursor-agent:${calls[0]!.sessionId}:0`) + }) + + it('the priced output survives the pricing pass with only costUSD added', async () => { + const baseDir = await makeBaseDir() + await buildFixture(baseDir) + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const raw = await collect(provider, source) + 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 baseDir = await makeBaseDir() + await buildFixture(baseDir) + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const seen = new Set() + + const first: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call) + const second: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call) + + expect(first).toHaveLength(1) + expect(second).toEqual([]) + }) +}) diff --git a/packages/cli/tests/providers/devin-bridge.test.ts b/packages/cli/tests/providers/devin-bridge.test.ts new file mode 100644 index 00000000..13974aee --- /dev/null +++ b/packages/cli/tests/providers/devin-bridge.test.ts @@ -0,0 +1,261 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createDevinProvider } from '../../src/providers/devin.js' +import { priceProviderCall } from '../../src/pricing-pass.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tmpDir: string +let originalHome: string | undefined + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'devin-bridge-test-')) + originalHome = process.env['HOME'] + process.env['HOME'] = tmpDir +}) + +afterEach(async () => { + if (originalHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = originalHome + await rm(tmpDir, { recursive: true, force: true }) +}) + +async function configureDevinRate(rate = 1): Promise { + await mkdir(join(tmpDir, '.config', 'codeburn'), { recursive: true }) + await writeFile(join(tmpDir, '.config', 'codeburn', 'config.json'), JSON.stringify({ + devin: { acuUsdRate: rate }, + })) +} + +function createDevinDb(cliDir: string): string { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const dbPath = join(cliDir, 'sessions.db') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + working_directory TEXT, + backend_type TEXT, + model TEXT, + agent_mode TEXT, + created_at INTEGER, + last_activity_at INTEGER, + title TEXT, + hidden INTEGER NOT NULL DEFAULT 0 + ) + `) + db.close() + return dbPath +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + try { + fn(db) + } finally { + db.close() + } +} + +async function writeTranscript(name: string, transcript: unknown): Promise { + const transcriptsDir = join(tmpDir, 'transcripts') + await mkdir(transcriptsDir, { recursive: true }) + const filePath = join(transcriptsDir, name) + await writeFile(filePath, JSON.stringify(transcript)) + return filePath +} + +async function collect(source: SessionSource): Promise { + const provider = createDevinProvider(tmpDir) + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + return calls +} + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +// Golden captured from the legacy in-CLI decode over the fixture below. +// Covers: transcript JSON parsing, ATIF v1.7 step metrics, user-message +// threading, generation_model display-name resolution, tool_names, sessions.db +// enrichment (project/projectPath/model/timestamp fallback), and per-step +// committed ACU cost converted to costUSD via the configured rate. +const GOLDEN: ParsedProviderCall[] = [ + { + provider: 'devin', + model: 'Opus 4.6', + inputTokens: 100, + outputTokens: 20, + cacheCreationInputTokens: 10, + cacheReadInputTokens: 5, + cachedInputTokens: 5, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0.123, + tools: ['read_file'], + bashCommands: [], + timestamp: '2027-01-15T08:00:01.000Z', + speed: 'standard', + deduplicationKey: 'devin:bridge-session:2', + userMessage: 'add devin bridge test', + sessionId: 'bridge-session', + project: 'codeburn-bridge', + projectPath: '/Users/me/projects/codeburn-bridge', + }, +] + +skipUnlessSqlite('devin bridge — fixture parity', () => { + it('the bridged provider reproduces the pre-migration decode byte-for-byte', async () => { + await configureDevinRate() + const dbPath = createDevinDb(tmpDir) + withTestDb(dbPath, (db) => { + db.prepare(` + INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('bridge-session', '/Users/me/projects/codeburn-bridge', 'claude-sonnet-4-6', 1_800_000_000, 1_800_000_010, 'Bridge Test', 0) + }) + + const filePath = await writeTranscript('bridge-session.json', { + schema_version: '1.7', + session_id: 'bridge-session', + agent: { name: 'devin', version: '2.0', model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + message: 'add devin bridge test', + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + source: 'assistant', + model_name: 'step-model', + message: 'I will read the file first', + tool_calls: [{ tool_call_id: 'tc1', function_name: 'read_file', arguments: { path: 'src/main.ts' } }], + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.123, + generation_model: 'claude-opus-4-6', + metrics: { input_tokens: 100, output_tokens: 20, cache_creation_tokens: 10, cache_read_tokens: 5 }, + }, + }, + ], + }) + + const source: SessionSource = { path: filePath, project: 'devin', provider: 'devin' } + expect(await collect(source)).toEqual(GOLDEN) + }) + + it('the measured-cost output survives the pricing pass unchanged', async () => { + await configureDevinRate() + const dbPath = createDevinDb(tmpDir) + withTestDb(dbPath, (db) => { + db.prepare(` + INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('bridge-session', '/Users/me/projects/codeburn-bridge', 'claude-sonnet-4-6', 1_800_000_000, 1_800_000_010, 'Bridge Test', 0) + }) + + await writeTranscript('bridge-session.json', { + schema_version: '1.7', + session_id: 'bridge-session', + agent: { name: 'devin', version: '2.0' }, + steps: [ + { + step_id: 2, + source: 'assistant', + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.123, + metrics: { input_tokens: 100 }, + }, + }, + ], + }) + + const source: SessionSource = { path: join(tmpDir, 'transcripts', 'bridge-session.json'), project: 'devin', provider: 'devin' } + const raw = await collect(source) + const priced = raw.map(priceProviderCall) + expect(priced).toEqual(raw) + }) + + it('derives the session id from the filename host-side when the transcript omits session_id', async () => { + // Deriving the id (transcript session_id, else the .json basename) is + // host-side; the decoder consumes it, so this arm is pinned here. + await configureDevinRate(1) + createDevinDb(tmpDir) + const filePath = await writeTranscript('fallback-session.json', { + schema_version: '1.7', + agent: { name: 'devin', version: '2.0' }, + steps: [ + { + step_id: 1, + source: 'assistant', + message: 'working', + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 10 }, + }, + }, + ], + }) + + const source: SessionSource = { path: filePath, project: 'devin', provider: 'devin' } + const calls = await collect(source) + expect(calls).toHaveLength(1) + expect(calls[0]!.sessionId).toBe('fallback-session') + expect(calls[0]!.deduplicationKey).toBe('devin:fallback-session:1') + }) + + it('dedup threads through the host-owned seenKeys set', async () => { + await configureDevinRate() + const dbPath = createDevinDb(tmpDir) + withTestDb(dbPath, (db) => { + db.prepare(` + INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('bridge-session', '/Users/me/projects/codeburn-bridge', 'claude-sonnet-4-6', 1_800_000_000, 1_800_000_010, 'Bridge Test', 0) + }) + + await writeTranscript('bridge-session.json', { + schema_version: '1.7', + session_id: 'bridge-session', + agent: { name: 'devin', version: '2.0' }, + steps: [ + { + step_id: 2, + source: 'assistant', + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.123, + metrics: { input_tokens: 100 }, + }, + }, + ], + }) + + const source: SessionSource = { path: join(tmpDir, 'transcripts', 'bridge-session.json'), project: 'devin', provider: 'devin' } + const provider = createDevinProvider(tmpDir) + const seen = new Set() + const first: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call) + const second: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call) + expect(first).toHaveLength(1) + expect(second).toEqual([]) + }) +}) diff --git a/packages/cli/tests/providers/hermes-bridge.test.ts b/packages/cli/tests/providers/hermes-bridge.test.ts new file mode 100644 index 00000000..69f55e26 --- /dev/null +++ b/packages/cli/tests/providers/hermes-bridge.test.ts @@ -0,0 +1,272 @@ +import { mkdir, mkdtemp, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createHermesProvider } from '../../src/providers/hermes.js' +import { priceProviderCall } from '../../src/pricing-pass.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tmpDir: string +let originalHermesHome: string | undefined + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'hermes-bridge-test-')) + originalHermesHome = process.env['HERMES_HOME'] +}) + +afterEach(async () => { + if (originalHermesHome === undefined) delete process.env['HERMES_HOME'] + else process.env['HERMES_HOME'] = originalHermesHome + await rm(tmpDir, { recursive: true, force: true }) +}) + +function createHermesDb(homeDir: string): string { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const dbPath = join(homeDir, 'state.db') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + model TEXT, + cwd TEXT, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + api_call_count INTEGER DEFAULT 0, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + started_at REAL, + ended_at REAL, + title TEXT + ) + `) + db.exec(` + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL + ) + `) + db.close() + return dbPath +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + try { + fn(db) + } finally { + db.close() + } +} + +function insertSession(db: TestDb, values: { + id: string + source?: string + model?: string + cwd?: string | null + billingProvider?: string + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + reasoningTokens: number + estimatedCost?: number | null + actualCost?: number | null + apiCalls?: number + toolCalls?: number + startedAt: number + title?: string +}): void { + db.prepare( + `INSERT INTO sessions ( + id, source, model, cwd, billing_provider, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens, reasoning_tokens, estimated_cost_usd, + actual_cost_usd, api_call_count, tool_call_count, started_at, title + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + values.id, + values.source ?? 'cli', + values.model ?? 'gpt-5.5', + values.cwd ?? null, + values.billingProvider ?? 'openai-codex', + values.inputTokens, + values.outputTokens, + values.cacheReadTokens, + values.cacheWriteTokens, + values.reasoningTokens, + values.estimatedCost ?? null, + values.actualCost ?? null, + values.apiCalls ?? 1, + values.toolCalls ?? 0, + values.startedAt, + values.title ?? values.id, + ) +} + +async function collect(source: SessionSource): Promise { + const provider = createHermesProvider(tmpDir) + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + return calls +} + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +// Golden captured from the legacy in-CLI decode over the fixture below. +// Covers: sqlite row decode, token buckets, costBasis='measured' precedence, +// tool-name map (incl. composio MCP before generic MCP), tool-result tool_name, +// project from sessions.cwd, dedup key, turnId, userMessage, toolSequence. +const GOLDEN: ParsedProviderCall[] = [ + { + provider: 'hermes', + model: 'claude-sonnet-4-20250514', + inputTokens: 1234, + outputTokens: 567, + cacheCreationInputTokens: 12, + cacheReadInputTokens: 89, + cachedInputTokens: 89, + reasoningTokens: 34, + webSearchRequests: 0, + costUSD: 0.0045, + costBasis: 'measured', + costIsEstimated: false, + tools: ['Read', 'Bash', 'MCP'], + bashCommands: ['npm test && npm run build'], + timestamp: '2026-05-23T15:13:20.000Z', + speed: 'standard', + deduplicationKey: 'hermes:default:bridge-session', + turnId: 'bridge-session:session', + toolSequence: [ + [ + { tool: 'Read', file: '/tmp/bridge.ts' }, + { tool: 'Bash', command: 'npm test && npm run build' }, + { tool: 'MCP' }, + ], + ], + userMessage: 'Add Hermes bridge parity test', + sessionId: 'bridge-session', + project: 'Users-me-projects-codeburn-bridge', + projectPath: '/Users/me/projects/codeburn-bridge', + }, +] + +skipUnlessSqlite('hermes bridge — fixture parity', () => { + it('the bridged provider reproduces the pre-migration decode byte-for-byte', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'bridge-session', + source: 'tui', + model: 'claude-sonnet-4-20250514', + cwd: '/Users/me/projects/codeburn-bridge', + inputTokens: 1234, + outputTokens: 567, + cacheReadTokens: 89, + cacheWriteTokens: 12, + reasoningTokens: 34, + actualCost: 0.0045, + apiCalls: 3, + toolCalls: 2, + startedAt: 1779549200, + title: 'Bridge Test', + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('bridge-session', 'user', 'Add Hermes bridge parity test', 1779549201) + db.prepare('INSERT INTO messages (session_id, role, content, tool_calls, timestamp) VALUES (?, ?, ?, ?, ?)') + .run( + 'bridge-session', + 'assistant', + '', + JSON.stringify([ + { function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/bridge.ts' }) } }, + { function: { name: 'terminal', arguments: JSON.stringify({ command: 'npm test && npm run build' }) } }, + { function: { name: 'mcp_composio_GMAIL_SEND_EMAIL', arguments: '{}' } }, + ]), + 1779549202, + ) + db.prepare('INSERT INTO messages (session_id, role, content, tool_name, timestamp) VALUES (?, ?, ?, ?, ?)') + .run('bridge-session', 'tool', null, 'read_file', 1779549203) + }) + + const source: SessionSource = { path: `${dbPath}#hermes-session=bridge-session`, project: 'hermes', provider: 'hermes' } + expect(await collect(source)).toEqual(GOLDEN) + }) + + it('the measured-cost output survives the pricing pass unchanged', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'bridge-session', + model: 'claude-sonnet-4-20250514', + inputTokens: 1234, + outputTokens: 567, + cacheReadTokens: 89, + cacheWriteTokens: 12, + reasoningTokens: 34, + actualCost: 0.0045, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('bridge-session', 'user', 'Add Hermes bridge parity test', 1779549201) + }) + + const source: SessionSource = { path: `${dbPath}#hermes-session=bridge-session`, project: 'hermes', provider: 'hermes' } + const raw = await collect(source) + const priced = raw.map(priceProviderCall) + expect(priced).toEqual(raw) + }) + + it('dedup threads through the host-owned seenKeys set', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'bridge-session', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + startedAt: 1779549200, + }) + }) + + const source: SessionSource = { path: `${dbPath}#hermes-session=bridge-session`, project: 'hermes', provider: 'hermes' } + const provider = createHermesProvider(tmpDir) + const seen = new Set() + const first: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call) + const second: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call) + expect(first).toHaveLength(1) + expect(second).toEqual([]) + }) +}) diff --git a/packages/cli/tests/providers/quickdesk-bridge.test.ts b/packages/cli/tests/providers/quickdesk-bridge.test.ts new file mode 100644 index 00000000..3b063b43 --- /dev/null +++ b/packages/cli/tests/providers/quickdesk-bridge.test.ts @@ -0,0 +1,253 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { quickdesk } from '../../src/providers/quickdesk.js' +import { priceProviderCall } from '../../src/pricing-pass.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tmpDir: string +let originalQuickworkHome: string | undefined + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'quickdesk-bridge-test-')) + originalQuickworkHome = process.env['QUICKWORK_HOME'] + process.env['QUICKWORK_HOME'] = tmpDir +}) + +afterEach(async () => { + if (originalQuickworkHome === undefined) delete process.env['QUICKWORK_HOME'] + else process.env['QUICKWORK_HOME'] = originalQuickworkHome + await rm(tmpDir, { recursive: true, force: true }) +}) + +async function writeMetrics(basePath: string, date: string, lines: Array | string>): Promise { + const metricsDir = join(basePath, 'metrics') + await mkdir(metricsDir, { recursive: true }) + const path = join(metricsDir, `metrics-${date}.jsonl`) + await writeFile(path, lines.map(line => typeof line === 'string' ? line : JSON.stringify(line)).join('\n') + '\n') + return path +} + +async function createSessionsDb(basePath: string): Promise { + const sessionsDir = join(basePath, 'sessions') + await mkdir(sessionsDir, { recursive: true }) + const dbPath = join(sessionsDir, 'sessions.db') + const { DatabaseSync: Database } = requireForTest('node:sqlite') as { + DatabaseSync: new (path: string) => TestDb + } + const db = new Database(dbPath) + db.exec('PRAGMA journal_mode = WAL') + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT, + created_at REAL, + updated_at REAL, + message_count INTEGER, + agent_mode TEXT, + deleted_at REAL + ) + `) + db.exec(` + CREATE TABLE session_messages ( + session_id TEXT, + role TEXT, + content TEXT, + timestamp REAL, + tool_names TEXT + ) + `) + db.close() + return dbPath +} + +function withDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') as { + DatabaseSync: new (path: string) => TestDb + } + const db = new Database(dbPath) + try { + fn(db) + } finally { + db.close() + } +} + +async function collect(): Promise { + const sources: SessionSource[] = await quickdesk.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 quickdesk.createSessionParser(source, seen).parse()) { + calls.push(call) + } + } + return calls +} + +async function buildFixture(): Promise { + const profileBase = join(tmpDir, 'profiles', 'bridge-data') + await mkdir(join(tmpDir, 'profiles'), { recursive: true }) + await writeFile( + join(tmpDir, 'profiles.json'), + JSON.stringify({ last_active: 'bridge-profile', entries: [{ id: 'bridge-profile', data_path: 'profiles/bridge-data' }] }), + ) + + const dbPath = await createSessionsDb(profileBase) + withDb(dbPath, db => { + db.prepare( + 'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run('bridge-metered', 'Metered session', 1783987200, 1783987300, 2, 'agent', null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('bridge-metered', 'user', 'metered prompt', 1783987201, null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('bridge-metered', 'assistant', 'metered answer', 1783987202, '["read_file"]') + + db.prepare( + 'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run('bridge-estimate', 'Estimate session', 1783900800, 1783900900, 3, 'agent', null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('bridge-estimate', 'user', 'estimate prompt', 1783900801, null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('bridge-estimate', 'tool', 'tool output', 1783900802, '["run_command"]') + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('bridge-estimate', 'assistant', 'estimate answer', 1783900803, null) + + db.prepare( + 'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run('bridge-deleted', 'Deleted session', 1784073600, 1784073700, 2, 'agent', 1784073800) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('bridge-deleted', 'user', 'deleted prompt', 1784073601, null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('bridge-deleted', 'assistant', 'deleted answer', 1784073602, null) + }) + + await writeMetrics(profileBase, '2026-07-14', [ + { session_id: 'bridge-metered', ToolName: 'write_file' }, + { + _aws: { Timestamp: 1783987200123 }, + session_id: 'bridge-metered', + thread_id: 'thread-1', + Model: 'claude-sonnet-4-5', + InputTokens: 120, + OutputTokens: 30, + CostUSD: 0.0042, + }, + ]) +} + + + +function golden(): ParsedProviderCall[] { + const projectPath = join(tmpDir, 'profiles', 'bridge-data') + return [ + { + provider: 'quickdesk', + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + bashCommands: [], + speed: 'standard', + project: 'bridge-profile', + projectPath, + model: 'claude-sonnet-4-5', + inputTokens: 120, + outputTokens: 30, + costUSD: 0.0042, + costBasis: 'measured', + costIsEstimated: false, + tools: ['Edit', 'Read'], + timestamp: '2026-07-14T00:00:00.123Z', + deduplicationKey: 'quickdesk:bridge-metered:2026-07-14T00:00:00.123Z:claude-sonnet-4-5:120:30', + userMessage: 'metered prompt', + sessionId: 'bridge-metered', + }, + { + provider: 'quickdesk', + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + bashCommands: [], + speed: 'standard', + project: 'bridge-profile', + projectPath, + model: 'quickdesk-auto', + inputTokens: 7, + outputTokens: 4, + costBasis: 'estimated', + costIsEstimated: true, + tools: ['Bash'], + timestamp: '2026-07-13T00:00:00.000Z', + deduplicationKey: 'quickdesk-est:bridge-estimate', + userMessage: 'estimate prompt', + sessionId: 'bridge-estimate', + }, + ] +} + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +skipUnlessSqlite('quickdesk bridge — fixture parity', () => { + it('the bridged provider reproduces the pre-migration decode byte-for-byte', async () => { + await buildFixture() + expect(await collect()).toEqual(golden()) + }) + + it('the priced output survives the pricing pass with only costUSD added', async () => { + await buildFixture() + 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) + if (raw[i]!.costBasis === 'measured') { + // Measured calls already carry costUSD from the provider; pricing leaves them byte-identical. + expect(call).toEqual(raw[i]) + } else { + const { costUSD, ...rest } = call + expect(rest).toEqual(raw[i]) + } + }) + }) + + it('dedup threads through the host-owned seenKeys set', async () => { + await buildFixture() + const sources = await quickdesk.discoverSessions() + sources.sort((a, b) => a.path.localeCompare(b.path)) + const seen = new Set() + const first: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of quickdesk.createSessionParser(source, seen).parse()) first.push(call) + } + const second: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of quickdesk.createSessionParser(source, seen).parse()) second.push(call) + } + expect(first.length).toBeGreaterThan(0) + expect(second).toEqual([]) + }) +}) diff --git a/packages/cli/tests/providers/warp-bridge.test.ts b/packages/cli/tests/providers/warp-bridge.test.ts new file mode 100644 index 00000000..b9424d35 --- /dev/null +++ b/packages/cli/tests/providers/warp-bridge.test.ts @@ -0,0 +1,404 @@ +import { mkdtemp, rm } from 'fs/promises' +import { mkdirSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { createWarpProvider } from '../../src/providers/warp.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import { priceProviderCall } from '../../src/pricing-pass.js' +import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +type QueryFixture = { + exchangeId: string + conversationId: string + startTs: string + input: string + outputStatus?: string + modelId?: string + workingDirectory?: string | null +} + +type BlockFixture = { + blockId: string + conversationId: string + startTs: string + completedTs: string + exitCode: number + command: string +} + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'warp-provider-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function createWarpDb(dir: string): string { + mkdirSync(dir, { recursive: true }) + const dbPath = join(dir, 'warp.sqlite') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE IF NOT EXISTS agent_conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + conversation_data TEXT NOT NULL, + last_modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + db.exec(` + CREATE TABLE IF NOT EXISTS ai_queries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + exchange_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + start_ts DATETIME NOT NULL, + input TEXT NOT NULL, + working_directory TEXT, + output_status TEXT NOT NULL, + model_id TEXT NOT NULL DEFAULT '', + planning_model_id TEXT NOT NULL DEFAULT '', + coding_model_id TEXT NOT NULL DEFAULT '' + ) + `) + db.exec(` + CREATE TABLE IF NOT EXISTS blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pane_leaf_uuid BLOB NOT NULL, + stylized_command BLOB NOT NULL, + stylized_output BLOB NOT NULL, + pwd TEXT, + git_branch TEXT, + virtual_env TEXT, + conda_env TEXT, + exit_code INTEGER NOT NULL, + did_execute BOOLEAN NOT NULL, + completed_ts DATETIME, + start_ts DATETIME, + ps1 TEXT, + honor_ps1 BOOLEAN NOT NULL DEFAULT 0, + shell TEXT, + user TEXT, + host TEXT, + is_background BOOLEAN NOT NULL DEFAULT 0, + rprompt TEXT, + prompt_snapshot TEXT, + block_id TEXT NOT NULL DEFAULT '', + ai_metadata TEXT, + is_local BOOLEAN, + agent_view_visibility TEXT, + git_branch_name TEXT + ) + `) + db.close() + return dbPath +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + try { + fn(db) + } finally { + db.close() + } +} + +function insertConversation( + db: TestDb, + conversationId: string, + conversationData: unknown, + lastModifiedAt = '2026-05-18 10:10:00', +): void { + db.prepare( + 'INSERT INTO agent_conversations (conversation_id, conversation_data, last_modified_at) VALUES (?, ?, ?)', + ).run(conversationId, JSON.stringify(conversationData), lastModifiedAt) +} + +function insertQuery(db: TestDb, q: QueryFixture): void { + db.prepare( + `INSERT INTO ai_queries ( + exchange_id, conversation_id, start_ts, input, working_directory, output_status, model_id, planning_model_id, coding_model_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, '', '')`, + ).run( + q.exchangeId, + q.conversationId, + q.startTs, + q.input, + q.workingDirectory ?? null, + q.outputStatus ?? '"Completed"', + q.modelId ?? 'auto-efficient', + ) +} + +function insertBlock(db: TestDb, b: BlockFixture): void { + db.prepare( + `INSERT INTO blocks ( + pane_leaf_uuid, stylized_command, stylized_output, exit_code, did_execute, + completed_ts, start_ts, block_id, ai_metadata + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + Buffer.from([0]), + b.command, + '', + b.exitCode, + 1, + b.completedTs, + b.startTs, + b.blockId, + JSON.stringify({ + requested_command_action_id: `call-${b.blockId}`, + conversation_id: b.conversationId, + }), + ) +} + +async function buildFixtureDb(dir: string): Promise { + const dbPath = createWarpDb(dir) + withTestDb(dbPath, (db) => { + insertConversation(db, 'conv-1', { + conversation_usage_metadata: { + token_usage: [ + { + model_id: 'GPT-5.3 Codex (medium reasoning)', + warp_tokens: 300, + byok_tokens: 0, + warp_token_usage_by_category: { primary_agent: 300 }, + byok_token_usage_by_category: {}, + }, + { + model_id: 'Claude Haiku 4.5', + warp_tokens: 90, + byok_tokens: 0, + warp_token_usage_by_category: { full_terminal_use: 90 }, + byok_token_usage_by_category: {}, + }, + ], + }, + }) + insertQuery(db, { + exchangeId: 'ex-1', + conversationId: 'conv-1', + startTs: '2026-05-18 10:00:00.000000', + input: JSON.stringify([{ Query: { text: 'short prompt' } }]), + modelId: 'auto-efficient', + workingDirectory: '/Users/test/project-a', + }) + insertQuery(db, { + exchangeId: 'ex-2', + conversationId: 'conv-1', + startTs: '2026-05-18 10:03:00.000000', + input: JSON.stringify([{ Query: { text: 'longer prompt with substantially more detail for weighting' } }]), + modelId: 'auto-efficient', + workingDirectory: '/Users/test/project-a', + }) + + insertConversation(db, 'conv-2', { + conversation_usage_metadata: { + token_usage: [ + { + model_id: 'GPT-5.3 Codex (medium reasoning)', + warp_tokens: 120, + byok_tokens: 0, + warp_token_usage_by_category: { primary_agent: 120 }, + byok_token_usage_by_category: {}, + }, + ], + }, + }) + insertQuery(db, { + exchangeId: 'ex-a', + conversationId: 'conv-2', + startTs: '2026-05-18 11:00:00.000000', + input: JSON.stringify([{ Query: { text: 'run tests' } }]), + }) + insertQuery(db, { + exchangeId: 'ex-b', + conversationId: 'conv-2', + startTs: '2026-05-18 11:05:00.000000', + input: JSON.stringify([{ Query: { text: 'summarize results' } }]), + }) + insertBlock(db, { + blockId: 'block-1', + conversationId: 'conv-2', + startTs: '2026-05-18 11:01:00.000000', + completedTs: '2026-05-18 11:01:04.000000', + exitCode: 0, + command: 'npm test && git status', + }) + }) + return dbPath +} + +async function collect(dbPath: string): Promise { + const provider = createWarpProvider(dbPath) + const sources: SessionSource[] = 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 skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +// Byte-identical parity gate for the warp bridge migration (phase 8). The +// GOLDEN below was captured from the legacy in-CLI decode run over the fixture +// above. Covers: token-budget allocation from conversation-level usage, +// model-alias resolution, command-block attribution to the nearest preceding +// exchange, Bash tool mapping + base-name extraction, and project path shaping. +const GOLDEN: ParsedProviderCall[] = [ + { + provider: 'warp', + model: 'gpt-5.3-codex', + inputTokens: 50, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + tools: [], + bashCommands: [], + timestamp: '2026-05-18T10:00:00.000Z', + speed: 'standard', + deduplicationKey: 'warp:conv-1:ex-1', + userMessage: 'short prompt', + sessionId: 'conv-1', + project: 'Users-test-project-a', + projectPath: '/Users/test/project-a', + }, + { + provider: 'warp', + model: 'gpt-5.3-codex', + inputTokens: 250, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + tools: [], + bashCommands: [], + timestamp: '2026-05-18T10:03:00.000Z', + speed: 'standard', + deduplicationKey: 'warp:conv-1:ex-2', + userMessage: 'longer prompt with substantially more detail for weighting', + sessionId: 'conv-1', + project: 'Users-test-project-a', + projectPath: '/Users/test/project-a', + }, + { + provider: 'warp', + model: 'gpt-5.3-codex', + inputTokens: 45, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + tools: ['Bash'], + bashCommands: ['npm', 'git'], + timestamp: '2026-05-18T11:00:00.000Z', + speed: 'standard', + deduplicationKey: 'warp:conv-2:ex-a', + userMessage: 'run tests', + sessionId: 'conv-2', + project: 'warp', + }, + { + provider: 'warp', + model: 'gpt-5.3-codex', + inputTokens: 75, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + tools: [], + bashCommands: [], + timestamp: '2026-05-18T11:05:00.000Z', + speed: 'standard', + deduplicationKey: 'warp:conv-2:ex-b', + userMessage: 'summarize results', + sessionId: 'conv-2', + project: 'warp', + }, +] + +skipUnlessSqlite('warp bridge — fixture parity', () => { + it('the bridged provider reproduces the pre-migration decode byte-for-byte', async () => { + const dbPath = await buildFixtureDb(tmpDir) + expect(await collect(dbPath)).toEqual(GOLDEN) + }) + + it('the priced output survives the pricing pass with only costUSD added', async () => { + const dbPath = await buildFixtureDb(tmpDir) + const raw = await collect(dbPath) + const priced = raw.map(priceProviderCall) + priced.forEach((call, i) => { + expect(typeof call.costUSD).toBe('number') + expect(Number.isFinite(call.costUSD)).toBe(true) + expect(call.costBasis).toBe('estimated') + const { costUSD, ...rest } = call + expect(rest).toEqual(raw[i]) + }) + }) + + it('toolDisplayName never resolves an inherited Object member', () => { + // Guard against re-expressing the run_command check as a bare object + // lookup: 'constructor'/'toString'/'__proto__' would then resolve to + // inherited members instead of passing through unchanged. + const provider = createWarpProvider('/nonexistent.sqlite') + expect(provider.toolDisplayName('run_command')).toBe('Bash') + for (const name of ['constructor', 'toString', '__proto__', 'hasOwnProperty', 'valueOf']) { + expect(provider.toolDisplayName(name)).toBe(name) + } + }) + + it('dedup threads through the host-owned seenKeys set', async () => { + const dbPath = await buildFixtureDb(tmpDir) + const provider = createWarpProvider(dbPath) + const sources = await provider.discoverSessions() + sources.sort((a, b) => a.path.localeCompare(b.path)) + 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.length).toBe(4) + expect(second).toEqual([]) + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 43f7d111..650ce289 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -118,6 +118,26 @@ "./providers/goose": { "types": "./dist/providers/goose/index.d.ts", "import": "./dist/providers/goose/index.js" + }, + "./providers/hermes": { + "types": "./dist/providers/hermes/index.d.ts", + "import": "./dist/providers/hermes/index.js" + }, + "./providers/warp": { + "types": "./dist/providers/warp/index.d.ts", + "import": "./dist/providers/warp/index.js" + }, + "./providers/cursor-agent": { + "types": "./dist/providers/cursor-agent/index.d.ts", + "import": "./dist/providers/cursor-agent/index.js" + }, + "./providers/quickdesk": { + "types": "./dist/providers/quickdesk/index.d.ts", + "import": "./dist/providers/quickdesk/index.js" + }, + "./providers/devin": { + "types": "./dist/providers/devin/index.d.ts", + "import": "./dist/providers/devin/index.js" } }, "files": [ diff --git a/packages/core/src/providers/cursor-agent/decode.ts b/packages/core/src/providers/cursor-agent/decode.ts new file mode 100644 index 00000000..6641f3cd --- /dev/null +++ b/packages/core/src/providers/cursor-agent/decode.ts @@ -0,0 +1,320 @@ +// @codeburn/core Cursor Agent decoder: pure decode over host-supplied records. +// The host reads the sqlite summary and the transcript file; this decoder parses +// the transcript into user/assistant turns and maps each turn to a rich, cost-free +// call. No fs / env / clock / sqlite / pricing / strip-ansi. + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { + AssistantTurn, + ConversationSummaryRow, + CursorAgentDecodedCall, + CursorAgentRecord, + ParsedTurn, +} from './types.js' + +const CHARS_PER_TOKEN = 4 +const MAX_USER_TEXT_LENGTH = 500 +const DIGITS_ONLY = /^\d+$/ +const USER_MARKER = /^\s*user:\s*/i +const ASSISTANT_MARKER = /^\s*A:\s*/ +const THINKING_MARKER = /^\s*\[Thinking\]\s*/ +const TOOL_CALL_MARKER = /^\s*\[Tool call\]\s*(.+?)\s*$/i +const TOOL_RESULT_MARKER = /^\s*\[Tool result\]\b/i +const USER_QUERY_OPEN = '' +const USER_QUERY_CLOSE = '' + +function estimateTokens(charCount: number): number { + if (charCount <= 0) return 0 + return Math.ceil(charCount / CHARS_PER_TOKEN) +} + +function parseToolName(raw: string): string { + const clean = raw.trim() + if (clean.length === 0) return 'unknown' + return clean.toLowerCase().replace(/\s+/g, '-') +} + +function normalizeTimestamp(raw: string | number | null | undefined): string | null { + if (raw === null || raw === undefined) return null + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (trimmed.length === 0) return null + if (DIGITS_ONLY.test(trimmed)) { + const num = Number(trimmed) + if (!Number.isNaN(num)) { + const ms = num < 1e12 ? num * 1000 : num + return new Date(ms).toISOString() + } + } + const parsed = new Date(trimmed) + if (!Number.isNaN(parsed.getTime())) return parsed.toISOString() + return null + } + + const ms = raw < 1e12 ? raw * 1000 : raw + return new Date(ms).toISOString() +} + +function extractUserQuery(userBlock: string): string { + const chunks: string[] = [] + let cursor = 0 + + while (cursor < userBlock.length) { + const openIndex = userBlock.indexOf(USER_QUERY_OPEN, cursor) + if (openIndex === -1) break + const start = openIndex + USER_QUERY_OPEN.length + const closeIndex = userBlock.indexOf(USER_QUERY_CLOSE, start) + if (closeIndex === -1) { + chunks.push(userBlock.slice(start).trim()) + break + } + chunks.push(userBlock.slice(start, closeIndex).trim()) + cursor = closeIndex + USER_QUERY_CLOSE.length + } + + const combined = chunks.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim() + return combined.slice(0, MAX_USER_TEXT_LENGTH) +} + +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 [] +} + +function parseJsonlTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolean } { + const lines = raw.split(/\r?\n/).filter(l => l.trim()) + if (lines.length === 0) return { turns: [], recognized: false } + + const turns: ParsedTurn[] = [] + let currentUserMessage = '' + + for (const line of lines) { + let entry: { role?: string; message?: { content?: Array<{ type?: string; text?: string; name?: string }> } } + try { + entry = JSON.parse(line) + } catch { + continue + } + + if (entry.role === 'user') { + const texts = normalizeContentBlocks(entry.message?.content) + .filter(c => c.type === 'text') + .map(c => c.text ?? '') + const combined = texts.join(' ') + currentUserMessage = extractUserQuery(combined) || combined.slice(0, MAX_USER_TEXT_LENGTH) + continue + } + + if (entry.role === 'assistant' && currentUserMessage) { + const content = normalizeContentBlocks(entry.message?.content) + const bodyParts: string[] = [] + const tools: string[] = [] + + for (const block of content) { + if (block.type === 'text' && block.text) { + bodyParts.push(block.text) + } else if (block.type === 'tool_use' && block.name) { + tools.push(`cursor:${block.name.toLowerCase()}`) + } + } + + turns.push({ + userMessage: currentUserMessage, + assistant: { + body: bodyParts.join('\n').trim(), + reasoning: '', + tools, + }, + }) + currentUserMessage = '' + } + } + + return { turns, recognized: turns.length > 0 } +} + +function parseTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolean } { + const lines = raw.split(/\r?\n/) + let recognized = false + + const pendingUsers: string[] = [] + const turns: ParsedTurn[] = [] + + let active: 'none' | 'user' | 'assistant' = 'none' + let userLines: string[] = [] + let assistantLines: string[] = [] + + const flushUser = () => { + if (userLines.length === 0) return + const userQuery = extractUserQuery(userLines.join('\n')) + if (userQuery.length > 0) pendingUsers.push(userQuery) + userLines = [] + } + + const flushAssistant = () => { + if (assistantLines.length === 0) return + + let output = '' + let reasoning = '' + const toolsByTurn = new Map() + + for (const line of assistantLines) { + if (TOOL_RESULT_MARKER.test(line)) continue + + const thinkingMatch = line.match(THINKING_MARKER) + if (thinkingMatch) { + const body = line.replace(THINKING_MARKER, '').trim() + if (body.length > 0) reasoning += `${body}\n` + continue + } + + const toolMatch = line.match(TOOL_CALL_MARKER) + if (toolMatch) { + const parsedTool = parseToolName(toolMatch[1] ?? '') + const toolKey = `cursor:${parsedTool}` + toolsByTurn.set(toolKey, true) + continue + } + + output += `${line}\n` + } + + if (pendingUsers.length > 0) { + const userMessage = pendingUsers.shift()! + const tools = Array.from(toolsByTurn.keys()) + turns.push({ + userMessage, + assistant: { + body: output.trim(), + reasoning: reasoning.trim(), + tools, + }, + }) + } + + assistantLines = [] + } + + for (const line of lines) { + if (USER_MARKER.test(line)) { + recognized = true + if (active === 'user') flushUser() + if (active === 'assistant') flushAssistant() + active = 'user' + userLines = [line.replace(USER_MARKER, '')] + continue + } + + if (ASSISTANT_MARKER.test(line)) { + recognized = true + if (active === 'user') flushUser() + if (active === 'assistant') flushAssistant() + active = 'assistant' + assistantLines = [line.replace(ASSISTANT_MARKER, '')] + continue + } + + if (active === 'user') { + userLines.push(line) + continue + } + + if (active === 'assistant') { + assistantLines.push(line) + } + } + + if (active === 'user') flushUser() + if (active === 'assistant') flushAssistant() + + return { turns, recognized } +} + +function resolveModel(raw: string | null | undefined): string { + if (!raw || raw === 'default') return 'cursor-agent-auto' + return raw +} + +export type CursorAgentDecodeInput = { + records: unknown[] + context: DecodeContext + // Optional live dedup set the host mutates in place (its shared cross-file + // seenKeys). Simple sqlite providers never persist resume state, so there is + // no serialized `seenKeys` fallback. + seenKeys?: Set +} + +export type CursorAgentDecodeResult = { + calls: CursorAgentDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +/** + * Decode one Cursor Agent source (host-supplied sqlite summary + transcript + + * file mtime) into rich, cost-free calls. Dedup is keyed on + * `cursor-agent::` against the live `seenKeys` set + * (host-owned). + */ +// `context` is part of the decode contract but the rich layer never consumes it: +// minimization / fingerprinting happens in toObservations. +export function decodeCursorAgent({ records, seenKeys: liveSeen }: CursorAgentDecodeInput): CursorAgentDecodeResult { + const seen = liveSeen ?? new Set() + const calls: CursorAgentDecodedCall[] = [] + const diagnostics: RecordDiagnostic[] = [] + + const composite = records[0] as CursorAgentRecord | undefined + if (!composite || typeof composite !== 'object') return { calls, diagnostics } + + const { summary, transcript, transcriptPath, fileMtime, conversationId } = composite + const isJsonl = transcriptPath.endsWith('.jsonl') + const parsed = isJsonl ? parseJsonlTranscript(transcript) : parseTranscript(transcript) + + // The pre-migration decode warned once per transcript whose parse produced + // nothing recognizable. Reported as a diagnostic so the host can re-emit it. + if (!parsed.recognized) { + diagnostics.push({ index: 0, code: 'unknown-shape' }) + return { calls, diagnostics } + } + + const timestamp = normalizeTimestamp(summary?.updatedAt) ?? fileMtime + const model = resolveModel(summary?.model ?? null) + + for (let turnIndex = 0; turnIndex < parsed.turns.length; turnIndex++) { + const turn = parsed.turns[turnIndex]! + const inputTokens = estimateTokens(turn.userMessage.length) + const outputTokens = estimateTokens(turn.assistant.body.length) + const reasoningTokens = estimateTokens(turn.assistant.reasoning.length) + const deduplicationKey = `cursor-agent:${conversationId}:${turnIndex}` + + if (seen.has(deduplicationKey)) continue + seen.add(deduplicationKey) + + calls.push({ + provider: 'cursor-agent', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens, + webSearchRequests: 0, + tools: turn.assistant.tools, + rawBashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey, + userMessage: turn.userMessage, + sessionId: conversationId, + }) + } + + return { calls, diagnostics } +} diff --git a/packages/core/src/providers/cursor-agent/index.ts b/packages/core/src/providers/cursor-agent/index.ts new file mode 100644 index 00000000..a6fe30da --- /dev/null +++ b/packages/core/src/providers/cursor-agent/index.ts @@ -0,0 +1,24 @@ +// @codeburn/core Cursor Agent provider. +// +// Two layers: +// - Rich pure decode (`decodeCursorAgent`): host-facing, NOT part of the stable +// minimized surface. Pure over host-supplied records; carries content +// in-memory but no pricing (cost leaves the decoder) and no bash base-name +// extraction (that stays host-side with its `strip-ansi` dependency). +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { decodeCursorAgent } from './decode.js' +export type { CursorAgentDecodeInput, CursorAgentDecodeResult } from './decode.js' +export { toObservations } from './observations.js' +export type { + RichCursorAgentSessionDecode, + CursorAgentToObservationsContext, +} from './observations.js' +export type { + CursorAgentDecodedCall, + CursorAgentRecord, + ConversationSummaryRow, + AssistantTurn, + ParsedTurn, +} from './types.js' diff --git a/packages/core/src/providers/cursor-agent/observations.ts b/packages/core/src/providers/cursor-agent/observations.ts new file mode 100644 index 00000000..06e944e0 --- /dev/null +++ b/packages/core/src/providers/cursor-agent/observations.ts @@ -0,0 +1,94 @@ +// Minimizing transform: rich Cursor Agent 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, or shell command. + +import { projectRef, sessionRef } from '../../fingerprint.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { CallObservation, SessionObservation } from '../../observations.js' +import type { CursorAgentDecodedCall } from './types.js' + +/** One Cursor Agent session's rich decode, as the host holds it before minimization. */ +export interface RichCursorAgentSessionDecode { + sessionId: string + /** Absolute project path (the session cwd); fingerprinted, never emitted raw. */ + projectPath: string + /** Rich, cost-free calls in decode order (as decodeCursorAgent emits them). */ + calls: CursorAgentDecodedCall[] +} + +export interface CursorAgentToObservationsContext { + /** 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. A name +// that does not match (a provider-native id with a colon, an argument blob) is +// dropped rather than emitted. +const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ + +function toCallObservation(call: CursorAgentDecodedCall, turnIndex: number): 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, + // Cursor Agent calls are priced from token buckets by the host's pricing table; + // they carry no provider-reported dollar figure. + costBasis: 'estimated', + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + } +} + +function toSessionObservation( + decode: RichCursorAgentSessionDecode, + ctx: CursorAgentToObservationsContext, +): SessionObservation { + const provider = ctx.provider ?? 'cursor-agent' + const calls: CallObservation[] = decode.calls.map((call, i) => toCallObservation(call, i)) + + 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 Cursor Agent 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) is ever copied into the result. Only fingerprints, enums, numbers, + * timestamps, dedup keys, and canonical tool names cross the boundary. + */ +export function toObservations( + decode: RichCursorAgentSessionDecode | RichCursorAgentSessionDecode[], + ctx: CursorAgentToObservationsContext, +): { 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/cursor-agent/types.ts b/packages/core/src/providers/cursor-agent/types.ts new file mode 100644 index 00000000..f85b2d4a --- /dev/null +++ b/packages/core/src/providers/cursor-agent/types.ts @@ -0,0 +1,64 @@ +// Raw record + rich-decode types for the Cursor Agent provider. +// +// The record types describe the shape of the host-supplied row objects: the +// conversation summary row read from the sqlite ai-code-tracking.db plus the +// transcript text and file metadata. The Decoded* types are the rich decode +// layer's output: pure over supplied records, carrying content in-memory but NO +// pricing (the host prices them). The CLI adapter maps CursorAgentDecodedCall +// into its own ParsedProviderCall by adding `costBasis: 'estimated'` and +// extracting bash base commands. + +/** One row from the Cursor Agent `conversation_summaries` sqlite table. */ +export type ConversationSummaryRow = { + conversationId: string + model: string | null + title: string | null + updatedAt: string | null +} + +/** One assistant turn after transcript parsing. */ +export type AssistantTurn = { + body: string + reasoning: string + tools: string[] +} + +/** One user/assistant pair after transcript parsing. */ +export type ParsedTurn = { + userMessage: string + assistant: AssistantTurn +} + +/** The composite record the host hands to the core decoder for one source. */ +export type CursorAgentRecord = { + /** Summary from ai-code-tracking.db, or null when the DB is missing/empty. */ + summary: ConversationSummaryRow | null + /** Raw transcript text (.txt or .jsonl). */ + transcript: string + /** Path to the transcript file (only its `.jsonl` suffix selects the parser). */ + transcriptPath: string + /** ISO timestamp of the transcript file's mtime (host-side I/O metadata). */ + fileMtime: string + /** Stable session id the host derived from the transcript path. */ + conversationId: string +} + +/** Rich decode of one Cursor Agent call, pre-pricing. */ +export type CursorAgentDecodedCall = { + provider: 'cursor-agent' + 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 +} diff --git a/packages/core/src/providers/devin/decode.ts b/packages/core/src/providers/devin/decode.ts new file mode 100644 index 00000000..d6e87a36 --- /dev/null +++ b/packages/core/src/providers/devin/decode.ts @@ -0,0 +1,295 @@ +// @codeburn/core Devin decoder: pure decode over a host-supplied transcript JSON +// object plus the matching sessions.db row. The host reads the file, parses the +// JSON, queries the sqlite database, and hands the composite record straight +// through. This decoder is pure: no fs / env / clock / sqlite / pricing. +// +// Model DISPLAY names stay host-side: resolving one needs the CLI's model +// table (`getShortModelName`), so the decoder only resolves which RAW ids win +// the precedence chain and emits them; the host formats them. + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { + ContentPart, + ContentPartText, + DevinAgentTrajectory, + DevinDecodeRecord, + DevinDecodedCall, + DevinMetadata, + DevinMetricsExtra, + DevinSessionMetadata, + DevinStep, + DevinUsage, + Metrics, + ToolCall, +} from './types.js' + +// Local copies of the host's safe number helpers (mirroring the claude decoder). +function isPositiveNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 +} + +function safeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 +} + +function getCommittedAcuCost(step: DevinStep): number { + const acuCost = [ + step.metadata?.committed_acu_cost, + step.extra?.committed_acu_cost, + ].filter((cost) => isPositiveNumber(cost)) + + return acuCost.shift() || 0 +} + +function hasAnyTokenField( + metrics: Metrics | null | undefined, +): boolean { + if (!metrics) return false + return [ + metrics.prompt_tokens, + metrics.completion_tokens, + metrics.cached_tokens, + metrics.extra?.cache_creation_input_tokens, + ].some((value) => value != null) +} + +function getMetricsFromStep( + step: DevinStep, +): Metrics | null { + // Prefer step.metrics (standard ATIF v1.7) only when it actually carries + // token fields; a present-but-empty metrics object must not shadow the + // legacy metadata.metrics location. + if (hasAnyTokenField(step.metrics)) { + return step.metrics ?? null + } + + if (step.metadata) { + return getDevinMetricsFromMetadata(step.metadata) + } + + return step.metrics ?? null +} + +function getDevinMetricsFromMetadata( + metadata: DevinMetadata, +): Metrics { + return { + prompt_tokens: metadata.metrics?.input_tokens, + completion_tokens: metadata.metrics?.output_tokens, + cached_tokens: metadata.metrics?.cache_read_tokens, + extra: { + cache_creation_input_tokens: metadata.metrics?.cache_creation_tokens, + }, + } +} + +function getUsage(step: DevinStep): DevinUsage | null { + const committedAcuCost = getCommittedAcuCost(step) + const metrics = getMetricsFromStep(step) + + const hasAnyUsage = [ + committedAcuCost, + metrics?.prompt_tokens, + metrics?.completion_tokens, + metrics?.extra?.cache_creation_input_tokens, + metrics?.cached_tokens, + ].some((x) => isPositiveNumber(x)) + + if (!hasAnyUsage) return null + + return { + committedAcuCost, + inputTokens: safeNumber(metrics?.prompt_tokens), + outputTokens: safeNumber(metrics?.completion_tokens), + cacheCreationInputTokens: safeNumber( + metrics?.extra?.cache_creation_input_tokens, + ), + cacheReadInputTokens: safeNumber(metrics?.cached_tokens), + } +} + +function projectNameFromPath(path: string): string { + const normalized = path.trim().replace(/[/\\]+$/, '') + return normalized.split(/[/\\]/).filter(Boolean).pop() ?? path +} + +function getProjectName( + project: string, + session: DevinSessionMetadata | null, +): string { + if (session?.workingDirectory) + return projectNameFromPath(session.workingDirectory) + if (session?.title) return session.title + return project +} + +function getProjectPath( + session: DevinSessionMetadata | null, +): string | undefined { + return session?.workingDirectory +} + +function getTimestamp( + step: DevinStep, + session: DevinSessionMetadata | null, +): string { + return [ + step.metadata?.created_at, + session?.lastActivityAt, + session?.createdAt, + ] + .filter(Boolean) + .shift() ?? '' +} + +function firstPresentString(...values: Array): string | undefined { + for (const value of values) { + const trimmed = value?.trim() + if (trimmed) return trimmed + } + return undefined +} + +// Resolve which RAW model ids win Devin's precedence chains. Turning these into +// a display name needs the host's model table, so that step stays CLI-side. +function getModelIds( + transcript: DevinAgentTrajectory, + step: DevinStep, + session: DevinSessionMetadata | null, +): { generationModel: string | undefined; modelName: string } { + const generationModel = firstPresentString( + step.metadata?.generation_model, + step.extra?.generation_model, + ) + const modelName = firstPresentString( + step.model_name, + transcript.agent?.model_name, + session?.model, + ) ?? 'devin' + + return { generationModel, modelName } +} + +function getToolNames(step: DevinStep): string[] { + return (step.tool_calls ?? []).map((call) => call.function_name) +} + +function isTextContentPart( + contentPart: ContentPart, +): contentPart is ContentPartText { + return contentPart.type === 'text' +} + +function normalizeContentPartMessage(contentPart: ContentPart) { + if (isTextContentPart(contentPart)) { + return contentPart.text + } else { + return contentPart.source.path + } +} + +function normalizeStepMessage(message: string | Array): string { + if (Array.isArray(message)) { + return message.map((x) => normalizeContentPartMessage(x).trim()).join(' ') + } + return message.trim() +} + +function getFirstUserMessageBeforeStep( + steps: DevinStep[], + index: number, +): string | null { + for (let i = index - 1; i >= 0; i--) { + const step = steps[i] + if (!step?.metadata?.is_user_input) continue + const message = step.message + ? normalizeStepMessage(step.message) + : undefined + if (message) return message + } + return null +} + +export type DevinDecodeInput = { + records: unknown[] + context: DecodeContext + // Optional live dedup set the host mutates in place (its shared cross-file + // seenKeys). Simple transcript providers never persist resume state, so there + // is no serialized `seenKeys` fallback. + seenKeys?: Set +} + +export type DevinDecodeResult = { + calls: DevinDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +/** + * Decode one Devin transcript (host-supplied parsed JSON + sessions.db row) + * into rich, cost-free calls. Dedup is keyed on `devin::` + * against the live `seenKeys` set (host-owned). + */ +export function decodeDevin({ records, seenKeys: liveSeen }: DevinDecodeInput): DevinDecodeResult { + const seen = liveSeen ?? new Set() + const calls: DevinDecodedCall[] = [] + const diagnostics: RecordDiagnostic[] = [] + + const composite = records[0] as DevinDecodeRecord | undefined + if (!composite || typeof composite !== 'object') return { calls, diagnostics } + const transcript = composite.transcript + const session = composite.session ?? null + const sourceProject = composite.project ?? 'devin' + const sessionId = composite.sessionId + + if (!transcript?.steps) return { calls, diagnostics } + if (session?.hidden) return { calls, diagnostics } + + const project = getProjectName(sourceProject, session) + const projectPath = getProjectPath(session) + + for (let index = 0; index < transcript.steps.length; index++) { + const step = transcript.steps[index] + if (step.metadata?.is_user_input) continue + + const usage = getUsage(step) + if (!usage) continue + + const timestamp = getTimestamp(step, session) + + const deduplicationKey = `devin:${sessionId}:${step.step_id}` + + if (seen.has(deduplicationKey)) continue + seen.add(deduplicationKey) + + const { generationModel, modelName } = getModelIds(transcript, step, session) + const tools = getToolNames(step) + const userMessage = + getFirstUserMessageBeforeStep(transcript.steps, index) ?? '' + + calls.push({ + provider: 'devin', + modelName, + ...(generationModel !== undefined ? { generationModel } : {}), + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheCreationInputTokens: usage.cacheCreationInputTokens, + cacheReadInputTokens: usage.cacheReadInputTokens, + cachedInputTokens: usage.cacheReadInputTokens, + reasoningTokens: 0, + webSearchRequests: 0, + tools, + rawBashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey, + userMessage, + sessionId, + project, + ...(projectPath ? { projectPath } : {}), + committedAcuCost: usage.committedAcuCost, + }) + } + + return { calls, diagnostics } +} diff --git a/packages/core/src/providers/devin/index.ts b/packages/core/src/providers/devin/index.ts new file mode 100644 index 00000000..b7ddab77 --- /dev/null +++ b/packages/core/src/providers/devin/index.ts @@ -0,0 +1,21 @@ +// @codeburn/core Devin provider. +// +// Two layers: +// - Rich pure decode (`decodeDevin`): host-facing, NOT part of the stable +// minimized surface. Pure over host-supplied transcript + sessions.db row; +// carries content in-memory but no pricing (cost leaves the decoder). +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { decodeDevin } from './decode.js' +export type { DevinDecodeInput, DevinDecodeResult } from './decode.js' +export { toObservations } from './observations.js' +export type { RichDevinSessionDecode, DevinToObservationsContext } from './observations.js' +export type { + DevinAgentTrajectory, + DevinDecodedCall, + DevinDecodeRecord, + DevinSessionMetadata, + DevinStep, + ToolCall, +} from './types.js' diff --git a/packages/core/src/providers/devin/observations.ts b/packages/core/src/providers/devin/observations.ts new file mode 100644 index 00000000..1bb71a64 --- /dev/null +++ b/packages/core/src/providers/devin/observations.ts @@ -0,0 +1,92 @@ +// Minimizing transform: rich Devin 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, or tool +// arguments. + +import { projectRef, sessionRef } from '../../fingerprint.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { CallObservation, SessionObservation } from '../../observations.js' +import type { DevinDecodedCall } from './types.js' + +/** One Devin session's rich decode, as the host holds it before minimization. */ +export interface RichDevinSessionDecode { + sessionId: string + /** Absolute project path (the session cwd); fingerprinted, never emitted raw. */ + projectPath: string + /** Rich, cost-free calls in decode order (as decodeDevin emits them). */ + calls: DevinDecodedCall[] +} + +export interface DevinToObservationsContext { + /** 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. A name +// that does not match (a provider-native id with a slash, an argument blob) is +// dropped rather than emitted. +const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ + +function toCallObservation(call: DevinDecodedCall, turnIndex: number, privacyKey: string): CallObservation { + return { + provider: call.provider, + // The raw model id, not the host's display name: the envelope is keyed by + // provider ids, and display formatting lives CLI-side. + model: call.generationModel ?? call.modelName, + tokens: { + input: call.inputTokens, + output: call.outputTokens, + reasoning: call.reasoningTokens, + cacheRead: call.cacheReadInputTokens, + cacheCreate: call.cacheCreationInputTokens, + }, + webSearchRequests: call.webSearchRequests, + speed: call.speed, + // Devin reports committed ACU cost which the host converts to USD; mark as + // measured so downstream passes leave costUSD untouched. + costBasis: 'measured', + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + } +} + +function toSessionObservation(decode: RichDevinSessionDecode, ctx: DevinToObservationsContext): SessionObservation { + const provider = ctx.provider ?? 'devin' + 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 Devin 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, project 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: RichDevinSessionDecode | RichDevinSessionDecode[], + ctx: DevinToObservationsContext, +): { 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/devin/types.ts b/packages/core/src/providers/devin/types.ts new file mode 100644 index 00000000..18e15e5c --- /dev/null +++ b/packages/core/src/providers/devin/types.ts @@ -0,0 +1,187 @@ +// Raw record + rich-decode types for the Devin provider. +// +// The record types describe the shape of Devin transcript JSON plus the +// sessions.db row the host enriches it with. The Decoded* types are the rich +// decode layer's output: pure over supplied records, carrying content in-memory +// but NO pricing (the host prices them). The CLI adapter maps DevinDecodedCall +// into its own ParsedProviderCall by adding `costBasis: 'measured'` and the +// ACU->USD conversion. + +export type AgentTrajectory = { + schema_version: string; + session_id?: string; + agent: Agent; + steps: StepType[]; + final_metrics?: FinalMetrics; +}; + +export type FinalMetrics = { + total_prompt_tokens?: number; + total_completion_tokens?: number; + total_cached_tokens?: number; + total_steps?: number; +}; + +export type DevinAgentExtra = { + backend?: string; + permission_mode?: string; +}; + +export type Agent = { + name: string; + version: string; + model_name?: string; + tool_definitions?: unknown; + extra?: Extra; +}; + +export type ToolCall = { + tool_call_id: string; + function_name: string; + arguments: unknown; +}; + +export type DevinMetadata = { + created_at?: string; + committed_acu_cost?: number; + generation_model?: string; + is_user_input?: boolean; + num_tokens?: number; + request_id?: string; + finish_reason?: string; + metrics?: { + input_tokens?: number; + output_tokens?: number; + cache_creation_tokens?: number; + cache_read_tokens?: number; + tokens_per_sec?: number; + total_time_ms?: number; + ttft_ms?: number; + tpot_ms?: number; + }; +}; + +export type ContentPart = ContentPartText | ContentPartImage; + +export type ContentPartText = { + type: "text"; + text: string; +}; + +export type ContentPartImage = { + type: "image"; + source: ImageSource; +}; + +export type ImageSource = { + media_type: string; + path: string; +}; + +export type Step = { + step_id: number; + timestamp?: string; + source?: string; + model_name?: string; + message: string | Array; + tool_calls?: Array; + extra?: StepExtra; + observation?: Observation; + metrics?: Metrics; +}; + +export type DevinTelemetry = { + source?: string; + operation?: string; +}; + +export type DevinStepExtra = { + committed_acu_cost?: number; + generation_model?: string; + telemetry?: DevinTelemetry; +}; + +export type Observation = { + results: Array; +}; + +export type ObservationResult = { + source_call_id?: string; + content?: string | Array; +}; + +export type Metrics = { + prompt_tokens?: number; + completion_tokens?: number; + cached_tokens?: number; + extra?: Extra; +}; + +export type DevinMetricsExtra = { + cache_creation_input_tokens?: number; +}; + +export type DevinStep = Step & { + metadata?: DevinMetadata; +}; + +export type DevinAgentTrajectory = AgentTrajectory; + +export type DevinSessionMetadata = { + id: string; + workingDirectory: string; + model: string; + title?: string; + createdAt: string; + lastActivityAt: string; + hidden: boolean; +}; + +export type DevinUsage = { + committedAcuCost: number; + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; +}; + +/** One host-supplied record: parsed transcript + sessions.db enrichment. */ +export type DevinDecodeRecord = { + transcript: DevinAgentTrajectory; + session: DevinSessionMetadata | null; + project: string; + /** Stable session id the host derived from the transcript (or its filename). */ + sessionId: string; +}; + +/** + * Rich decode of one Devin assistant step, pre-pricing. Mirrors the host's + * ParsedProviderCall minus cost fields (the host adds those). Devin tool calls + * do not carry bash commands or file paths that need CLI-side extraction, so + * `rawBashCommands` is always empty. + */ +export type DevinDecodedCall = { + provider: 'devin'; + /** Raw model id winning the step/agent/session chain; host formats it. */ + modelName: string; + /** Raw generation-model id, when the step reported one. */ + generationModel?: 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; + project: string; + projectPath?: string; + /** Provider-reported ACU cost; the host converts it to USD. */ + committedAcuCost: number; +}; diff --git a/packages/core/src/providers/hermes/decode.ts b/packages/core/src/providers/hermes/decode.ts new file mode 100644 index 00000000..4446eec8 --- /dev/null +++ b/packages/core/src/providers/hermes/decode.ts @@ -0,0 +1,236 @@ +// @codeburn/core Hermes decoder: pure decode over host-supplied sqlite rows. +// The host opens state.db, runs the SQL, and hands the session row + messages + +// profile straight through. This decoder is pure: no fs / env / clock / sqlite / +// pricing / strip-ansi. It emits raw command strings; bash base-name extraction +// stays host-side. + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { + HermesDecodedCall, + HermesMessageRow, + HermesSessionRow, + HermesToolCall, + HermesToolSequenceEntry, +} from './types.js' + +// Hermes tool ids mapped to the canonical vocabulary. An id with no mapping +// passes through unchanged so a provider-native tool still shows up. +export const hermesToolNameMap: Record = { + terminal: 'Bash', + execute_code: 'CodeExecution', + read_file: 'Read', + search_files: 'Grep', + write_file: 'Write', + patch: 'Edit', + browser_navigate: 'Browser', + browser_click: 'Browser', + browser_type: 'Browser', + browser_press: 'Browser', + browser_scroll: 'Browser', + browser_snapshot: 'Browser', + browser_vision: 'Vision', + browser_console: 'Browser', + browser_get_images: 'Browser', + web_search: 'WebSearch', + web_extract: 'WebFetch', + delegate_task: 'Agent', + vision_analyze: 'Vision', + process: 'Bash', + todo: 'TodoWrite', + skill_view: 'Skill', + skill_manage: 'Skill', + skills_list: 'Skill', + memory: 'Memory', + session_search: 'SessionSearch', +} + +function sanitizeProject(raw: string): string { + const trimmed = raw.trim() + if (!trimmed) return 'hermes' + return trimmed.replace(/^[/\\]+/, '').replace(/[:/\\]/g, '-') +} + +function parseTimestamp(raw: number | null): string { + if (raw == null) return '' + const ms = raw < 1e12 ? raw * 1000 : raw + return new Date(ms).toISOString() +} + +function firstUserMessage(messages: HermesMessageRow[]): string { + const msg = messages.find(m => m.role === 'user' && typeof m.content === 'string' && m.content.trim().length > 0) + return Array.from(msg?.content ?? '').slice(0, 500).join('') +} + +export function mapToolName(raw: string): string { + // Composio MCP tools are matched first — the generic mcp_ prefix on line + // below would also match composio names, so order matters here. + if (raw.startsWith('mcp_composio_')) return 'MCP' + if (raw.startsWith('mcp_') || raw.startsWith('mcp__')) return raw + if (raw.startsWith('browser_')) return 'Browser' + return hermesToolNameMap[raw] ?? raw +} + +function parseToolCalls(raw: string | null): HermesToolCall[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as unknown + return Array.isArray(parsed) ? (parsed as HermesToolCall[]) : [] + } catch { + return [] + } +} + +function collectTools(messages: HermesMessageRow[]): { + tools: string[] + toolSequence: HermesToolSequenceEntry[][] + rawBashCommands: string[] +} { + const tools: string[] = [] + const toolSequence: HermesToolSequenceEntry[][] = [] + const rawBashCommands: string[] = [] + + for (const msg of messages) { + if (msg.role === 'assistant') { + const currentTurnTools: HermesToolSequenceEntry[] = [] + for (const call of parseToolCalls(msg.tool_calls)) { + const rawName = call.function?.name ?? '' + if (!rawName) continue + const mapped = mapToolName(rawName) + tools.push(mapped) + const toolCall: HermesToolSequenceEntry = { tool: mapped } + const rawArgs = call.function?.arguments + if (rawArgs) { + try { + const args = JSON.parse(rawArgs) as Record + const file = args['path'] ?? args['file_path'] + if (typeof file === 'string') toolCall.file = file + const command = args['command'] + if (typeof command === 'string') { + toolCall.command = command + rawBashCommands.push(command) + } + } catch { + // Ignore malformed arguments from historical sessions. + } + } + currentTurnTools.push(toolCall) + } + if (currentTurnTools.length > 0) { + toolSequence.push(currentTurnTools) + } + } else if (msg.role === 'tool' && msg.tool_name) { + tools.push(mapToolName(msg.tool_name)) + } + } + + return { + tools: [...new Set(tools)], + toolSequence: toolSequence.length > 0 ? toolSequence : [], + rawBashCommands, + } +} + +function inferProject(messages: HermesMessageRow[], fallback: string): { project: string; projectPath?: string } { + const cwdPattern = /^Current working directory:\s*([a-zA-Z]:\\[^\r\n`"]+|\/[^\r\n`"\\]+)/m + for (const msg of messages) { + if (msg.role !== 'user' && msg.role !== 'system') continue + const text = msg.content ?? '' + const match = cwdPattern.exec(text) + if (match?.[1]) { + const projectPath = match[1].trim() + return { project: sanitizeProject(projectPath), projectPath } + } + } + return { project: fallback } +} + +export type HermesDecodeInput = { + records: unknown[] + context: DecodeContext + // Optional live dedup set the host mutates in place (its shared cross-file + // seenKeys). Simple sqlite providers never persist resume state, so there is + // no serialized `seenKeys` fallback. + seenKeys?: Set +} + +export type HermesDecodeResult = { + calls: HermesDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +/** + * Decode one Hermes session (host-supplied session row + messages + profile) + * into rich, cost-free calls. Dedup is keyed on `hermes::` + * against the live `seenKeys` set (host-owned). + */ +export function decodeHermes({ records, seenKeys: liveSeen }: HermesDecodeInput): HermesDecodeResult { + const seen = liveSeen ?? new Set() + const calls: HermesDecodedCall[] = [] + const diagnostics: RecordDiagnostic[] = [] + + const composite = records[0] as { session: HermesSessionRow; messages: HermesMessageRow[]; profile: string } | undefined + if (!composite || typeof composite !== 'object') return { calls, diagnostics } + const row = composite.session + const messages = composite.messages ?? [] + const profile = composite.profile ?? 'default' + + if (!row || !row.id) return { calls, diagnostics } + + const inputTokens = row.input_tokens ?? 0 + const outputTokens = row.output_tokens ?? 0 + const cacheReadTokens = row.cache_read_tokens ?? 0 + const cacheWriteTokens = row.cache_write_tokens ?? 0 + const reasoningTokens = row.reasoning_tokens ?? 0 + if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens === 0) { + return { calls, diagnostics } + } + + const model = row.model ?? 'unknown' + const { tools, toolSequence, rawBashCommands } = collectTools(messages) + // Hermes records the session's working directory in sessions.cwd. + // Prefer it; fall back to scraping a "Current working directory:" line + // from the transcript (older builds), then to the profile name. + const cwd = row.cwd?.trim() + const projectInfo = cwd + ? { project: sanitizeProject(cwd), projectPath: cwd } + : inferProject(messages, sanitizeProject(profile)) + const timestamp = parseTimestamp(row.started_at) + const dedupKey = `hermes:${profile}:${row.id}` + if (seen.has(dedupKey)) return { calls, diagnostics } + seen.add(dedupKey) + + // Hermes bills reasoning tokens at the output rate (same as Gemini). + // When Hermes stored an actual or estimated cost, pass it as measured; + // otherwise the host pricing pass will estimate from token buckets. + const recordedCost = + (row.actual_cost_usd ?? 0) > 0 ? row.actual_cost_usd! + : (row.estimated_cost_usd ?? 0) > 0 ? row.estimated_cost_usd! + : undefined + + calls.push({ + provider: 'hermes', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: cacheWriteTokens, + cacheReadInputTokens: cacheReadTokens, + cachedInputTokens: cacheReadTokens, + reasoningTokens, + webSearchRequests: 0, + tools, + rawBashCommands, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + turnId: `${row.id}:session`, + toolSequence: toolSequence.length > 0 ? toolSequence : undefined, + userMessage: firstUserMessage(messages), + sessionId: row.id, + ...(recordedCost !== undefined ? { recordedCost } : {}), + project: projectInfo.project, + ...(projectInfo.projectPath ? { projectPath: projectInfo.projectPath } : {}), + }) + + return { calls, diagnostics } +} diff --git a/packages/core/src/providers/hermes/index.ts b/packages/core/src/providers/hermes/index.ts new file mode 100644 index 00000000..1ea513fe --- /dev/null +++ b/packages/core/src/providers/hermes/index.ts @@ -0,0 +1,21 @@ +// @codeburn/core Hermes provider. +// +// Two layers: +// - Rich pure decode (`decodeHermes`): host-facing, NOT part of the stable +// minimized surface. Pure over host-supplied sqlite rows; carries content +// in-memory but no pricing (cost leaves the decoder) and no bash base-name +// extraction (that stays host-side with its `strip-ansi` dependency). +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { decodeHermes, hermesToolNameMap, mapToolName } from './decode.js' +export type { HermesDecodeInput, HermesDecodeResult } from './decode.js' +export { toObservations } from './observations.js' +export type { RichHermesSessionDecode, HermesToObservationsContext } from './observations.js' +export type { + HermesDecodedCall, + HermesMessageRow, + HermesSessionRow, + HermesToolCall, + HermesToolSequenceEntry, +} from './types.js' diff --git a/packages/core/src/providers/hermes/observations.ts b/packages/core/src/providers/hermes/observations.ts new file mode 100644 index 00000000..112a11cc --- /dev/null +++ b/packages/core/src/providers/hermes/observations.ts @@ -0,0 +1,93 @@ +// Minimizing transform: rich Hermes 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 { extractResourceRefs } from '../resource-refs.js' +import type { HermesDecodedCall } from './types.js' + +/** One Hermes session's rich decode, as the host holds it before minimization. */ +export interface RichHermesSessionDecode { + sessionId: string + /** Absolute project path (the session cwd); fingerprinted, never emitted raw. */ + projectPath: string + /** Rich, cost-free calls in decode order (as decodeHermes emits them). */ + calls: HermesDecodedCall[] +} + +export interface HermesToObservationsContext { + /** 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. A name +// that does not match (a smuggled command with spaces/slashes, an argument blob) +// is dropped rather than emitted. +const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ + +function toCallObservation(call: HermesDecodedCall, turnIndex: number, privacyKey: string): CallObservation { + const measured = call.recordedCost !== undefined + 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: measured ? 'measured' : 'estimated', + ...(measured ? { measuredCostUSD: call.recordedCost } : {}), + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + ...extractResourceRefs(privacyKey, call.toolSequence), + } +} + +function toSessionObservation(decode: RichHermesSessionDecode, ctx: HermesToObservationsContext): SessionObservation { + const provider = ctx.provider ?? 'hermes' + 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 Hermes 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: RichHermesSessionDecode | RichHermesSessionDecode[], + ctx: HermesToObservationsContext, +): { 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/hermes/types.ts b/packages/core/src/providers/hermes/types.ts new file mode 100644 index 00000000..86f73f8e --- /dev/null +++ b/packages/core/src/providers/hermes/types.ts @@ -0,0 +1,90 @@ +// Raw sqlite-row + rich-decode types for the Hermes provider. +// +// The record types (HermesSessionRow, HermesMessageRow) describe the shape of the +// rows the host reads from Hermes's state.db. The Decoded* types are the rich +// decode layer's output: pure over supplied records, carrying content in-memory +// but NO host-side pricing (the host prices them). The CLI adapter maps +// HermesDecodedCall into its own ParsedProviderCall by adding costBasis / costUSD +// from the provider-recorded cost, extracting bash base commands, and running the +// pricing pass. + +export type HermesSessionRow = { + id: string + source: string | null + model: string | null + cwd: string | null + billing_provider: string | null + input_tokens: number | null + output_tokens: number | null + cache_read_tokens: number | null + cache_write_tokens: number | null + reasoning_tokens: number | null + estimated_cost_usd: number | null + actual_cost_usd: number | null + api_call_count: number | null + tool_call_count: number | null + started_at: number | null + ended_at: number | null + title: string | null +} + +export type HermesMessageRow = { + id: number | null + role: string + content: string | null + tool_calls: string | null + tool_name: string | null + timestamp: number | null +} + +export type HermesToolCall = { + function?: { + name?: string + arguments?: string + } +} + +/** One tool invocation captured in a turn's tool sequence. */ +export type HermesToolSequenceEntry = { + tool: string + file?: string + command?: string +} + +/** + * Rich decode of one Hermes session (one row from sessions + its messages), + * pre-pricing. Mirrors the host's ParsedProviderCall minus cost fields (the host + * adds those). `rawBashCommands` are the un-split shell command strings from + * Bash-mapped tool calls; the CLI adapter runs its own base-name extraction on + * them to build the `bashCommands` field (that extraction, and its `strip-ansi` + * dependency, stay CLI-side). `toolSequence` carries raw file paths host-side so + * the observation transform can fingerprint them; it never leaves the host as-is. + */ +export type HermesDecodedCall = { + provider: 'hermes' + 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 + toolSequence?: HermesToolSequenceEntry[][] + /** Provider-recorded cost (actual_cost_usd or estimated_cost_usd), when present. + * The host converts this into `costUSD` + `costBasis: 'measured'`. */ + recordedCost?: number + /** Absolute project path (the session cwd or a path scraped from the transcript); + * fingerprinted by toObservations, never emitted raw. */ + projectPath?: string + /** Sanitized project name derived from cwd, transcript, or profile; host-only. */ + project?: string +} diff --git a/packages/core/src/providers/quickdesk/decode.ts b/packages/core/src/providers/quickdesk/decode.ts new file mode 100644 index 00000000..e54a995e --- /dev/null +++ b/packages/core/src/providers/quickdesk/decode.ts @@ -0,0 +1,245 @@ +// @codeburn/core Quickdesk decoder: pure decode over host-supplied records. +// The host reads the metrics JSONL and queries the sqlite sessions.db; this +// decoder extracts token buckets, tool lists, and user messages with no fs, env, +// clock, sqlite, pricing, or strip-ansi. It emits raw command strings (always +// empty for this provider); bash base-name extraction stays host-side. + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { + QuickdeskDatabaseInput, + QuickdeskDecodedCall, + QuickdeskMetricsInput, + QuickdeskMetricsRecord, + QuickdeskSessionMetadata, +} from './types.js' + +const METRICS_FILE_RE = /^metrics-(\d{4})-(\d{2})-(\d{2})\.jsonl$/ + +export const quickdeskToolNameMap: Record = { + readFile: 'Read', + read_file: 'Read', + writeFile: 'Edit', + write_file: 'Edit', + editFile: 'Edit', + edit_file: 'Edit', + runCommand: 'Bash', + run_command: 'Bash', + executeBash: 'Bash', + shell: 'Bash', + grep: 'Grep', + searchFiles: 'Grep', + search_files: 'Grep', +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function nonNegativeNumber(value: unknown): number | undefined { + const number = finiteNumber(value) + return number !== undefined && number >= 0 ? number : undefined +} + +function uniqueMappedTools(values: string[]): string[] { + return [...new Set(values.map(value => quickdeskToolNameMap[value] ?? value).filter(Boolean))] +} + +function unixSecondsIso(value: number): string | null { + const date = new Date(value * 1000) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + +function estimateTokensFromChars(chars: number): number { + return Math.ceil(chars / 4) +} + +function usageRecord(record: Record): boolean { + return Boolean(stringValue(record['Model'])) + && nonNegativeNumber(record['InputTokens']) !== undefined + && nonNegativeNumber(record['OutputTokens']) !== undefined +} + +function sessionId(record: Record): string { + return stringValue(record['session_id']) +} + +function toolsKey(record: Record): string { + return sessionId(record) +} + +function collectMetricTools(records: QuickdeskMetricsRecord[]): Map { + const tools = new Map() + for (const { record } of records) { + const key = toolsKey(record) + const tool = stringValue(record['ToolName']) + if (!key || !tool) continue + const current = tools.get(key) ?? [] + current.push(quickdeskToolNameMap[tool] ?? tool) + tools.set(key, current) + } + for (const [key, values] of tools) tools.set(key, [...new Set(values)]) + return tools +} + +function fallbackTimestamp(path: string): string | null { + const match = METRICS_FILE_RE.exec(path) + if (!match) return null + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const date = new Date(Date.UTC(year, month - 1, day)) + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null + return date.toISOString() +} + +function metricsTimestamp(record: Record, fileId: string): string | null { + const aws = asRecord(record['_aws']) + const timestampMs = finiteNumber(aws?.['Timestamp']) + if (timestampMs !== undefined) { + const date = new Date(timestampMs) + if (!Number.isNaN(date.getTime())) return date.toISOString() + } + return fallbackTimestamp(fileId) +} + +export type QuickdeskDecodeInput = { + records: unknown[] + context: DecodeContext + seenKeys?: Set +} + +export type QuickdeskDecodeResult = { + calls: QuickdeskDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +function decodeMetrics(input: QuickdeskMetricsInput, seen: Set): QuickdeskDecodedCall[] { + const calls: QuickdeskDecodedCall[] = [] + const { records, sessions, project, projectPath, fileId } = input + const linkedTools = collectMetricTools(records) + + for (const { record } of records) { + if (!usageRecord(record)) continue + const model = stringValue(record['Model']) + const inputTokens = nonNegativeNumber(record['InputTokens'])! + const outputTokens = nonNegativeNumber(record['OutputTokens'])! + const timestamp = metricsTimestamp(record, fileId) + if (!timestamp) continue + + const linkedSessionId = sessionId(record) + const metadata = linkedSessionId ? sessions.get(linkedSessionId) : undefined + if (metadata?.deleted) continue + + const fallbackId = `${project}:${fileId}` + const deduplicationKey = `quickdesk:${linkedSessionId || fallbackId}:${timestamp}:${model}:${inputTokens}:${outputTokens}` + if (seen.has(deduplicationKey)) continue + seen.add(deduplicationKey) + + const recordedCost = nonNegativeNumber(record['CostUSD']) + const metricTools = linkedTools.get(toolsKey(record)) ?? [] + const tools = uniqueMappedTools([...metricTools, ...(metadata?.tools ?? [])]) + + calls.push({ + provider: 'quickdesk', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + tools, + rawBashCommands: [], + speed: 'standard', + timestamp, + deduplicationKey, + userMessage: metadata?.firstUserMessage ?? '', + sessionId: linkedSessionId || fileId, + project, + projectPath, + ...(recordedCost !== undefined ? { recordedCost } : {}), + }) + } + + return calls +} + +function decodeDatabase(input: QuickdeskDatabaseInput, seen: Set): QuickdeskDecodedCall[] { + const calls: QuickdeskDecodedCall[] = [] + const { sessions, meteredSessionIds, project, projectPath } = input + + for (const metadata of sessions) { + if (metadata.deleted || meteredSessionIds.has(metadata.id) || metadata.createdAt === undefined) continue + const createdAtSeconds = metadata.createdAt > 1_000_000_000_000 + ? metadata.createdAt / 1000 + : metadata.createdAt + const timestamp = unixSecondsIso(createdAtSeconds) + if (!timestamp) continue + const inputTokens = estimateTokensFromChars(metadata.inputChars) + const outputTokens = estimateTokensFromChars(metadata.outputChars) + if (inputTokens + outputTokens === 0) continue + + const deduplicationKey = `quickdesk-est:${metadata.id}` + if (seen.has(deduplicationKey)) continue + seen.add(deduplicationKey) + const model = 'quickdesk-auto' + + calls.push({ + provider: 'quickdesk', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + tools: metadata.tools, + rawBashCommands: [], + speed: 'standard', + timestamp, + deduplicationKey, + userMessage: metadata.firstUserMessage, + sessionId: metadata.id, + project, + projectPath, + }) + } + + return calls +} + +/** + * Decode Quickdesk records (metrics JSONL or sqlite-derived session metadata) + * into rich, cost-free calls. Dedup is keyed on `quickdesk::...` + * against the live `seenKeys` set (host-owned). + */ +export function decodeQuickdesk({ records, seenKeys: liveSeen }: QuickdeskDecodeInput): QuickdeskDecodeResult { + const seen = liveSeen ?? new Set() + const diagnostics: RecordDiagnostic[] = [] + + const input = records[0] as QuickdeskMetricsInput | QuickdeskDatabaseInput | undefined + if (!input || typeof input !== 'object') return { calls: [], diagnostics } + + if (input.variant === 'metrics') { + return { calls: decodeMetrics(input, seen), diagnostics } + } + + if (input.variant === 'database') { + return { calls: decodeDatabase(input, seen), diagnostics } + } + + return { calls: [], diagnostics } +} diff --git a/packages/core/src/providers/quickdesk/index.ts b/packages/core/src/providers/quickdesk/index.ts new file mode 100644 index 00000000..eec3ae10 --- /dev/null +++ b/packages/core/src/providers/quickdesk/index.ts @@ -0,0 +1,20 @@ +// @codeburn/core Quickdesk provider. +// +// Two layers: +// - Rich pure decode (`decodeQuickdesk`): host-facing, NOT part of the stable +// minimized surface. Pure over host-supplied records; carries content in-memory +// but no pricing (cost leaves the decoder) and no bash base-name extraction. +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { decodeQuickdesk, quickdeskToolNameMap } from './decode.js' +export type { QuickdeskDecodeInput, QuickdeskDecodeResult } from './decode.js' +export { toObservations } from './observations.js' +export type { RichQuickdeskSessionDecode, QuickdeskToObservationsContext } from './observations.js' +export type { + QuickdeskDecodedCall, + QuickdeskDatabaseInput, + QuickdeskMetricsInput, + QuickdeskMetricsRecord, + QuickdeskSessionMetadata, +} from './types.js' diff --git a/packages/core/src/providers/quickdesk/observations.ts b/packages/core/src/providers/quickdesk/observations.ts new file mode 100644 index 00000000..35324147 --- /dev/null +++ b/packages/core/src/providers/quickdesk/observations.ts @@ -0,0 +1,90 @@ +// Minimizing transform: rich Quickdesk 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, or shell +// command. + +import { projectRef, sessionRef } from '../../fingerprint.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { CallObservation, SessionObservation } from '../../observations.js' +import type { QuickdeskDecodedCall } from './types.js' + +/** One Quickdesk session's rich decode, as the host holds it before minimization. */ +export interface RichQuickdeskSessionDecode { + sessionId: string + /** Absolute project path; fingerprinted, never emitted raw. */ + projectPath: string + /** Rich, cost-free calls in decode order (as decodeQuickdesk emits them). */ + calls: QuickdeskDecodedCall[] +} + +export interface QuickdeskToObservationsContext { + /** 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. A name +// that does not match (a smuggled command with spaces/slashes, an argument blob) +// is dropped rather than emitted. +const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ + +function toCallObservation(call: QuickdeskDecodedCall, turnIndex: number, privacyKey: string): CallObservation { + const measured = call.recordedCost !== undefined + 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: measured ? 'measured' : 'estimated', + ...(measured ? { measuredCostUSD: call.recordedCost } : {}), + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + } +} + +function toSessionObservation(decode: RichQuickdeskSessionDecode, ctx: QuickdeskToObservationsContext): SessionObservation { + const provider = ctx.provider ?? 'quickdesk' + 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 Quickdesk 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, project path, command, + * or 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: RichQuickdeskSessionDecode | RichQuickdeskSessionDecode[], + ctx: QuickdeskToObservationsContext, +): { 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/quickdesk/types.ts b/packages/core/src/providers/quickdesk/types.ts new file mode 100644 index 00000000..929d8d4e --- /dev/null +++ b/packages/core/src/providers/quickdesk/types.ts @@ -0,0 +1,70 @@ +// Raw record + rich-decode types for the Quickdesk provider. +// +// The CLI reads Quickdesk's metrics JSONL and sqlite sessions.db host-side and +// hands plain row/composite objects to the core decoder. The Decoded* types are +// the rich decode layer's output: pure over supplied records, carrying content +// in-memory but NO pricing (the host prices them). + +export type QuickdeskMetricsRecord = { + record: Record +} + +export type QuickdeskSessionMetadata = { + id: string + title: string + agentMode: string + createdAt?: number + deleted: boolean + firstUserMessage: string + inputChars: number + outputChars: number + tools: string[] +} + +export type QuickdeskMetricsInput = { + variant: 'metrics' + records: QuickdeskMetricsRecord[] + sessions: Map + project: string + projectPath: string + fileId: string +} + +export type QuickdeskDatabaseInput = { + variant: 'database' + sessions: QuickdeskSessionMetadata[] + meteredSessionIds: Set + project: string + projectPath: string +} + +/** + * Rich decode of one Quickdesk call (from metrics or database estimate), + * pre-pricing. Mirrors the host's ParsedProviderCall minus cost fields (the host + * adds those). `rawBashCommands` is always empty for this provider; the field is + * kept so the host-side map is uniform. `recordedCost` carries the provider- + * reported dollar figure when present; the host converts it into `costUSD` + + * `costBasis: 'measured'`. + */ +export type QuickdeskDecodedCall = { + provider: 'quickdesk' + 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 + project: string + projectPath: string + /** Provider-reported cost, when present. The host converts this into `costUSD` + `costBasis: 'measured'`. */ + recordedCost?: number +} diff --git a/packages/core/src/providers/warp/decode.ts b/packages/core/src/providers/warp/decode.ts new file mode 100644 index 00000000..d194f88a --- /dev/null +++ b/packages/core/src/providers/warp/decode.ts @@ -0,0 +1,334 @@ +// @codeburn/core Warp decoder: pure decode over host-supplied sqlite rows. +// The host opens warp.sqlite, runs the SQL, textualizes the stylized_command +// BLOB, and hands the conversation + exchanges + blocks straight through. This +// decoder is pure: no fs / env / clock / sqlite / pricing / strip-ansi. It emits +// raw command strings; bash base-name extraction stays host-side. + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { + WarpBlockRow, + WarpConversationData, + WarpConversationRow, + WarpDecodedCall, + WarpExchangeToolInfo, + WarpParsedExchange, + WarpQueryRow, + WarpTokenUsageEntry, +} from './types.js' + +const PRIMARY_AGENT_CATEGORY = 'primary_agent' + +const modelAliases: Record = { + 'Claude Sonnet 4.6': 'claude-sonnet-4-6', + 'Claude Sonnet 4.5': 'claude-sonnet-4-5', + 'Claude Haiku 4.5': 'claude-haiku-4-5', + 'Claude Opus 4.6': 'claude-opus-4-6', + 'GPT-5.3 Codex (low reasoning)': 'gpt-5.3-codex', + 'GPT-5.3 Codex (medium reasoning)': 'gpt-5.3-codex', + 'GPT-5.3 Codex (high reasoning)': 'gpt-5.3-codex', + 'GPT-5.3 Codex (extra high reasoning)': 'gpt-5.3-codex', + 'auto-efficient': 'warp-auto-efficient', + 'auto-powerful': 'warp-auto-powerful', +} + +function normalizeModel(rawModel: string): string { + const model = rawModel.trim() + if (!model) return model + return modelAliases[model] ?? model +} + +function parseTimestamp(raw: string | null | undefined): number | null { + if (!raw) return null + const trimmed = raw.trim() + if (!trimmed) return null + const withT = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T') + const lastPlus = withT.lastIndexOf('+') + const lastMinus = withT.lastIndexOf('-') + const hasOffset = lastPlus > 9 || lastMinus > 9 + const hasTimezone = withT.endsWith('Z') || hasOffset + const normalized = hasTimezone ? withT : `${withT}Z` + const ms = Date.parse(normalized) + return Number.isNaN(ms) ? null : ms +} + +function parseJsonString(raw: string): string { + try { + const parsed = JSON.parse(raw) as unknown + return typeof parsed === 'string' ? parsed : raw + } catch { + return raw + } +} + +function isFinalStatus(rawStatus: string): boolean { + const status = parseJsonString(rawStatus) + return status === 'Completed' || status === 'Cancelled' || status === 'Failed' +} + +function safeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 +} + +function extractCategoryTokens(categories: Record | undefined, key: string): number { + if (!categories) return 0 + return safeNumber(categories[key]) +} + +function estimateTokensFromChars(charCount: number): number { + // Mirror the host's token-estimate heuristic exactly (CHARS_PER_TOKEN = 4). + return Math.ceil(charCount / 4) +} + +function extractTokenBudget(rawConversationData: string): { tokenBudget: number; dominantModel: string } { + let conversationData: WarpConversationData + try { + conversationData = JSON.parse(rawConversationData) as WarpConversationData + } catch { + return { tokenBudget: 0, dominantModel: '' } + } + + const entries = conversationData.conversation_usage_metadata?.token_usage ?? [] + let primaryTotal = 0 + let fallbackTotal = 0 + let dominantPrimaryTokens = 0 + let dominantFallbackTokens = 0 + let dominantModel = '' + + for (const entry of entries) { + const primaryTokens = + extractCategoryTokens(entry.warp_token_usage_by_category, PRIMARY_AGENT_CATEGORY) + + extractCategoryTokens(entry.byok_token_usage_by_category, PRIMARY_AGENT_CATEGORY) + const entryTotal = safeNumber(entry.warp_tokens) + safeNumber(entry.byok_tokens) + + primaryTotal += primaryTokens + fallbackTotal += entryTotal + + if (primaryTokens > dominantPrimaryTokens) { + dominantPrimaryTokens = primaryTokens + dominantModel = typeof entry.model_id === 'string' ? entry.model_id : dominantModel + } + + if (dominantPrimaryTokens === 0 && entryTotal > dominantFallbackTokens) { + dominantFallbackTokens = entryTotal + dominantModel = typeof entry.model_id === 'string' ? entry.model_id : dominantModel + } + } + + const tokenBudget = primaryTotal > 0 ? primaryTotal : fallbackTotal + return { tokenBudget: Math.max(0, Math.round(tokenBudget)), dominantModel: normalizeModel(dominantModel) } +} + +function extractUserMessage(rawInput: string): string { + try { + const parsed = JSON.parse(rawInput) as unknown + if (!Array.isArray(parsed)) return '' + for (const item of parsed) { + if (!item || typeof item !== 'object') continue + const query = (item as { Query?: { text?: unknown } }).Query + if (!query || typeof query !== 'object') continue + if (typeof query.text === 'string' && query.text.trim()) return query.text + } + return '' + } catch { + return '' + } +} + +function estimateWeight(rawInput: string): number { + const userMessage = extractUserMessage(rawInput) + const source = userMessage || rawInput + const tokens = estimateTokensFromChars(source.length) + return Math.max(1, tokens) +} + +function allocateTokens(weights: number[], tokenBudget: number): number[] { + if (weights.length === 0) return [] + const normalizedWeights = weights.map(w => Math.max(0, Math.round(w))) + const totalWeight = normalizedWeights.reduce((sum, weight) => sum + weight, 0) + const budget = Math.max(0, Math.round(tokenBudget)) + + if (budget === 0) return normalizedWeights.map(() => 0) + if (totalWeight === 0) { + const even = Math.floor(budget / normalizedWeights.length) + const allocated = normalizedWeights.map(() => even) + let remainder = budget - even * normalizedWeights.length + let index = 0 + while (remainder > 0) { + allocated[index] = (allocated[index] ?? 0) + 1 + remainder-- + index = (index + 1) % normalizedWeights.length + } + return allocated + } + + const rawAllocation = normalizedWeights.map(weight => (budget * weight) / totalWeight) + const allocated = rawAllocation.map(value => Math.floor(value)) + let remainder = budget - allocated.reduce((sum, value) => sum + value, 0) + + const byLargestFraction = rawAllocation + .map((value, index) => ({ index, fraction: value - Math.floor(value) })) + .sort((a, b) => b.fraction - a.fraction) + + let pointer = 0 + while (remainder > 0 && byLargestFraction.length > 0) { + const index = byLargestFraction[pointer]!.index + allocated[index] = (allocated[index] ?? 0) + 1 + remainder-- + pointer = (pointer + 1) % byLargestFraction.length + } + + return allocated +} + +function resolveModelForExchange(exchange: WarpQueryRow, dominantModel: string): string { + const candidate = + exchange.model_id.trim() || + exchange.coding_model_id.trim() || + exchange.planning_model_id.trim() || + dominantModel || + 'warp-auto-efficient' + const normalized = normalizeModel(candidate) + if ((normalized === 'warp-auto-efficient' || normalized === 'warp-auto-powerful') && dominantModel) { + return dominantModel + } + return normalized +} + +function sanitizeProject(path: string): string { + return path.replace(/^\/+/, '').replace(/\//g, '-') +} + +function assignCommandBlocksToExchanges( + blocks: WarpBlockRow[], + exchanges: WarpParsedExchange[], +): Map { + const toolsByExchange = new Map() + + function getOrCreate(exchangeId: string): WarpExchangeToolInfo { + const existing = toolsByExchange.get(exchangeId) + if (existing) return existing + const created: WarpExchangeToolInfo = { tools: [], rawBashCommands: [] } + toolsByExchange.set(exchangeId, created) + return created + } + + for (const block of blocks) { + const blockStartMs = parseTimestamp(block.start_ts) + if (blockStartMs === null) continue + + let targetExchange: WarpParsedExchange | null = null + for (const exchange of exchanges) { + if (exchange.startMs > blockStartMs) break + targetExchange = exchange + } + if (!targetExchange) continue + + const info = getOrCreate(targetExchange.exchange_id) + if (!info.tools.includes('Bash')) info.tools.push('Bash') + + const commandText = block.stylized_command ?? '' + // The host textualizes the BLOB before handing rows to the decoder, so + // commandText is already a plain string here. + if (commandText && !info.rawBashCommands.includes(commandText)) { + info.rawBashCommands.push(commandText) + } + } + + return toolsByExchange +} + +export type WarpDecodeInput = { + records: unknown[] + context: DecodeContext + // Optional live dedup set the host mutates in place (its shared cross-file + // seenKeys). Simple sqlite providers never persist resume state, so there is + // no serialized `seenKeys` fallback. + seenKeys?: Set +} + +export type WarpDecodeResult = { + calls: WarpDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +/** + * Decode one Warp conversation (host-supplied conversation row + exchanges + + * textualized blocks + source project) into rich, cost-free calls. Dedup is + * keyed on `warp::` against the live `seenKeys` set + * (host-owned). + */ +export function decodeWarp({ records, seenKeys: liveSeen }: WarpDecodeInput): WarpDecodeResult { + const seen = liveSeen ?? new Set() + const calls: WarpDecodedCall[] = [] + const diagnostics: RecordDiagnostic[] = [] + + const composite = records[0] as + | { + conversationId: string + conversation: WarpConversationRow + exchanges: WarpQueryRow[] + blocks: WarpBlockRow[] + sourceProject: string + } + | undefined + if (!composite || typeof composite !== 'object') return { calls, diagnostics } + + const { conversationId, conversation, exchanges, blocks, sourceProject } = composite + if (!conversationId || !conversation) return { calls, diagnostics } + + const parsedExchanges: WarpParsedExchange[] = [] + for (const exchange of exchanges) { + if (!isFinalStatus(exchange.output_status)) continue + const startMs = parseTimestamp(exchange.start_ts) + if (startMs === null) continue + parsedExchanges.push({ ...exchange, startMs }) + } + if (parsedExchanges.length === 0) return { calls, diagnostics } + + const { tokenBudget, dominantModel } = extractTokenBudget(conversation.conversation_data) + const weights = parsedExchanges.map(exchange => estimateWeight(exchange.input)) + const fallbackBudget = weights.reduce((sum, weight) => sum + weight, 0) + const allocatedTokens = allocateTokens(weights, tokenBudget > 0 ? tokenBudget : fallbackBudget) + const toolsByExchange = assignCommandBlocksToExchanges(blocks, parsedExchanges) + + for (let index = 0; index < parsedExchanges.length; index++) { + const exchange = parsedExchanges[index]! + const deduplicationKey = `warp:${conversationId}:${exchange.exchange_id}` + if (seen.has(deduplicationKey)) continue + + const timestamp = new Date(exchange.startMs).toISOString() + const model = resolveModelForExchange(exchange, dominantModel) + const inputTokens = allocatedTokens[index] ?? 0 + const exchangeTools = toolsByExchange.get(exchange.exchange_id) ?? { tools: [], rawBashCommands: [] } + const userMessage = extractUserMessage(exchange.input).slice(0, 500) + const projectPath = exchange.working_directory?.trim() || undefined + const project = projectPath ? sanitizeProject(projectPath) : sourceProject + + seen.add(deduplicationKey) + calls.push({ + provider: 'warp', + model, + inputTokens, + // Warp exposes only conversation-level usage totals in these tables, + // so we cannot reliably split per-exchange input vs output tokens. + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + tools: exchangeTools.tools, + rawBashCommands: exchangeTools.rawBashCommands, + timestamp, + speed: 'standard', + deduplicationKey, + userMessage, + sessionId: conversationId, + project, + ...(projectPath ? { projectPath } : {}), + }) + } + + return { calls, diagnostics } +} diff --git a/packages/core/src/providers/warp/index.ts b/packages/core/src/providers/warp/index.ts new file mode 100644 index 00000000..41735dd0 --- /dev/null +++ b/packages/core/src/providers/warp/index.ts @@ -0,0 +1,24 @@ +// @codeburn/core Warp provider. +// +// Two layers: +// - Rich pure decode (`decodeWarp`): host-facing, NOT part of the stable +// minimized surface. Pure over host-supplied sqlite rows; carries content +// in-memory but no pricing (cost leaves the decoder) and no bash base-name +// extraction (that stays host-side with its `strip-ansi` dependency). +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { decodeWarp } from './decode.js' +export type { WarpDecodeInput, WarpDecodeResult } from './decode.js' +export { toObservations } from './observations.js' +export type { RichWarpSessionDecode, WarpToObservationsContext } from './observations.js' +export type { + WarpBlockRow, + WarpConversationData, + WarpConversationRow, + WarpDecodedCall, + WarpExchangeToolInfo, + WarpParsedExchange, + WarpQueryRow, + WarpTokenUsageEntry, +} from './types.js' diff --git a/packages/core/src/providers/warp/observations.ts b/packages/core/src/providers/warp/observations.ts new file mode 100644 index 00000000..90606683 --- /dev/null +++ b/packages/core/src/providers/warp/observations.ts @@ -0,0 +1,88 @@ +// Minimizing transform: rich Warp 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 { WarpDecodedCall } from './types.js' + +/** One Warp session's rich decode, as the host holds it before minimization. */ +export interface RichWarpSessionDecode { + sessionId: string + /** Absolute project path (the session working directory); fingerprinted, never emitted raw. */ + projectPath: string + /** Rich, cost-free calls in decode order (as decodeWarp emits them). */ + calls: WarpDecodedCall[] +} + +export interface WarpToObservationsContext { + /** 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. A name +// that does not match (a smuggled command with spaces/slashes, an argument blob) +// is dropped rather than emitted. +const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ + +function toCallObservation(call: WarpDecodedCall, turnIndex: number): 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: RichWarpSessionDecode, ctx: WarpToObservationsContext): SessionObservation { + const provider = ctx.provider ?? 'warp' + const calls: CallObservation[] = decode.calls.map((call, i) => toCallObservation(call, i)) + + 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 Warp 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, file path) is ever copied into the result. Only fingerprints, enums, + * numbers, timestamps, dedup keys, and canonical tool names cross the boundary. + */ +export function toObservations( + decode: RichWarpSessionDecode | RichWarpSessionDecode[], + ctx: WarpToObservationsContext, +): { 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/warp/types.ts b/packages/core/src/providers/warp/types.ts new file mode 100644 index 00000000..ee860bd8 --- /dev/null +++ b/packages/core/src/providers/warp/types.ts @@ -0,0 +1,84 @@ +// Raw sqlite-row + rich-decode types for the Warp provider. +// +// The record types describe the shape of the rows the host reads from Warp's +// warp.sqlite. The CLI textualizes the `stylized_command` BLOB before handing +// rows to the core decoder (blobToText is I/O-adjacent and stays host-side). +// The Decoded* types are the rich decode layer's output: pure over supplied +// records, carrying content in-memory but NO host-side pricing. The CLI adapter +// maps WarpDecodedCall into its own ParsedProviderCall by adding costBasis, +// extracting bash base commands, and running the pricing pass. + +export type WarpConversationRow = { + conversation_id: string + conversation_data: string + last_modified_at: string | null +} + +export type WarpQueryRow = { + exchange_id: string + conversation_id: string + start_ts: string + input: string + working_directory: string | null + output_status: string + model_id: string + planning_model_id: string + coding_model_id: string +} + +/** Block row after the host has textualized `stylized_command`. */ +export type WarpBlockRow = { + block_id: string + start_ts: string | null + stylized_command: string | null +} + +export type WarpTokenUsageEntry = { + model_id?: string + warp_tokens?: number + byok_tokens?: number + warp_token_usage_by_category?: Record + byok_token_usage_by_category?: Record +} + +export type WarpConversationData = { + conversation_usage_metadata?: { + token_usage?: WarpTokenUsageEntry[] + } +} + +export type WarpParsedExchange = WarpQueryRow & { + startMs: number +} + +export type WarpExchangeToolInfo = { + tools: string[] + rawBashCommands: string[] +} + +/** + * Rich decode of one Warp exchange, pre-pricing. Mirrors the host's + * ParsedProviderCall minus cost fields. `rawBashCommands` are the un-split shell + * command strings from command blocks; the CLI adapter runs its own base-name + * extraction on them. + */ +export type WarpDecodedCall = { + provider: 'warp' + 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 + project: string + projectPath?: string +} diff --git a/packages/core/tests/architecture-gate.test.ts b/packages/core/tests/architecture-gate.test.ts index 19edab77..c212421e 100644 --- a/packages/core/tests/architecture-gate.test.ts +++ b/packages/core/tests/architecture-gate.test.ts @@ -157,6 +157,16 @@ const USER_MESSAGE_ALLOWLIST = new Set([ 'src/providers/forge/types.ts', 'src/providers/goose/decode.ts', 'src/providers/goose/types.ts', + 'src/providers/hermes/decode.ts', + 'src/providers/hermes/types.ts', + 'src/providers/warp/decode.ts', + 'src/providers/warp/types.ts', + 'src/providers/cursor-agent/decode.ts', + 'src/providers/cursor-agent/types.ts', + 'src/providers/quickdesk/decode.ts', + 'src/providers/quickdesk/types.ts', + 'src/providers/devin/decode.ts', + 'src/providers/devin/types.ts', ]) describe('architecture gate: no classification or free text in @codeburn/core source', () => { diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index 89a8c3b0..1b9848ee 100644 --- a/packages/core/tests/content-smuggling.test.ts +++ b/packages/core/tests/content-smuggling.test.ts @@ -29,6 +29,11 @@ import { decodeOpenClaw, toObservations as toOpenClawObservations } from '../src import { decodeZed, toObservations as toZedObservations } from '../src/providers/zed/index.js' import { decodeForge, toObservations as toForgeObservations } from '../src/providers/forge/index.js' import { decodeGoose, toObservations as toGooseObservations } from '../src/providers/goose/index.js' +import { decodeHermes, toObservations as toHermesObservations } from '../src/providers/hermes/index.js' +import { decodeWarp, toObservations as toWarpObservations } from '../src/providers/warp/index.js' +import { decodeCursorAgent, toObservations as toCursorAgentObservations } from '../src/providers/cursor-agent/index.js' +import { decodeQuickdesk, toObservations as toQuickdeskObservations } from '../src/providers/quickdesk/index.js' +import { decodeDevin, toObservations as toDevinObservations } from '../src/providers/devin/index.js' import type { DecodeContext } from '../src/contracts.js' import type { ZedThreadRow } from '../src/providers/zed/index.js' @@ -886,3 +891,385 @@ describe('content-smuggling guardrail: diagnostic detail rejects paths', () => { expect(DiagnosticDetail.safeParse(SECRETS.commandLine).success).toBe(false) }) }) + +describe('content-smuggling guardrail: real hermes decode -> toObservations is secret-free', () => { + // A hostile Hermes sqlite session planting every secret in the free-text fields + // the decode captures: the user prompt, a terminal command, a read_file path, + // and a tool NAME carrying a command line. Decoding it fully and minimizing + // MUST surface none of them. + const hermesContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'hermes', sourceRef: 'ref' } + + function decodeAndMinimize() { + const session = { + id: 'sess-hostile', + source: 'cli', + model: 'claude-sonnet-4-20250514', + cwd: SECRETS.absPath, + billing_provider: 'openai-codex', + input_tokens: 1000, + output_tokens: 200, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 50, + estimated_cost_usd: null, + actual_cost_usd: null, + api_call_count: 1, + tool_call_count: 3, + started_at: 1779549200, + ended_at: null, + title: 'Hostile', + } + const messages = [ + { id: 1, role: 'user', content: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}`, tool_calls: null, tool_name: null, timestamp: 1779549201 }, + { + id: 2, + role: 'assistant', + content: null, + tool_calls: JSON.stringify([ + { function: { name: 'read_file', arguments: JSON.stringify({ path: SECRETS.absPath }) } }, + { function: { name: 'terminal', arguments: JSON.stringify({ command: SECRETS.commandLine }) } }, + // A hostile tool NAME carrying a command line: fails canonical charset. + { function: { name: SECRETS.commandLine, arguments: '{}' } }, + ]), + tool_name: null, + timestamp: 1779549202, + }, + ] + const { calls } = decodeHermes({ records: [{ session, messages, profile: 'default' }], context: hermesContext }) + const { sessions } = toHermesObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'hermes' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) + + it('keeps canonical tool names (Bash/Read) and drops the argument-carrying name', () => { + const env = decodeAndMinimize() + const allToolNames = env.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).toContain('Bash') + expect(allToolNames).toContain('Read') + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) + + it('fingerprints the read_file path into a 16-hex resourceRead, never the raw path', () => { + const env = decodeAndMinimize() + const reads = env.sessions.flatMap(s => s.calls.flatMap(c => c.resourceReads ?? [])) + expect(reads.length).toBeGreaterThan(0) + for (const ref of reads) { + expect(ref.resourceId).toMatch(/^[0-9a-f]{16}$/) + expect(typeof ref.resourceClass).toBe('string') + } + expect(allStrings(reads)).not.toContain(SECRETS.absPath) + }) +}) + +describe('content-smuggling guardrail: real warp decode -> toObservations is secret-free', () => { + // A hostile Warp sqlite session planting every secret in the free-text fields + // the decode captures: the user prompt and the raw command block text. The + // working directory is also a secret path. Minimizing MUST surface none of them. + const warpContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'warp', sourceRef: 'ref' } + + function decodeAndMinimize() { + const conversation: { conversation_id: string; conversation_data: string; last_modified_at: string } = { + conversation_id: 'sess-hostile', + conversation_data: JSON.stringify({ + conversation_usage_metadata: { + token_usage: [ + { + model_id: 'GPT-5.3 Codex (medium reasoning)', + warp_tokens: 100, + byok_tokens: 0, + warp_token_usage_by_category: { primary_agent: 100 }, + byok_token_usage_by_category: {}, + }, + ], + }, + }), + last_modified_at: '2026-07-17 10:10:00', + } + const exchanges = [ + { + exchange_id: 'ex-hostile', + conversation_id: 'sess-hostile', + start_ts: '2026-07-17T10:00:00.000000', + input: JSON.stringify([{ Query: { text: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}` } }]), + working_directory: SECRETS.absPath, + output_status: '"Completed"', + model_id: 'auto-efficient', + planning_model_id: '', + coding_model_id: '', + }, + ] + const blocks = [ + { + block_id: 'block-hostile', + start_ts: '2026-07-17T10:00:01.000000', + stylized_command: SECRETS.commandLine, + }, + ] + const { calls } = decodeWarp({ + records: [{ conversationId: 'sess-hostile', conversation, exchanges, blocks, sourceProject: 'warp' }], + context: warpContext, + }) + const { sessions } = toWarpObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'warp' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) + + it('keeps the canonical Bash tool name and never emits the raw command', () => { + const env = decodeAndMinimize() + const allToolNames = env.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).toContain('Bash') + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) +}) + +describe('content-smuggling guardrail: real cursor-agent decode -> toObservations is secret-free', () => { + // A hostile Cursor Agent transcript planting every secret in the free-text + // fields the decode captures: the user prompt, the assistant body, reasoning + // text, and a tool NAME carrying a command line. Minimizing MUST surface none + // of them. + const cursorAgentContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'cursor-agent', sourceRef: 'ref' } + + function decodeAndMinimize() { + const transcript = [ + 'user:', + `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}`, + 'A:', + '[Thinking] ' + SECRETS.commandLine, + SECRETS.absPath, + '[Tool call] ' + SECRETS.commandLine, + ].join('\n') + + const { calls } = decodeCursorAgent({ + records: [{ + summary: null, + transcript, + transcriptPath: SECRETS.absPath, + fileMtime: '2026-07-17T10:00:00.000Z', + }], + context: cursorAgentContext, + }) + const { sessions } = toCursorAgentObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'cursor-agent' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile transcript', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) + + it('drops non-canonical (argument-carrying) tool names instead of emitting them', () => { + const env = decodeAndMinimize() + const allToolNames = env.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) +}) + +describe('content-smuggling guardrail: real quickdesk decode -> toObservations is secret-free', () => { + // A hostile Quickdesk session planting every secret in the free-text fields the + // decode captures: the user prompt and tool_names (both from metrics-linked + // sessions and from database estimates). Minimizing MUST surface none of them. + const quickdeskContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'quickdesk', sourceRef: 'ref' } + + function decodeAndMinimize() { + const sessions = new Map() + sessions.set('sess-hostile', { + id: 'sess-hostile', + title: 'Hostile', + agentMode: 'agent', + createdAt: 1783987200, + deleted: false, + firstUserMessage: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}`, + inputChars: 100, + outputChars: 50, + tools: [SECRETS.commandLine], + }) + + const metricsRecords = [ + { record: { session_id: 'sess-hostile', ToolName: SECRETS.commandLine } }, + { + record: { + _aws: { Timestamp: 1783987200123 }, + session_id: 'sess-hostile', + Model: 'claude-sonnet-4-5', + InputTokens: 100, + OutputTokens: 50, + }, + }, + ] + const { calls: metricsCalls } = decodeQuickdesk({ + records: [{ + variant: 'metrics', + records: metricsRecords, + sessions, + project: 'hostile-project', + projectPath: SECRETS.absPath, + fileId: 'metrics-2026-07-17.jsonl', + }], + context: quickdeskContext, + }) + + const dbSessions = [{ + id: 'db-hostile', + title: 'DB Hostile', + agentMode: 'agent', + createdAt: 1783987200, + deleted: false, + firstUserMessage: `${SECRETS.prompt} ${SECRETS.apiKey}`, + inputChars: 20, + outputChars: 10, + tools: [SECRETS.commandLine], + }] + const { calls: dbCalls } = decodeQuickdesk({ + records: [{ + variant: 'database', + sessions: dbSessions, + meteredSessionIds: new Set(), + project: 'hostile-project', + projectPath: SECRETS.absPath, + }], + context: quickdeskContext, + }) + + const allCalls = [...metricsCalls, ...dbCalls] + const { sessions: observed } = toQuickdeskObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls: allCalls }, + { privacyKey: 'test-privacy-key', provider: 'quickdesk' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions: observed, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) + + it('drops non-canonical (argument-carrying) tool names instead of emitting them', () => { + const env = decodeAndMinimize() + const allToolNames = env.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) +}) + +describe('content-smuggling guardrail: real devin decode -> toObservations is secret-free', () => { + // A hostile Devin transcript planting every secret in the free-text fields the + // decode captures: the user prompt, a tool NAME carrying a command line, and + // tool arguments containing a secret path. Minimizing MUST surface none of them. + const devinContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'devin', sourceRef: '/tmp/devin/transcripts/sess-hostile.json' } + + function decodeAndMinimize() { + const transcript = { + schema_version: '1.7', + session_id: 'sess-hostile', + agent: { name: 'devin', version: '2.0', model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + message: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}`, + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + source: 'assistant', + message: 'reading file', + tool_calls: [ + { tool_call_id: 'tc1', function_name: 'read_file', arguments: { path: SECRETS.absPath } }, + // A hostile tool NAME carrying a command line (spaces + slashes): it + // fails the canonical charset and must be dropped, not emitted. + { tool_call_id: 'tc2', function_name: SECRETS.commandLine, arguments: {} }, + ], + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 100 }, + }, + }, + ], + } + const { calls } = decodeDevin({ records: [{ transcript, session: null, project: 'devin' }], context: devinContext }) + const { sessions } = toDevinObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'devin' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile transcript', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) + + it('keeps canonical tool names (read_file) and drops the argument-carrying name', () => { + const env = decodeAndMinimize() + const allToolNames = env.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).toContain('read_file') + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) +}) + diff --git a/packages/core/tests/providers/cursor-agent-decode.test.ts b/packages/core/tests/providers/cursor-agent-decode.test.ts new file mode 100644 index 00000000..76cc98b5 --- /dev/null +++ b/packages/core/tests/providers/cursor-agent-decode.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' + +import { decodeCursorAgent, toObservations } from '../../src/providers/cursor-agent/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import type { DecodeContext } from '../../src/contracts.js' +import type { CursorAgentRecord } from '../../src/providers/cursor-agent/types.js' + +const context: DecodeContext = { privacyKey: 'k', providerId: 'cursor-agent', sourceRef: 'ref' } + +function makeRecord(opts: { + transcript: string + transcriptPath?: string + summaryModel?: string | null + summaryUpdatedAt?: string | null + fileMtime?: string + conversationId?: string +}): CursorAgentRecord { + return { + summary: opts.summaryModel === undefined && opts.summaryUpdatedAt === undefined + ? null + : { + conversationId: 'sess-a', + model: opts.summaryModel ?? null, + title: null, + updatedAt: opts.summaryUpdatedAt ?? null, + }, + transcript: opts.transcript, + transcriptPath: opts.transcriptPath ?? '/data/projects/my-proj/agent-transcripts/123e4567-e89b-12d3-a456-426614174000.txt', + fileMtime: opts.fileMtime ?? '2026-05-16T10:00:00.000Z', + conversationId: opts.conversationId ?? '123e4567-e89b-12d3-a456-426614174000', + } +} + +describe('cursor-agent rich decode (moved to @codeburn/core)', () => { + it('decodes a txt transcript into a cost-free rich call', () => { + const { calls } = decodeCursorAgent({ + records: [makeRecord({ transcript: 'user:\nexplain parser output\nA:\nfirst line\nsecond line\n' })], + context, + }) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('cursor-agent') + expect(call.model).toBe('cursor-agent-auto') + expect(call.inputTokens).toBe(6) + expect(call.outputTokens).toBe(6) + expect(call.reasoningTokens).toBe(0) + expect(call.cacheReadInputTokens).toBe(0) + expect(call.cacheCreationInputTokens).toBe(0) + expect(call.tools).toEqual([]) + expect(call.rawBashCommands).toEqual([]) + expect(call.userMessage).toBe('explain parser output') + expect(call.deduplicationKey).toBe('cursor-agent:123e4567-e89b-12d3-a456-426614174000:0') + expect(call.sessionId).toBe('123e4567-e89b-12d3-a456-426614174000') + expect(call.speed).toBe('standard') + expect(call.timestamp).toBe('2026-05-16T10:00:00.000Z') + expect(call).not.toHaveProperty('costUSD') + expect(call).not.toHaveProperty('costBasis') + }) + + it('uses summary model and updatedAt when present', () => { + const { calls } = decodeCursorAgent({ + records: [makeRecord({ + transcript: 'user:\nhello\nA:\nworld\n', + summaryModel: 'claude-4.6-sonnet', + summaryUpdatedAt: '2025-01-01T00:00:00.000Z', + fileMtime: '2026-05-16T10:00:00.000Z', + })], + context, + }) + + expect(calls[0]!.model).toBe('claude-4.6-sonnet') + expect(calls[0]!.timestamp).toBe('2025-01-01T00:00:00.000Z') + }) + + it('falls back to fileMtime when summary updatedAt is absent', () => { + const { calls } = decodeCursorAgent({ + records: [makeRecord({ + transcript: 'user:\nhello\nA:\nworld\n', + summaryModel: 'claude-4.6-sonnet', + summaryUpdatedAt: null, + fileMtime: '2026-05-16T10:00:00.000Z', + })], + context, + }) + + expect(calls[0]!.timestamp).toBe('2026-05-16T10:00:00.000Z') + }) + + it('maps tools from txt [Tool call] markers', () => { + const { calls } = decodeCursorAgent({ + records: [makeRecord({ + transcript: 'user:\nrun tools\nA:\n[Tool call] Read file\n[Tool result] ok\n[Tool call] Run command\n', + })], + context, + }) + + expect(calls[0]!.tools).toEqual(['cursor:read-file', 'cursor:run-command']) + }) + + it('maps tools from jsonl tool_use blocks', () => { + const { calls } = decodeCursorAgent({ + records: [makeRecord({ + transcriptPath: '/data/sess-a.jsonl', + transcript: JSON.stringify({ role: 'user', message: { content: [{ type: 'text', text: 'run tools' }] } }) + '\n' + + JSON.stringify({ role: 'assistant', message: { content: [{ type: 'tool_use', name: 'EditFile' }, { type: 'text', text: 'done' }] } }), + })], + context, + }) + + expect(calls[0]!.tools).toEqual(['cursor:editfile']) + }) + + it('extracts reasoning from [Thinking] markers', () => { + const { calls } = decodeCursorAgent({ + records: [makeRecord({ + transcript: 'user:\nthink\nA:\n[Thinking] private reasoning\nvisible output\n', + })], + context, + }) + + expect(calls[0]!.reasoningTokens).toBeGreaterThan(0) + expect(calls[0]!.outputTokens).toBeGreaterThan(0) + }) + + it('dedups repeated turns using the host-owned seenKeys set', () => { + const seen = new Set() + const record = makeRecord({ transcript: 'user:\nhello\nA:\nworld\n' }) + const first = decodeCursorAgent({ records: [record], context, seenKeys: seen }).calls + expect(first).toHaveLength(1) + const again = decodeCursorAgent({ records: [record], context, seenKeys: seen }).calls + expect(again).toEqual([]) + }) + + it('skips unrecognized transcripts', () => { + const { calls } = decodeCursorAgent({ + records: [makeRecord({ transcript: 'no markers in this transcript' })], + context, + }) + + expect(calls).toEqual([]) + }) + + it('skips jsonl transcripts with no recognizable turns', () => { + const { calls } = decodeCursorAgent({ + records: [makeRecord({ + transcriptPath: '/data/sess-a.jsonl', + transcript: '{"role":"system","message":{"content":[{"type":"text","text":"hello"}]}}', + })], + context, + }) + + expect(calls).toEqual([]) + }) + + it('uses the host-supplied conversation id verbatim for session id and dedup key', () => { + // Deriving the id from the transcript path (uuid stem vs sha1 fallback) is + // host-side, so a single id is authoritative; the decoder never re-derives. + const { calls } = decodeCursorAgent({ + records: [makeRecord({ + transcriptPath: '/data/projects/proj/agent-transcripts/not-a-uuid.txt', + transcript: 'user:\nhello\nA:\nworld\n', + conversationId: 'a1b2c3d4e5f60718', + })], + context, + }) + + expect(calls[0]!.sessionId).toBe('a1b2c3d4e5f60718') + expect(calls[0]!.deduplicationKey).toBe('cursor-agent:a1b2c3d4e5f60718:0') + }) + + it('toObservations produces a schema-valid, content-free envelope', () => { + const { calls } = decodeCursorAgent({ + records: [makeRecord({ + transcript: 'user:\nhello\nA:\nworld\n', + summaryModel: 'claude-4.6-sonnet', + summaryUpdatedAt: '2025-01-01T00:00:00.000Z', + })], + context, + }) + + const { sessions } = toObservations( + { sessionId: '123e4567-e89b-12d3-a456-426614174000', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'cursor-agent' }, + ) + + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.costBasis).toBe('estimated') + }) +}) diff --git a/packages/core/tests/providers/devin-decode.test.ts b/packages/core/tests/providers/devin-decode.test.ts new file mode 100644 index 00000000..b653c87b --- /dev/null +++ b/packages/core/tests/providers/devin-decode.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, it } from 'vitest' + +import { decodeDevin, toObservations } from '../../src/providers/devin/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import type { DecodeContext } from '../../src/contracts.js' +import type { DevinAgentTrajectory, DevinDecodeRecord, DevinSessionMetadata } from '../../src/providers/devin/types.js' + +const context: DecodeContext = { privacyKey: 'k', providerId: 'devin', sourceRef: '/tmp/devin/transcripts/sess-a.json' } + +function makeRecord( + transcript: DevinAgentTrajectory, + session: DevinSessionMetadata | null = null, + project = 'devin', + sessionId?: string, +): DevinDecodeRecord { + return { transcript, session, project, sessionId: sessionId ?? transcript.session_id ?? 'sess-a' } +} + +const BASE_TRANSCRIPT: DevinAgentTrajectory = { + schema_version: '1.7', + session_id: 'sess-a', + agent: { name: 'devin', version: '2.0', model_name: 'agent-model' }, + steps: [], +} + +const BASE_SESSION: DevinSessionMetadata = { + id: 'sess-a', + workingDirectory: '/Users/me/projects/codeburn', + model: 'claude-sonnet-4-6', + title: 'Test', + createdAt: '2027-01-15T08:00:00.000Z', + lastActivityAt: '2027-01-15T08:00:10.000Z', + hidden: false, +} + +describe('devin rich decode (moved to @codeburn/core)', () => { + it('decodes assistant calls into cost-free rich calls, skipping user-input and empty steps', () => { + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + steps: [ + { + step_id: 1, + message: 'fix the bug', + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + source: 'assistant', + model_name: 'step-model', + message: 'reading file', + tool_calls: [{ tool_call_id: 'tc1', function_name: 'read_file', arguments: { path: 'src/main.ts' } }], + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + generation_model: 'claude-opus-4-6', + metrics: { input_tokens: 100, output_tokens: 20, cache_creation_tokens: 10, cache_read_tokens: 5 }, + }, + }, + { + step_id: 3, + source: 'assistant', + message: 'empty step', + metadata: { created_at: '2027-01-15T08:00:02.000Z' }, + }, + ], + } + + const { calls } = decodeDevin({ records: [makeRecord(transcript, BASE_SESSION)], context }) + expect(calls).toHaveLength(1) + + const call = calls[0]! + expect(call.provider).toBe('devin') + expect(call.generationModel).toBe('claude-opus-4-6') + expect(call.inputTokens).toBe(100) + expect(call.outputTokens).toBe(20) + expect(call.cacheCreationInputTokens).toBe(10) + expect(call.cacheReadInputTokens).toBe(5) + expect(call.cachedInputTokens).toBe(5) + expect(call.reasoningTokens).toBe(0) + expect(call.webSearchRequests).toBe(0) + expect(call.tools).toEqual(['read_file']) + expect(call.rawBashCommands).toEqual([]) + expect(call.userMessage).toBe('fix the bug') + expect(call.deduplicationKey).toBe('devin:sess-a:2') + expect(call.sessionId).toBe('sess-a') + expect(call.project).toBe('codeburn') + expect(call.projectPath).toBe('/Users/me/projects/codeburn') + expect(call.committedAcuCost).toBe(0.1) + // No pricing crosses into the decode layer. + expect(call).not.toHaveProperty('costUSD') + expect(call).not.toHaveProperty('costBasis') + }) + + it('threads a live seenKeys set so a repeated step drops across passes', () => { + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + steps: [ + { + step_id: 2, + source: 'assistant', + message: 'working', + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 10 }, + }, + }, + ], + } + + const seen = new Set() + const first = decodeDevin({ records: [makeRecord(transcript)], context, seenKeys: seen }).calls + expect(first).toHaveLength(1) + const again = decodeDevin({ records: [makeRecord(transcript)], context, seenKeys: seen }).calls + expect(again).toEqual([]) + }) + + it('prefers step.metrics over metadata.metrics when both are present', () => { + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + steps: [ + { + step_id: 1, + source: 'assistant', + message: 'working', + metrics: { + prompt_tokens: 500, + completion_tokens: 100, + cached_tokens: 20, + extra: { cache_creation_input_tokens: 30 }, + }, + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 1, output_tokens: 1, cache_creation_tokens: 1, cache_read_tokens: 1 }, + }, + }, + ], + } + + const { calls } = decodeDevin({ records: [makeRecord(transcript)], context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(500) + expect(calls[0]!.outputTokens).toBe(100) + expect(calls[0]!.cacheCreationInputTokens).toBe(30) + expect(calls[0]!.cacheReadInputTokens).toBe(20) + }) + + it('falls back to metadata.metrics when step.metrics is present but empty', () => { + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + steps: [ + { + step_id: 1, + source: 'assistant', + message: 'working', + metrics: {}, + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 80, output_tokens: 20, cache_read_tokens: 5 }, + }, + }, + ], + } + + const { calls } = decodeDevin({ records: [makeRecord(transcript)], context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(80) + expect(calls[0]!.outputTokens).toBe(20) + expect(calls[0]!.cacheReadInputTokens).toBe(5) + }) + + it('reads ACU cost from step.extra when metadata.committed_acu_cost is absent', () => { + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + steps: [ + { + step_id: 1, + source: 'assistant', + message: 'working', + extra: { committed_acu_cost: 0.3 }, + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + metrics: { input_tokens: 10 }, + }, + }, + ], + } + + const { calls } = decodeDevin({ records: [makeRecord(transcript)], context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.committedAcuCost).toBe(0.3) + }) + + it('prefers metadata.committed_acu_cost over extra.committed_acu_cost', () => { + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + steps: [ + { + step_id: 1, + source: 'assistant', + message: 'working', + extra: { committed_acu_cost: 0.99 }, + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + committed_acu_cost: 0.11, + metrics: { input_tokens: 10 }, + }, + }, + ], + } + + const { calls } = decodeDevin({ records: [makeRecord(transcript)], context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.committedAcuCost).toBe(0.11) + }) + + it('uses the host-supplied session id verbatim for session id and dedup key', () => { + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + session_id: undefined, + steps: [ + { + step_id: 1, + source: 'assistant', + message: 'working', + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 10 }, + }, + }, + ], + } + + // The transcript omits session_id, so the host derived the id from the + // filename and passed it in; the decoder never re-derives it. + const { calls } = decodeDevin({ + records: [makeRecord(transcript, null, 'devin', 'fallback-session')], + context: { ...context, sourceRef: '/tmp/devin/transcripts/fallback-session.json' }, + }) + expect(calls).toHaveLength(1) + expect(calls[0]!.sessionId).toBe('fallback-session') + expect(calls[0]!.deduplicationKey).toBe('devin:fallback-session:1') + }) + + it('emits the RAW generation_model and model_name ids, never a display name', () => { + // Display formatting needs the host's model table and stays CLI-side; the + // decoder only resolves which raw id wins each precedence chain. + const cases: Array<{ generationModel: string; modelName: string }> = [ + { generationModel: 'gpt-5-3-codex-xhigh', modelName: 'GPT-5.4' }, + { generationModel: 'gpt-5-4-low', modelName: 'GPT-5.5' }, + { generationModel: 'MODEL_PRIVATE_11', modelName: 'Gemini 3 Flash' }, + { generationModel: 'claude-opus-4-6', modelName: 'agent-model' }, + ] + + for (const row of cases) { + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + session_id: `model-${row.generationModel}`, + agent: { ...BASE_TRANSCRIPT.agent, model_name: row.modelName }, + steps: [ + { + step_id: 1, + source: 'assistant', + message: 'working', + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + committed_acu_cost: 0.1, + generation_model: row.generationModel, + metrics: { input_tokens: 1 }, + }, + }, + ], + } + + const { calls } = decodeDevin({ + records: [makeRecord(transcript)], + context: { ...context, sourceRef: `/tmp/devin/transcripts/model-${row.generationModel}.json` }, + }) + expect(calls).toHaveLength(1) + expect(calls[0]!.generationModel).toBe(row.generationModel) + expect(calls[0]!.modelName).toBe(row.modelName) + } + }) + + it('extracts user message from ContentPart[] messages', () => { + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + steps: [ + { + step_id: 1, + message: [ + { type: 'text', text: 'look at this' }, + { type: 'image', source: { media_type: 'image/png', path: '/tmp/screenshot.png' } }, + ], + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + source: 'assistant', + message: 'working', + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 50 }, + }, + }, + ], + } + + const { calls } = decodeDevin({ records: [makeRecord(transcript)], context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.userMessage).toBe('look at this /tmp/screenshot.png') + }) + + it('skips sessions hidden in sessions.db', () => { + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + steps: [ + { + step_id: 1, + source: 'assistant', + message: 'working', + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 10 }, + }, + }, + ], + } + + const hiddenSession: DevinSessionMetadata = { ...BASE_SESSION, hidden: true } + const { calls } = decodeDevin({ records: [makeRecord(transcript, hiddenSession)], context }) + expect(calls).toEqual([]) + }) + + it('toObservations produces a schema-valid, content-free envelope', () => { + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + steps: [ + { + step_id: 1, + message: 'fix the bug', + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + source: 'assistant', + message: 'reading file', + tool_calls: [{ tool_call_id: 'tc1', function_name: 'read_file', arguments: { path: 'src/main.ts' } }], + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 100 }, + }, + }, + ], + } + + const { calls } = decodeDevin({ records: [makeRecord(transcript, BASE_SESSION)], context }) + const { sessions } = toObservations( + { sessionId: 'sess-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'devin' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + }) + + it('toObservations emits measured cost basis', () => { + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + steps: [ + { + step_id: 1, + source: 'assistant', + message: 'working', + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + committed_acu_cost: 0.5, + metrics: { input_tokens: 100 }, + }, + }, + ], + } + + const { calls } = decodeDevin({ records: [makeRecord(transcript)], context }) + const { sessions } = toObservations( + { sessionId: 'sess-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'devin' }, + ) + expect(sessions[0]!.calls[0]!.costBasis).toBe('measured') + }) +}) diff --git a/packages/core/tests/providers/hermes-decode.test.ts b/packages/core/tests/providers/hermes-decode.test.ts new file mode 100644 index 00000000..6c9ce681 --- /dev/null +++ b/packages/core/tests/providers/hermes-decode.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'vitest' + +import { decodeHermes, toObservations } from '../../src/providers/hermes/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import type { DecodeContext } from '../../src/contracts.js' +import type { HermesMessageRow, HermesSessionRow } from '../../src/providers/hermes/types.js' + +const context: DecodeContext = { privacyKey: 'k', providerId: 'hermes', sourceRef: 'ref' } + +function makeComposite(session: HermesSessionRow, messages: HermesMessageRow[], profile = 'default') { + return { session, messages, profile } +} + +const BASE_SESSION: HermesSessionRow = { + id: 'sess-a', + source: 'cli', + model: 'claude-sonnet-4-20250514', + cwd: '/Users/me/projects/codeburn', + billing_provider: 'openai-codex', + input_tokens: 1000, + output_tokens: 200, + cache_read_tokens: 50, + cache_write_tokens: 10, + reasoning_tokens: 25, + estimated_cost_usd: null, + actual_cost_usd: null, + api_call_count: 3, + tool_call_count: 2, + started_at: 1779549200, + ended_at: null, + title: 'Test', +} + +describe('hermes rich decode (moved to @codeburn/core)', () => { + it('decodes a session row + messages into a cost-free rich call', () => { + const messages: HermesMessageRow[] = [ + { id: 1, role: 'user', content: 'Implement Hermes support', tool_calls: null, tool_name: null, timestamp: 1779549201 }, + { + id: 2, + role: 'assistant', + content: null, + tool_calls: JSON.stringify([ + { function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/hermes.ts' }) } }, + { function: { name: 'terminal', arguments: JSON.stringify({ command: 'npm test' }) } }, + ]), + tool_name: null, + timestamp: 1779549202, + }, + ] + const { calls } = decodeHermes({ records: [makeComposite(BASE_SESSION, messages)], context }) + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('hermes') + expect(call.model).toBe('claude-sonnet-4-20250514') + expect(call.inputTokens).toBe(1000) + expect(call.outputTokens).toBe(200) + expect(call.cacheReadInputTokens).toBe(50) + expect(call.cacheCreationInputTokens).toBe(10) + expect(call.reasoningTokens).toBe(25) + expect(call.tools).toEqual(['Read', 'Bash']) + expect(call.rawBashCommands).toEqual(['npm test']) + expect(call.userMessage).toBe('Implement Hermes support') + expect(call.deduplicationKey).toBe('hermes:default:sess-a') + expect(call.turnId).toBe('sess-a:session') + expect(call.sessionId).toBe('sess-a') + expect(call.project).toBe('Users-me-projects-codeburn') + expect(call.projectPath).toBe('/Users/me/projects/codeburn') + expect(call.recordedCost).toBeUndefined() + expect(call).not.toHaveProperty('costUSD') + expect(call).not.toHaveProperty('costBasis') + }) + + it('skips a zero-token session', () => { + const session: HermesSessionRow = { ...BASE_SESSION, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0 } + const { calls } = decodeHermes({ records: [makeComposite(session, [])], context }) + expect(calls).toHaveLength(0) + }) + + it('threads a live seenKeys set so a repeated session drops', () => { + const messages: HermesMessageRow[] = [ + { id: 1, role: 'user', content: 'Hello', tool_calls: null, tool_name: null, timestamp: 1779549201 }, + ] + const seen = new Set() + const first = decodeHermes({ records: [makeComposite(BASE_SESSION, messages)], context, seenKeys: seen }).calls + expect(first).toHaveLength(1) + const again = decodeHermes({ records: [makeComposite(BASE_SESSION, messages)], context, seenKeys: seen }).calls + expect(again).toEqual([]) + }) + + it('maps composio MCP tools before generic MCP prefixes', () => { + const messages: HermesMessageRow[] = [ + { + id: 1, + role: 'assistant', + content: null, + tool_calls: JSON.stringify([ + { function: { name: 'mcp_composio_GMAIL_SEND_EMAIL', arguments: '{}' } }, + { function: { name: 'mcp__github__create_issue', arguments: '{}' } }, + ]), + tool_name: null, + timestamp: 1779549201, + }, + ] + const { calls } = decodeHermes({ records: [makeComposite(BASE_SESSION, messages)], context }) + expect(calls[0]!.tools).toEqual(['MCP', 'mcp__github__create_issue']) + }) + + it('maps browser_* tools to Browser', () => { + const messages: HermesMessageRow[] = [ + { + id: 1, + role: 'assistant', + content: null, + tool_calls: JSON.stringify([ + { function: { name: 'browser_navigate', arguments: '{}' } }, + { function: { name: 'browser_click', arguments: '{}' } }, + ]), + tool_name: null, + timestamp: 1779549201, + }, + ] + const { calls } = decodeHermes({ records: [makeComposite(BASE_SESSION, messages)], context }) + expect(calls[0]!.tools).toEqual(['Browser']) + }) + + it('counts tool-result messages by their tool_name', () => { + const messages: HermesMessageRow[] = [ + { id: 1, role: 'tool', content: null, tool_calls: null, tool_name: 'read_file', timestamp: 1779549201 }, + ] + const { calls } = decodeHermes({ records: [makeComposite(BASE_SESSION, messages)], context }) + expect(calls[0]!.tools).toContain('Read') + }) + + it('falls back to unknown when model is missing', () => { + const session: HermesSessionRow = { ...BASE_SESSION, model: null } + const messages: HermesMessageRow[] = [ + { id: 1, role: 'user', content: 'Hello', tool_calls: null, tool_name: null, timestamp: 1779549201 }, + ] + const { calls } = decodeHermes({ records: [makeComposite(session, messages)], context }) + expect(calls[0]!.model).toBe('unknown') + }) + + it('does not split multibyte characters when truncating the first user message', () => { + const message = `${'a'.repeat(499)}đŸ˜€truncated tail` + const messages: HermesMessageRow[] = [ + { id: 1, role: 'user', content: message, tool_calls: null, tool_name: null, timestamp: 1779549201 }, + ] + const { calls } = decodeHermes({ records: [makeComposite(BASE_SESSION, messages)], context }) + expect(calls[0]!.userMessage).toBe(`${'a'.repeat(499)}đŸ˜€`) + }) + + it('prefers sessions.cwd over transcript project inference', () => { + const messages: HermesMessageRow[] = [ + { id: 1, role: 'user', content: 'Current working directory: /tmp/decoy\nbuild it', tool_calls: null, tool_name: null, timestamp: 1779549201 }, + ] + const { calls } = decodeHermes({ records: [makeComposite(BASE_SESSION, messages)], context }) + expect(calls[0]!.project).toBe('Users-me-projects-codeburn') + expect(calls[0]!.projectPath).toBe('/Users/me/projects/codeburn') + }) + + it('infers project from transcript when sessions.cwd is absent', () => { + const session: HermesSessionRow = { ...BASE_SESSION, cwd: null } + const messages: HermesMessageRow[] = [ + { id: 1, role: 'user', content: 'Current working directory: /tmp/legacy-project\nbuild it', tool_calls: null, tool_name: null, timestamp: 1779549201 }, + ] + const { calls } = decodeHermes({ records: [makeComposite(session, messages, 'legacy-profile')], context }) + expect(calls[0]!.project).toBe('tmp-legacy-project') + expect(calls[0]!.projectPath).toBe('/tmp/legacy-project') + }) + + it('falls back to sanitized profile name when no project source exists', () => { + const session: HermesSessionRow = { ...BASE_SESSION, cwd: null } + const messages: HermesMessageRow[] = [ + { id: 1, role: 'user', content: 'Hello', tool_calls: null, tool_name: null, timestamp: 1779549201 }, + ] + const { calls } = decodeHermes({ records: [makeComposite(session, messages, 'my profile')], context }) + expect(calls[0]!.project).toBe('my profile') + expect(calls[0]!.projectPath).toBeUndefined() + }) + + it('chooses actual_cost_usd over estimated_cost_usd', () => { + const session: HermesSessionRow = { + ...BASE_SESSION, + estimated_cost_usd: 0.99, + actual_cost_usd: 0.123, + } + const messages: HermesMessageRow[] = [ + { id: 1, role: 'user', content: 'Hello', tool_calls: null, tool_name: null, timestamp: 1779549201 }, + ] + const { calls } = decodeHermes({ records: [makeComposite(session, messages)], context }) + expect(calls[0]!.recordedCost).toBe(0.123) + }) + + it('falls back to estimated_cost_usd when actual_cost_usd is zero/null', () => { + const session: HermesSessionRow = { + ...BASE_SESSION, + estimated_cost_usd: 0.456, + actual_cost_usd: 0, + } + const messages: HermesMessageRow[] = [ + { id: 1, role: 'user', content: 'Hello', tool_calls: null, tool_name: null, timestamp: 1779549201 }, + ] + const { calls } = decodeHermes({ records: [makeComposite(session, messages)], context }) + expect(calls[0]!.recordedCost).toBe(0.456) + }) + + it('omits recordedCost when no cost is recorded', () => { + const session: HermesSessionRow = { + ...BASE_SESSION, + estimated_cost_usd: 0, + actual_cost_usd: null, + } + const messages: HermesMessageRow[] = [ + { id: 1, role: 'user', content: 'Hello', tool_calls: null, tool_name: null, timestamp: 1779549201 }, + ] + const { calls } = decodeHermes({ records: [makeComposite(session, messages)], context }) + expect(calls[0]!.recordedCost).toBeUndefined() + }) + + it('toObservations produces a schema-valid, content-free envelope', () => { + const messages: HermesMessageRow[] = [ + { id: 1, role: 'user', content: 'Implement Hermes support', tool_calls: null, tool_name: null, timestamp: 1779549201 }, + { + id: 2, + role: 'assistant', + content: null, + tool_calls: JSON.stringify([ + { function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/hermes.ts' }) } }, + ]), + tool_name: null, + timestamp: 1779549202, + }, + ] + const { calls } = decodeHermes({ records: [makeComposite(BASE_SESSION, messages)], context }) + const { sessions } = toObservations( + { sessionId: 'sess-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'hermes' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + const reads = sessions.flatMap(s => s.calls.flatMap(c => c.resourceReads ?? [])) + expect(reads.length).toBeGreaterThan(0) + for (const ref of reads) expect(ref.resourceId).toMatch(/^[0-9a-f]{16}$/) + }) + + it('toObservations emits measured cost when recordedCost is present', () => { + const session: HermesSessionRow = { ...BASE_SESSION, actual_cost_usd: 1.23 } + const messages: HermesMessageRow[] = [ + { id: 1, role: 'user', content: 'Hello', tool_calls: null, tool_name: null, timestamp: 1779549201 }, + ] + const { calls } = decodeHermes({ records: [makeComposite(session, messages)], context }) + const { sessions } = toObservations( + { sessionId: 'sess-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'hermes' }, + ) + const call = sessions[0]!.calls[0]! + expect(call.costBasis).toBe('measured') + expect(call.measuredCostUSD).toBe(1.23) + }) +}) diff --git a/packages/core/tests/providers/quickdesk-decode.test.ts b/packages/core/tests/providers/quickdesk-decode.test.ts new file mode 100644 index 00000000..83075690 --- /dev/null +++ b/packages/core/tests/providers/quickdesk-decode.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from 'vitest' + +import { decodeQuickdesk, toObservations } from '../../src/providers/quickdesk/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import type { DecodeContext } from '../../src/contracts.js' +import type { + QuickdeskDatabaseInput, + QuickdeskMetricsInput, + QuickdeskSessionMetadata, +} from '../../src/providers/quickdesk/types.js' + +const context: DecodeContext = { privacyKey: 'k', providerId: 'quickdesk', sourceRef: 'ref' } + +function makeSession(overrides: Partial = {}): QuickdeskSessionMetadata { + return { + id: 'sess-a', + title: 'Test', + agentMode: 'agent', + createdAt: 1783987200, + deleted: false, + firstUserMessage: 'hello', + inputChars: 0, + outputChars: 0, + tools: [], + ...overrides, + } +} + +function makeMetricsInput(overrides: Partial = {}): QuickdeskMetricsInput { + return { + variant: 'metrics', + records: [], + sessions: new Map(), + project: 'default', + projectPath: '/Users/me/projects/codeburn', + fileId: 'metrics-2026-07-14.jsonl', + ...overrides, + } +} + +function makeDatabaseInput(overrides: Partial = {}): QuickdeskDatabaseInput { + return { + variant: 'database', + sessions: [], + meteredSessionIds: new Set(), + project: 'default', + projectPath: '/Users/me/projects/codeburn', + ...overrides, + } +} + +describe('quickdesk rich decode (moved to @codeburn/core)', () => { + it('decodes a metrics record into a cost-free rich call', () => { + const input = makeMetricsInput({ + records: [ + { record: { Model: 'claude-sonnet-4-5', InputTokens: 120, OutputTokens: 30, CostUSD: 0.0042 } }, + ], + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('quickdesk') + expect(call.model).toBe('claude-sonnet-4-5') + expect(call.inputTokens).toBe(120) + expect(call.outputTokens).toBe(30) + expect(call.recordedCost).toBe(0.0042) + expect(call.project).toBe('default') + expect(call.projectPath).toBe('/Users/me/projects/codeburn') + }) + + it('estimates cost when metrics record lacks CostUSD', () => { + const input = makeMetricsInput({ + records: [{ record: { Model: 'claude-sonnet-4-5', InputTokens: 40, OutputTokens: 10 } }], + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls).toHaveLength(1) + expect(calls[0]!).not.toHaveProperty('recordedCost') + }) + + it('links sqlite session metadata to metrics by session_id', () => { + const sessions = new Map() + sessions.set('linked', makeSession({ + id: 'linked', + firstUserMessage: 'linked prompt', + tools: ['read_file'], + })) + const input = makeMetricsInput({ + records: [{ record: { session_id: 'linked', Model: 'claude-sonnet-4-5', InputTokens: 10, OutputTokens: 5 } }], + sessions, + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.userMessage).toBe('linked prompt') + expect(calls[0]!.tools).toEqual(['Read']) + expect(calls[0]!.sessionId).toBe('linked') + }) + + it('merges tools from metrics ToolName and linked session metadata', () => { + const sessions = new Map() + sessions.set('merged', makeSession({ id: 'merged', tools: ['write_file'] })) + const input = makeMetricsInput({ + records: [ + { record: { session_id: 'merged', ToolName: 'read_file' } }, + { record: { session_id: 'merged', Model: 'claude-sonnet-4-5', InputTokens: 10, OutputTokens: 5 } }, + ], + sessions, + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Read', 'Edit']) + }) + + it('dedups metrics calls via the live seenKeys set', () => { + const input = makeMetricsInput({ + records: [{ record: { Model: 'claude-sonnet-4-5', InputTokens: 10, OutputTokens: 5 } }], + }) + const seen = new Set() + const first = decodeQuickdesk({ records: [input], context, seenKeys: seen }).calls + expect(first).toHaveLength(1) + const again = decodeQuickdesk({ records: [input], context, seenKeys: seen }).calls + expect(again).toEqual([]) + }) + + it('skips deleted linked sessions', () => { + const sessions = new Map() + sessions.set('deleted', makeSession({ id: 'deleted', deleted: true })) + const input = makeMetricsInput({ + records: [{ record: { session_id: 'deleted', Model: 'claude-sonnet-4-5', InputTokens: 10, OutputTokens: 5 } }], + sessions, + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls).toHaveLength(0) + }) + + it('keeps metrics records with zero tokens because usageRecord does not filter them', () => { + const input = makeMetricsInput({ + records: [{ record: { Model: 'claude-sonnet-4-5', InputTokens: 0, OutputTokens: 0 } }], + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(0) + expect(calls[0]!.outputTokens).toBe(0) + }) + + it('skips metrics records missing required fields', () => { + const input = makeMetricsInput({ + records: [ + { record: { Model: 'claude-sonnet-4-5', InputTokens: 10 } }, + { record: { InputTokens: 10, OutputTokens: 5 } }, + ], + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls).toHaveLength(0) + }) + + it('decodes database sessions into estimated rich calls', () => { + const input = makeDatabaseInput({ + sessions: [makeSession({ id: 'db-sess', inputChars: 12, outputChars: 8, firstUserMessage: 'db prompt', tools: ['Bash'] })], + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('quickdesk-auto') + expect(calls[0]!.inputTokens).toBe(3) + expect(calls[0]!.outputTokens).toBe(2) + expect(calls[0]!.tools).toEqual(['Bash']) + expect(calls[0]!.userMessage).toBe('db prompt') + expect(calls[0]!).not.toHaveProperty('recordedCost') + }) + + it('skips database sessions with zero estimated tokens', () => { + const input = makeDatabaseInput({ + sessions: [makeSession({ id: 'empty', inputChars: 0, outputChars: 0 })], + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls).toHaveLength(0) + }) + + it('skips metered database sessions', () => { + const input = makeDatabaseInput({ + sessions: [makeSession({ id: 'metered', inputChars: 12, outputChars: 8 })], + meteredSessionIds: new Set(['metered']), + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls).toHaveLength(0) + }) + + it('skips deleted database sessions', () => { + const input = makeDatabaseInput({ + sessions: [makeSession({ id: 'deleted', inputChars: 12, outputChars: 8, deleted: true })], + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls).toHaveLength(0) + }) + + it('treats millisecond created_at values as milliseconds', () => { + const input = makeDatabaseInput({ + sessions: [makeSession({ id: 'ms', createdAt: 1783987200000, inputChars: 4, outputChars: 4 })], + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.timestamp).toBe('2026-07-14T00:00:00.000Z') + }) + + it('dedups database calls via the live seenKeys set', () => { + const input = makeDatabaseInput({ + sessions: [makeSession({ id: 'db-sess', inputChars: 12, outputChars: 8 })], + }) + const seen = new Set() + const first = decodeQuickdesk({ records: [input], context, seenKeys: seen }).calls + expect(first).toHaveLength(1) + const again = decodeQuickdesk({ records: [input], context, seenKeys: seen }).calls + expect(again).toEqual([]) + }) + + it('maps tool names through the quickdesk tool-name map', () => { + const input = makeMetricsInput({ + records: [ + { record: { session_id: 'tools', ToolName: 'readFile' } }, + { record: { session_id: 'tools', ToolName: 'runCommand' } }, + { record: { session_id: 'tools', ToolName: 'unknownTool' } }, + { record: { session_id: 'tools', Model: 'claude-sonnet-4-5', InputTokens: 10, OutputTokens: 5 } }, + ], + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls[0]!.tools).toEqual(['Read', 'Bash', 'unknownTool']) + }) + + it('falls back to file-date timestamp when _aws.Timestamp is absent', () => { + const input = makeMetricsInput({ + records: [{ record: { Model: 'claude-sonnet-4-5', InputTokens: 10, OutputTokens: 5 } }], + fileId: 'metrics-2026-05-01.jsonl', + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + expect(calls[0]!.timestamp).toBe('2026-05-01T00:00:00.000Z') + }) + + it('toObservations produces a schema-valid, content-free envelope', () => { + const input = makeMetricsInput({ + records: [{ record: { Model: 'claude-sonnet-4-5', InputTokens: 10, OutputTokens: 5 } }], + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + const { sessions } = toObservations( + { sessionId: 'sess-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'quickdesk' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + }) + + it('toObservations emits measured cost when recordedCost is present', () => { + const input = makeMetricsInput({ + records: [{ record: { Model: 'claude-sonnet-4-5', InputTokens: 10, OutputTokens: 5, CostUSD: 0.001 } }], + }) + const { calls } = decodeQuickdesk({ records: [input], context }) + const { sessions } = toObservations( + { sessionId: 'sess-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'quickdesk' }, + ) + const call = sessions[0]!.calls[0]! + expect(call.costBasis).toBe('measured') + expect(call.measuredCostUSD).toBe(0.001) + }) +}) diff --git a/packages/core/tests/providers/warp-decode.test.ts b/packages/core/tests/providers/warp-decode.test.ts new file mode 100644 index 00000000..1dc7f9f7 --- /dev/null +++ b/packages/core/tests/providers/warp-decode.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' + +import { decodeWarp, toObservations } from '../../src/providers/warp/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import type { DecodeContext } from '../../src/contracts.js' +import type { WarpBlockRow, WarpConversationRow, WarpQueryRow } from '../../src/providers/warp/types.js' + +const context: DecodeContext = { privacyKey: 'k', providerId: 'warp', sourceRef: 'ref' } + +function makeComposite( + conversationId: string, + conversation: WarpConversationRow, + exchanges: WarpQueryRow[], + blocks: WarpBlockRow[] = [], + sourceProject = 'warp', +) { + return { conversationId, conversation, exchanges, blocks, sourceProject } +} + +const BASE_CONVERSATION: WarpConversationRow = { + conversation_id: 'conv-a', + conversation_data: JSON.stringify({ + conversation_usage_metadata: { + token_usage: [ + { + model_id: 'GPT-5.3 Codex (medium reasoning)', + warp_tokens: 300, + byok_tokens: 0, + warp_token_usage_by_category: { primary_agent: 300 }, + byok_token_usage_by_category: {}, + }, + ], + }, + }), + last_modified_at: '2026-05-18 10:10:00', +} + +function makeExchange(id: string, overrides: Partial = {}): WarpQueryRow { + return { + exchange_id: id, + conversation_id: 'conv-a', + start_ts: '2026-05-18 10:00:00.000000', + input: JSON.stringify([{ Query: { text: 'hello warp' } }]), + working_directory: '/Users/me/projects/codeburn', + output_status: '"Completed"', + model_id: 'auto-efficient', + planning_model_id: '', + coding_model_id: '', + ...overrides, + } +} + +describe('warp rich decode (moved to @codeburn/core)', () => { + it('decodes a conversation + exchanges into cost-free rich calls', () => { + const exchanges: WarpQueryRow[] = [ + makeExchange('ex-1'), + makeExchange('ex-2', { input: JSON.stringify([{ Query: { text: 'a much longer prompt for weighting purposes' } }]) }), + ] + const { calls } = decodeWarp({ records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges)], context }) + expect(calls).toHaveLength(2) + expect(calls[0]!.provider).toBe('warp') + expect(calls[0]!.sessionId).toBe('conv-a') + expect(calls[0]!.model).toBe('gpt-5.3-codex') + expect(calls[0]!.inputTokens + calls[1]!.inputTokens).toBe(300) + expect(calls[0]!.userMessage).toBe('hello warp') + expect(calls[0]!.deduplicationKey).toBe('warp:conv-a:ex-1') + expect(calls[0]!.project).toBe('Users-me-projects-codeburn') + expect(calls[0]!.projectPath).toBe('/Users/me/projects/codeburn') + expect(calls[0]!).not.toHaveProperty('costBasis') + expect(calls[0]!).not.toHaveProperty('costUSD') + }) + + it('threads a live seenKeys set so a repeated exchange drops', () => { + const exchanges: WarpQueryRow[] = [makeExchange('ex-1')] + const seen = new Set() + const first = decodeWarp({ + records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges)], + context, + seenKeys: seen, + }).calls + expect(first).toHaveLength(1) + const again = decodeWarp({ + records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges)], + context, + seenKeys: seen, + }).calls + expect(again).toEqual([]) + }) + + it('skips non-final exchanges', () => { + const exchanges: WarpQueryRow[] = [ + makeExchange('ex-final'), + makeExchange('ex-pending', { output_status: '"Pending"' }), + ] + const { calls } = decodeWarp({ records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges)], context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.deduplicationKey).toBe('warp:conv-a:ex-final') + }) + + it('skips exchanges with invalid timestamps and does not poison seenKeys', () => { + const exchanges: WarpQueryRow[] = [ + makeExchange('ex-bad-ts', { start_ts: 'not-a-timestamp' }), + makeExchange('ex-ok'), + ] + const seen = new Set() + const { calls } = decodeWarp({ + records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges)], + context, + seenKeys: seen, + }) + expect(calls).toHaveLength(1) + expect(calls[0]!.deduplicationKey).toBe('warp:conv-a:ex-ok') + expect(seen.has('warp:conv-a:ex-bad-ts')).toBe(false) + }) + + it('allocates tokens proportionally by estimated weight', () => { + const exchanges: WarpQueryRow[] = [ + makeExchange('ex-short', { input: JSON.stringify([{ Query: { text: 'hi' } }]) }), + makeExchange('ex-long', { input: JSON.stringify([{ Query: { text: 'this is a substantially longer user prompt for weighting' } }]) }), + ] + const { calls } = decodeWarp({ records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges)], context }) + expect(calls).toHaveLength(2) + expect(calls[0]!.inputTokens + calls[1]!.inputTokens).toBe(300) + expect(calls[1]!.inputTokens).toBeGreaterThan(calls[0]!.inputTokens) + }) + + it('attributes command blocks to the nearest preceding exchange and emits raw commands', () => { + const exchanges: WarpQueryRow[] = [ + makeExchange('ex-a', { start_ts: '2026-05-18 11:00:00.000000' }), + makeExchange('ex-b', { start_ts: '2026-05-18 11:05:00.000000' }), + ] + const blocks: WarpBlockRow[] = [ + { block_id: 'block-1', start_ts: '2026-05-18 11:01:00.000000', stylized_command: 'npm test && git status' }, + ] + const { calls } = decodeWarp({ records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges, blocks)], context }) + const callA = calls.find(c => c.deduplicationKey === 'warp:conv-a:ex-a') + const callB = calls.find(c => c.deduplicationKey === 'warp:conv-a:ex-b') + expect(callA).toBeDefined() + expect(callA!.tools).toEqual(['Bash']) + expect(callA!.rawBashCommands).toEqual(['npm test && git status']) + expect(callB!.tools).toEqual([]) + expect(callB!.rawBashCommands).toEqual([]) + }) + + it('maps run_command blocks to the canonical Bash tool name', () => { + const exchanges: WarpQueryRow[] = [makeExchange('ex-a')] + const blocks: WarpBlockRow[] = [ + { block_id: 'block-1', start_ts: '2026-05-18 10:00:01.000000', stylized_command: 'ls -la' }, + ] + const { calls } = decodeWarp({ records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges, blocks)], context }) + expect(calls[0]!.tools).toEqual(['Bash']) + }) + + it('falls back to warp-auto-efficient when no model is available', () => { + const conversation: WarpConversationRow = { + ...BASE_CONVERSATION, + conversation_data: JSON.stringify({ conversation_usage_metadata: { token_usage: [] } }), + } + const exchanges: WarpQueryRow[] = [ + makeExchange('ex-1', { model_id: '', input: JSON.stringify([{ Query: { text: 'hello' } }]) }), + ] + const { calls } = decodeWarp({ records: [makeComposite('conv-a', conversation, exchanges)], context }) + expect(calls[0]!.model).toBe('warp-auto-efficient') + }) + + it('resolves auto-efficient to the dominant model when present', () => { + const exchanges: WarpQueryRow[] = [makeExchange('ex-1', { model_id: 'auto-efficient' })] + const { calls } = decodeWarp({ records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges)], context }) + expect(calls[0]!.model).toBe('gpt-5.3-codex') + }) + + it('uses the fallback token budget when conversation usage is absent', () => { + const conversation: WarpConversationRow = { + ...BASE_CONVERSATION, + conversation_data: JSON.stringify({ conversation_usage_metadata: { token_usage: [] } }), + } + const exchanges: WarpQueryRow[] = [makeExchange('ex-1')] + const { calls } = decodeWarp({ records: [makeComposite('conv-a', conversation, exchanges)], context }) + expect(calls[0]!.inputTokens).toBeGreaterThan(0) + }) + + it('toObservations produces a schema-valid, content-free envelope', () => { + const exchanges: WarpQueryRow[] = [makeExchange('ex-1')] + const blocks: WarpBlockRow[] = [ + { block_id: 'block-1', start_ts: '2026-05-18 10:00:01.000000', stylized_command: 'npm test' }, + ] + const { calls } = decodeWarp({ records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges, blocks)], context }) + const { sessions } = toObservations( + { sessionId: 'conv-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'warp' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.toolNames).toEqual(['Bash']) + }) +}) diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index d84bb475..f8d3796b 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -32,6 +32,11 @@ export default defineConfig({ 'src/providers/zed/index.ts', 'src/providers/forge/index.ts', 'src/providers/goose/index.ts', + 'src/providers/hermes/index.ts', + 'src/providers/warp/index.ts', + 'src/providers/cursor-agent/index.ts', + 'src/providers/quickdesk/index.ts', + 'src/providers/devin/index.ts', ], format: ['esm'], target: 'node20',