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',