mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-03 21:35:13 +00:00
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:
parent
6c64a1bbde
commit
4ef24e9fcd
39 changed files with 6084 additions and 1610 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
167
packages/cli/tests/providers/cursor-agent-bridge.test.ts
Normal file
167
packages/cli/tests/providers/cursor-agent-bridge.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
261
packages/cli/tests/providers/devin-bridge.test.ts
Normal file
261
packages/cli/tests/providers/devin-bridge.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
272
packages/cli/tests/providers/hermes-bridge.test.ts
Normal file
272
packages/cli/tests/providers/hermes-bridge.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
253
packages/cli/tests/providers/quickdesk-bridge.test.ts
Normal file
253
packages/cli/tests/providers/quickdesk-bridge.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
404
packages/cli/tests/providers/warp-bridge.test.ts
Normal file
404
packages/cli/tests/providers/warp-bridge.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
320
packages/core/src/providers/cursor-agent/decode.ts
Normal file
320
packages/core/src/providers/cursor-agent/decode.ts
Normal 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 }
|
||||
}
|
||||
24
packages/core/src/providers/cursor-agent/index.ts
Normal file
24
packages/core/src/providers/cursor-agent/index.ts
Normal 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'
|
||||
94
packages/core/src/providers/cursor-agent/observations.ts
Normal file
94
packages/core/src/providers/cursor-agent/observations.ts
Normal 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: [] }
|
||||
}
|
||||
64
packages/core/src/providers/cursor-agent/types.ts
Normal file
64
packages/core/src/providers/cursor-agent/types.ts
Normal 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
|
||||
}
|
||||
295
packages/core/src/providers/devin/decode.ts
Normal file
295
packages/core/src/providers/devin/decode.ts
Normal 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 }
|
||||
}
|
||||
21
packages/core/src/providers/devin/index.ts
Normal file
21
packages/core/src/providers/devin/index.ts
Normal 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'
|
||||
92
packages/core/src/providers/devin/observations.ts
Normal file
92
packages/core/src/providers/devin/observations.ts
Normal 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: [] }
|
||||
}
|
||||
187
packages/core/src/providers/devin/types.ts
Normal file
187
packages/core/src/providers/devin/types.ts
Normal 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;
|
||||
};
|
||||
236
packages/core/src/providers/hermes/decode.ts
Normal file
236
packages/core/src/providers/hermes/decode.ts
Normal 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 }
|
||||
}
|
||||
21
packages/core/src/providers/hermes/index.ts
Normal file
21
packages/core/src/providers/hermes/index.ts
Normal 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'
|
||||
93
packages/core/src/providers/hermes/observations.ts
Normal file
93
packages/core/src/providers/hermes/observations.ts
Normal 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: [] }
|
||||
}
|
||||
90
packages/core/src/providers/hermes/types.ts
Normal file
90
packages/core/src/providers/hermes/types.ts
Normal 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
|
||||
}
|
||||
245
packages/core/src/providers/quickdesk/decode.ts
Normal file
245
packages/core/src/providers/quickdesk/decode.ts
Normal 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 }
|
||||
}
|
||||
20
packages/core/src/providers/quickdesk/index.ts
Normal file
20
packages/core/src/providers/quickdesk/index.ts
Normal 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'
|
||||
90
packages/core/src/providers/quickdesk/observations.ts
Normal file
90
packages/core/src/providers/quickdesk/observations.ts
Normal 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: [] }
|
||||
}
|
||||
70
packages/core/src/providers/quickdesk/types.ts
Normal file
70
packages/core/src/providers/quickdesk/types.ts
Normal 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
|
||||
}
|
||||
334
packages/core/src/providers/warp/decode.ts
Normal file
334
packages/core/src/providers/warp/decode.ts
Normal 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 }
|
||||
}
|
||||
24
packages/core/src/providers/warp/index.ts
Normal file
24
packages/core/src/providers/warp/index.ts
Normal 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'
|
||||
88
packages/core/src/providers/warp/observations.ts
Normal file
88
packages/core/src/providers/warp/observations.ts
Normal 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: [] }
|
||||
}
|
||||
84
packages/core/src/providers/warp/types.ts
Normal file
84
packages/core/src/providers/warp/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
197
packages/core/tests/providers/cursor-agent-decode.test.ts
Normal file
197
packages/core/tests/providers/cursor-agent-decode.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
401
packages/core/tests/providers/devin-decode.test.ts
Normal file
401
packages/core/tests/providers/devin-decode.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
265
packages/core/tests/providers/hermes-decode.test.ts
Normal file
265
packages/core/tests/providers/hermes-decode.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
268
packages/core/tests/providers/quickdesk-decode.test.ts
Normal file
268
packages/core/tests/providers/quickdesk-decode.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
201
packages/core/tests/providers/warp-decode.test.ts
Normal file
201
packages/core/tests/providers/warp-decode.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue