refactor(core): tail migrations — hermes, warp, cursor-agent, quickdesk, devin (phase 8, sqlite batch 2)

Move the pure record decode for five sqlite-backed providers into
@codeburn/core, keeping discovery, sqlite I/O, pricing, and bash
base-name extraction CLI-side behind the dual-registry bridge.

sqlite variant: the host opens the database, runs the SQL, textualizes
BLOBs (warp stylized_command, quickdesk content/tool_names), probes
sqlite_master table variants (quickdesk), and re-throws SQLITE_BUSY so a
transient lock on a live database retries instead of being cached as an
empty result (hermes). The core decoders receive already-normalized rows.

Validator fixes over the drafted migration (original behavior is the
authority in every case):

- warp: restored the `run_command` tool-name check as an identity
  comparison. The draft had rewritten it as a bare Record lookup, so
  'constructor'/'toString'/'__proto__' resolved to inherited Object
  members instead of passing through. Added a regression test.
- warp: restored `safeNumber` to the host's semantics (positive finite
  numbers only). The draft's rewrite accepted numeric strings and
  negative values, changing token budgets and dominant-model selection
  for untrusted conversation_data.
- warp: `estimateTokensFromChars` now mirrors the host helper exactly.
- devin: model DISPLAY names move back CLI-side. The draft shipped a
  7-line regex standing in for the host's `getShortModelName`, which
  mis-rendered every non-Claude model id. The decoder now emits the raw
  generation_model / model_name ids and the CLI formats them.
- devin: dropped the added `costBasis: 'measured'`; the pre-migration
  call carried `costUSD` with no basis marker. Golden corrected.
- quickdesk: fixed an inverted source-variant predicate that routed
  `sourceId: 'metrics'` on a sessions.db path (and `sourceId:
  'sessions-db'` on a metrics path) to the wrong parser.
- cursor-agent: the "unrecognized transcript format" warning is now
  re-emitted from the real decode result via a decode wrapper. The draft
  re-derived `recognized` host-side as `hasUser && hasAssistant`, which
  silently dropped the warning whenever a jsonl transcript had both roles
  but produced no turns.
- cursor-agent: restored the per-conversation summary cache, so the
  attribution database is opened once per conversation id, not per source.
- cursor-agent/devin: the session id is derived once, host-side, and
  passed through, removing a second derivation that could disagree with
  the host's on Windows-style paths.
- hermes: `browser_vision` maps to Vision, matching the original table.

Goldens re-verified by running each bridge fixture through the
pre-migration provider implementation: all five reproduce field-for-field.
This commit is contained in:
iamtoruk 2026-07-26 20:26:18 -07:00
parent 6c64a1bbde
commit 4ef24e9fcd
39 changed files with 6084 additions and 1610 deletions

View file

@ -4,52 +4,22 @@ import { readdir, readFile, stat } from 'fs/promises'
import { join, basename } from 'path'
import { homedir } from 'os'
import { decodeCursorAgent } from '@codeburn/core/providers/cursor-agent'
import type { ConversationSummaryRow, CursorAgentDecodedCall } from '@codeburn/core/providers/cursor-agent'
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'
import { createBridgedProvider } from './bridge.js'
import type { Provider, SessionSource, ParsedProviderCall } from './types.js'
type ConversationSummary = {
conversationId: string
model: string | null
title: string | null
updatedAt: string | null
}
type AssistantTurn = {
body: string
reasoning: string
tools: string[]
}
type ParsedTurn = {
userMessage: string
assistant: AssistantTurn
}
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 = '<user_query>'
const USER_QUERY_CLOSE = '</user_query>'
const warnedUnrecognizedTranscripts = new Set<string>()
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<string, string> = {
'claude-4.5-opus-high-thinking': 'Opus 4.5 (Thinking)',
'claude-4-opus': 'Opus 4',
@ -65,6 +35,8 @@ const modelDisplayNames: Record<string, string> = {
default: 'Auto (Sonnet est.)',
}
const warnedUnrecognizedTranscripts = new Set<string>()
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<string, true>()
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<string>,
dbPath: string,
summariesByConversationId: Map<string, ConversationSummary>,
): 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<ParsedProviderCall> {
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<string, ConversationSummary>()
const summariesByConversationId = new Map<string, ConversationSummaryRow>()
return {
return createBridgedProvider<CursorAgentDecodedCall>({
name: 'cursor-agent',
displayName: 'Cursor Agent',
@ -527,10 +216,76 @@ export function createCursorAgentProvider(baseDirOverride?: string): Provider {
return sources
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): 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<unknown[] | null> {
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()

View file

@ -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<StepType extends Step = Step, AgentExtra = unknown> = {
schema_version: string;
session_id?: string;
agent: Agent<AgentExtra>;
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<Extra = unknown> = {
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<StepExtra = unknown, MetricsExtra = unknown> = {
step_id: number;
timestamp?: string;
source: string;
model_name?: string;
message: string | Array<ContentPart>;
tool_calls?: Array<ToolCall>;
extra?: StepExtra;
observation?: Observation;
metrics?: Metrics<MetricsExtra>;
};
type DevinTelemetry = {
source?: string;
operation?: string;
};
type DevinStepExtra = {
committed_acu_cost?: number;
generation_model?: string;
telemetry?: DevinTelemetry;
};
type Observation = {
results: Array<ObservationResult>;
};
type ObservationResult = {
source_call_id?: string;
content?: string | Array<ContentPart>;
};
type Metrics<Extra = unknown> = {
prompt_tokens?: number;
completion_tokens?: number;
cached_tokens?: number;
extra?: Extra;
};
type DevinMetricsExtra = {
cache_creation_input_tokens?: number;
};
type DevinStep = Step<DevinStepExtra, DevinMetricsExtra> & {
metadata?: DevinMetadata;
};
type DevinAgentTrajectory = AgentTrajectory<DevinStep, DevinAgentExtra>;
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<DevinMetricsExtra> | 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<DevinMetricsExtra> | 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<DevinMetricsExtra> {
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>): 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,
function parseNumericTimestamp(value: number): string {
const millis = value < 10_000_000_000 ? value * 1000 : value;
return new Date(millis).toISOString();
}
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 {
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 normalizeStepMessage(message: string | Array<ContentPart>): 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;
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<number | null> {
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<string>,
private sessionMetadata: Map<string, DevinSessionMetadata>,
) {}
async *parse(): AsyncGenerator<ParsedProviderCall> {
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<string, DevinSessionMetadata> | null = null;
let costFactor: number | null | undefined;
const getSessionMetadata = () => {
if (!sessionMetadata) sessionMetadata = loadSessionMetadata(sessionsDbPath);
return sessionMetadata;
};
return {
const getCachedCostFactor = async (): Promise<number | null> => {
if (costFactor === undefined) costFactor = await getCostFactor();
return costFactor;
};
return createBridgedProvider<DevinDecodedCall>({
name: DEVIN_PROVIDER_NAME,
displayName: DEVIN_PROVIDER_DISPLAY_NAME,
@ -546,7 +193,7 @@ export function createDevinProvider(cliDir: string): Provider {
},
async discoverSessions(): Promise<SessionSource[]> {
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<string>,
): SessionParser {
return new DevinSessionParser(source, seenKeys, getSessionMetadata());
async readRecords(source: SessionSource): Promise<unknown[] | null> {
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);

View file

@ -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<string, string> = {
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<string> {
return new Set(db.query<TableInfoRow>('PRAGMA table_info(sessions)').map(row => row.name))
}
function getMessageColumns(db: SqliteDatabase): Set<string> {
return new Set(db.query<TableInfoRow>('PRAGMA table_info(messages)').map(row => row.name))
}
function numberColumn(columns: Set<string>, name: TableColumn): string {
return columns.has(name) ? `coalesce(${name}, 0) AS ${name}` : `0 AS ${name}`
}
@ -154,10 +96,6 @@ function nullableColumn(columns: Set<string>, name: TableColumn): string {
return columns.has(name) ? name : `NULL AS ${name}`
}
function getMessageColumns(db: SqliteDatabase): Set<string> {
return new Set(db.query<TableInfoRow>('PRAGMA table_info(messages)').map(row => row.name))
}
function usageExpression(columns: Set<string>): string {
const usageColumns: Array<keyof HermesSessionRow> = [
'input_tokens',
@ -172,96 +110,6 @@ function usageExpression(columns: Set<string>): 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<string, unknown>
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<SessionSource[]> {
let db: SqliteDatabase
try {
@ -303,16 +151,76 @@ async function discoverFromDb(dbPath: string, profile: string): Promise<SessionS
}
}
function createParser(source: SessionSource, seenKeys: Set<string>, 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<ParsedProviderCall> {
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<HermesDecodedCall>({
name: 'hermes',
displayName: 'Hermes Agent',
modelDisplayName(model: string): string {
return getShortModelName(model)
},
toolDisplayName(rawTool: string): string {
return mapToolName(rawTool)
},
async discoverSessions(): Promise<SessionSource[]> {
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<unknown[] | null> {
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<string>, 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<HermesSessionRow>(
`SELECT id,
@ -350,7 +257,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, 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<string>, 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<SessionSource[]> {
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<string>): SessionParser {
return createParser(source, seenKeys, hermesHome)
},
}
decode: decodeHermes,
toProviderCall,
})
}
export const hermes = createHermesProvider()

View file

@ -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<string, string> = {
'claude-sonnet-4-6': 'Sonnet 4.6',
}
const toolNameMap: Record<string, string> = {
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<string, unknown>
}
type SessionMetadata = {
id: string
title: string
agentMode: string
createdAt?: number
deleted: boolean
firstUserMessage: string
inputChars: number
outputChars: number
tools: string[]
}
type DatabaseSnapshot = {
sessions: Map<string, SessionMetadata>
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<string, QuickdeskSessionMetadata>; canEstimate: boolean } {
const empty: { sessions: Map<string, QuickdeskSessionMetadata>; 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<string, SessionMetadata>()
const sessions = new Map<string, QuickdeskSessionMetadata>()
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<MetricsRecord[]> {
async function readMetricsRecords(path: string): Promise<Array<{ record: Record<string, unknown> }>> {
let contents: string
try {
contents = await readFile(path, 'utf8')
@ -327,7 +293,7 @@ async function readMetricsRecords(path: string): Promise<MetricsRecord[]> {
return []
}
const records: MetricsRecord[] = []
const records: Array<{ record: Record<string, unknown> }> = []
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<MetricsRecord[]> {
return records
}
function usageRecord(record: Record<string, unknown>): boolean {
return Boolean(stringValue(record['Model']))
&& nonNegativeNumber(record['InputTokens']) !== undefined
&& nonNegativeNumber(record['OutputTokens']) !== undefined
}
function sessionId(record: Record<string, unknown>): string {
return stringValue(record['session_id'])
}
function toolsKey(record: Record<string, unknown>): string {
return sessionId(record)
}
function collectMetricTools(records: MetricsRecord[]): Map<string, string[]> {
const tools = new Map<string, string[]>()
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<string, unknown>, 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<Set<string>> {
const ids = new Set<string>()
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<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
function isDatabaseSource(source: SessionSource): boolean {
return source.sourceId === 'sessions-db' || basename(source.path) === 'sessions.db'
}
export function createQuickdeskProvider(): Provider {
return createBridgedProvider<QuickdeskDecodedCall>({
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<ProbeRoot[]> {
return (await resolveProfileBases()).map(base => ({ path: base.path, label: base.profile }))
},
async discoverSessions(): Promise<SessionSource[]> {
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<unknown[] | null> {
const basePath = basePathFor(source)
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]
}
const records = await readMetricsRecords(source.path)
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 input: QuickdeskMetricsInput = {
variant: 'metrics',
records,
sessions: snapshot.sessions,
project,
projectPath: basePath,
fileId: basename(source.path),
}
return [input]
},
}
decode: decodeQuickdesk,
toProviderCall,
})
}
function createDatabaseParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const basePath = basePathFor(source)
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,
}
}
},
}
}
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<ProbeRoot[]> {
return (await resolveProfileBases()).map(base => ({ path: base.path, label: base.profile }))
},
async discoverSessions(): Promise<SessionSource[]> {
return discoverSources()
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return source.sourceId === 'sessions-db' || basename(source.path) === 'sessions.db'
? createDatabaseParser(source, seenKeys)
: createMetricsParser(source, seenKeys)
},
}
export const quickdesk = createQuickdeskProvider()

View file

@ -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'
import { createBridgedProvider } from './bridge.js'
import type { ParsedProviderCall, Provider, SessionSource } from './types.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<string, string> = {
'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',
}
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<string, unknown>
byok_token_usage_by_category?: Record<string, unknown>
}
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<string, unknown> | 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<string, ExchangeToolInfo> {
const toolsByExchange = new Map<string, ExchangeToolInfo>()
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<string>): 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<ParsedProviderCall> {
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<WarpConversationRow>(
`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<WarpQueryRow>(
`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<WarpBlockRow>(
`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<SessionSource[]> {
}
export function createWarpProvider(dbPathOverride?: string): Provider {
return {
return createBridgedProvider<WarpDecodedCall>({
name: 'warp',
displayName: 'Warp',
@ -490,10 +167,71 @@ export function createWarpProvider(dbPathOverride?: string): Provider {
return sessions
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): 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<unknown[] | null> {
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<WarpConversationRow>(
`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<WarpQueryRow>(
`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<RawWarpBlockRow>(
`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()

View file

@ -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<string> {
const dir = await mkdtemp(join(tmpdir(), 'cursor-agent-bridge-test-'))
tempRoots.push(dir)
return dir
}
async function collect(provider: Provider, source: SessionSource): Promise<ParsedProviderCall[]> {
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<void> {
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<user_query>${userText}</user_query>\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:\n<user_query>hello</user_query>\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<string>()
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([])
})
})

View file

@ -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<void> {
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<string> {
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<ParsedProviderCall[]> {
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<string>()
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([])
})
})

View file

@ -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<ParsedProviderCall[]> {
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<string>()
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([])
})
})

View file

@ -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<Record<string, unknown> | string>): Promise<string> {
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<string> {
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<ParsedProviderCall[]> {
const sources: SessionSource[] = await quickdesk.discoverSessions()
sources.sort((a, b) => a.path.localeCompare(b.path))
const seen = new Set<string>()
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<void> {
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<string>()
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([])
})
})

View file

@ -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<string> {
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<ParsedProviderCall[]> {
const provider = createWarpProvider(dbPath)
const sources: SessionSource[] = await provider.discoverSessions()
sources.sort((a, b) => a.path.localeCompare(b.path))
const seen = new Set<string>()
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<string>()
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([])
})
})

View file

@ -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": [

View file

@ -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 = '<user_query>'
const USER_QUERY_CLOSE = '</user_query>'
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<T extends { type?: string; text?: string }>(
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<string, true>()
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<string>
}
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:<conversationId>:<turnIndex>` 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<string>()
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 }
}

View file

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

View file

@ -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: [] }
}

View file

@ -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
}

View file

@ -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<DevinMetricsExtra> | 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<DevinMetricsExtra> | 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<DevinMetricsExtra> {
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>): 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<ContentPart>): 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<string>
}
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:<sessionId>:<step_id>`
* against the live `seenKeys` set (host-owned).
*/
export function decodeDevin({ records, seenKeys: liveSeen }: DevinDecodeInput): DevinDecodeResult {
const seen = liveSeen ?? new Set<string>()
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 }
}

View file

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

View file

@ -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: [] }
}

View file

@ -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<StepType extends Step = Step, AgentExtra = unknown> = {
schema_version: string;
session_id?: string;
agent: Agent<AgentExtra>;
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<Extra = unknown> = {
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<StepExtra = unknown, MetricsExtra = unknown> = {
step_id: number;
timestamp?: string;
source?: string;
model_name?: string;
message: string | Array<ContentPart>;
tool_calls?: Array<ToolCall>;
extra?: StepExtra;
observation?: Observation;
metrics?: Metrics<MetricsExtra>;
};
export type DevinTelemetry = {
source?: string;
operation?: string;
};
export type DevinStepExtra = {
committed_acu_cost?: number;
generation_model?: string;
telemetry?: DevinTelemetry;
};
export type Observation = {
results: Array<ObservationResult>;
};
export type ObservationResult = {
source_call_id?: string;
content?: string | Array<ContentPart>;
};
export type Metrics<Extra = unknown> = {
prompt_tokens?: number;
completion_tokens?: number;
cached_tokens?: number;
extra?: Extra;
};
export type DevinMetricsExtra = {
cache_creation_input_tokens?: number;
};
export type DevinStep = Step<DevinStepExtra, DevinMetricsExtra> & {
metadata?: DevinMetadata;
};
export type DevinAgentTrajectory = AgentTrajectory<DevinStep, DevinAgentExtra>;
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;
};

View file

@ -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<string, string> = {
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<string, unknown>
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<string>
}
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:<profile>:<sessionId>`
* against the live `seenKeys` set (host-owned).
*/
export function decodeHermes({ records, seenKeys: liveSeen }: HermesDecodeInput): HermesDecodeResult {
const seen = liveSeen ?? new Set<string>()
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 }
}

View file

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

View file

@ -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: [] }
}

View file

@ -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
}

View file

@ -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<string, string> = {
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<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: 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<string, unknown>): boolean {
return Boolean(stringValue(record['Model']))
&& nonNegativeNumber(record['InputTokens']) !== undefined
&& nonNegativeNumber(record['OutputTokens']) !== undefined
}
function sessionId(record: Record<string, unknown>): string {
return stringValue(record['session_id'])
}
function toolsKey(record: Record<string, unknown>): string {
return sessionId(record)
}
function collectMetricTools(records: QuickdeskMetricsRecord[]): Map<string, string[]> {
const tools = new Map<string, string[]>()
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<string, unknown>, 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<string>
}
export type QuickdeskDecodeResult = {
calls: QuickdeskDecodedCall[]
diagnostics: RecordDiagnostic[]
}
function decodeMetrics(input: QuickdeskMetricsInput, seen: Set<string>): 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<string>): 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:<sessionId>:...`
* against the live `seenKeys` set (host-owned).
*/
export function decodeQuickdesk({ records, seenKeys: liveSeen }: QuickdeskDecodeInput): QuickdeskDecodeResult {
const seen = liveSeen ?? new Set<string>()
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 }
}

View file

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

View file

@ -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: [] }
}

View file

@ -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<string, unknown>
}
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<string, QuickdeskSessionMetadata>
project: string
projectPath: string
fileId: string
}
export type QuickdeskDatabaseInput = {
variant: 'database'
sessions: QuickdeskSessionMetadata[]
meteredSessionIds: Set<string>
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
}

View file

@ -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<string, string> = {
'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<string, unknown> | 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<string, WarpExchangeToolInfo> {
const toolsByExchange = new Map<string, WarpExchangeToolInfo>()
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<string>
}
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:<conversationId>:<exchangeId>` against the live `seenKeys` set
* (host-owned).
*/
export function decodeWarp({ records, seenKeys: liveSeen }: WarpDecodeInput): WarpDecodeResult {
const seen = liveSeen ?? new Set<string>()
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 }
}

View file

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

View file

@ -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: [] }
}

View file

@ -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<string, unknown>
byok_token_usage_by_category?: Record<string, unknown>
}
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
}

View file

@ -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', () => {

View file

@ -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:',
`<user_query>${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}</user_query>`,
'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)
})
})

View file

@ -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:\n<user_query>explain parser output</user_query>\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:\n<user_query>hello</user_query>\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:\n<user_query>hello</user_query>\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:\n<user_query>run tools</user_query>\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: '<user_query>run tools</user_query>' }] } }) + '\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:\n<user_query>think</user_query>\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<string>()
const record = makeRecord({ transcript: 'user:\n<user_query>hello</user_query>\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:\n<user_query>hello</user_query>\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:\n<user_query>hello</user_query>\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')
})
})

View file

@ -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<string>()
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')
})
})

View file

@ -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<string>()
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)
})
})

View file

@ -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> = {}): 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> = {}): 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> = {}): 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<string, QuickdeskSessionMetadata>()
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<string, QuickdeskSessionMetadata>()
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<string>()
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<string, QuickdeskSessionMetadata>()
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<string>()
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)
})
})

View file

@ -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> = {}): 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<string>()
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<string>()
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'])
})
})

View file

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