refactor(core): opencode-session shared decode into core — opencode, kilo-code (phase 8, shared batch S2)

Unifies the three-file OpenCode decode family — session-message.ts,
sqlite-session-parser.ts, and opencode-file-parser.ts — into a single core
module, packages/core/src/providers/opencode-session, exposing a two-arm
tagged envelope (kind: 'sqlite' | 'file') over one shared assistant-turn
builder. The SQLite driver, both WITH RECURSIVE session_tree queries,
blobToText, discovery, and extractBashCommands all stay CLI-side.

session-message.ts shrinks to the discovery-side sanitize helper;
sqlite-session-parser.ts and opencode-file-parser.ts keep their SQL and
directory walks and expose readRecords adapters. No decode logic remains
CLI-side in any of the three.

kilo-code is now fully converted: both arms — cline task dirs via the batch-S1
core vscode-cline module and SQLite via the new module — run through core
decodes from one bridged provider, and the thin createClineParser adapter S1
retained is deleted.

The CODEBURN_VERBOSE zero-yield notice is reconstructed host-side by
zed-style decode wrappers, byte-identical for both the OpenCode and KiloCode
labels, with parseFailCount/roleSkipCount derived from the decode's
malformed-json / unknown-shape diagnostics. The session-level SQLite fallback
is pre-fetched onto the envelope instead of queried lazily — output-neutral,
changing only I/O volume — and keeps its distinct key shape, emitting no
skills/subagentTypes keys.

Validator fixes on top of the migration:

- opencode's file arm emitted a spurious SQLite verbose stderr line, reporting
  a previous SQLite source's session id and message count, because the
  readRecords -> decode count handoff was never cleared when switching arms.
  The handoff is now reset on the file path. Pinned by a new regression test.
- The S8 and F9 dedup goldens did not actually pin add-after-build: the SQLite
  fixture used two different message ids, and both file fixtures were degenerate
  (the CLI one wrote the same filename twice and silently overwrote; the core
  one shared a text part across both messages, so the "skipped" message built
  successfully). All four now fail if seenKeys.add is hoisted above the build.
- Added key-presence gates to the goldens, since toEqual accepts a
  present-but-undefined key: the session-level fallback must omit
  skills/subagentTypes, a message-arm call per arm must carry them, and
  fallbackCostUSD must be present for cost 0 and absent for an absent cost.

Goldens were captured pre-migration and re-verified against the original tree
by restoring 38172892's sources in place; all 36 pass on both sides.

Pre-existing issues moved verbatim and left alone: the bare-Record tool map
with a truthy hit check in normalizeToolName (prototype leak on names like
'constructor'), opencode.ts's duplicated display-only tool map, and the dead
inner role guard in the SQLite arm. Every new lookup introduced by the
unification is a Map.
This commit is contained in:
iamtoruk 2026-07-26 22:55:34 -07:00
parent 3817289238
commit 4808b115cc
16 changed files with 3512 additions and 438 deletions

View file

@ -1,9 +1,16 @@
import { join } from 'path'
import { homedir } from 'os'
import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js'
import { discoverSqliteSessions, createSqliteSessionParser, type SqliteProviderConfig } from './sqlite-session-parser.js'
import type { Provider, SessionSource, SessionParser } from './types.js'
import { decodeVscodeCline } from '@codeburn/core/providers/vscode-cline'
import { decodeOpenCodeSession } from '@codeburn/core/providers/opencode-session'
import type { VscodeClineDecodedCall } from '@codeburn/core/providers/vscode-cline'
import type { OpenCodeSessionDecodedCall } from '@codeburn/core/providers/opencode-session'
import { discoverSqliteSessions, readSqliteSessionRecords, type SqliteProviderConfig } from './sqlite-session-parser.js'
import { discoverClineTasks, readClineRecords, toClineProviderCall } from './vscode-cline-parser.js'
import { toOpenCodeProviderCall } from './opencode.js'
import { createBridgedProvider } from './bridge.js'
import type { Provider, SessionSource, ParsedProviderCall } from './types.js'
const EXTENSION_ID = 'kilocode.kilo-code'
const PROVIDER_NAME = 'kilo-code'
@ -18,10 +25,20 @@ function getSqliteConfig(): SqliteProviderConfig {
}
}
type KiloRich =
| { arm: 'cline'; call: VscodeClineDecodedCall }
| { arm: 'sqlite'; call: OpenCodeSessionDecodedCall }
export function createKiloCodeProvider(overrideDir?: string | string[]): Provider {
const sqliteConfig = getSqliteConfig()
return {
// Provider-instance-scoped handoff for the verbose-stderr counts and session
// id on the SQLite arm. readRecords and decode are called synchronously in
// sequence inside one parse() generator, so this is safe.
let lastSqliteCounts: { messageCount: number; partCount: number } | undefined
let lastSessionId: string | undefined
return createBridgedProvider<KiloRich>({
name: PROVIDER_NAME,
displayName: 'KiloCode',
@ -41,13 +58,59 @@ export function createKiloCodeProvider(overrideDir?: string | string[]): Provide
return [...oldSessions, ...dbSessions]
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
async readRecords(source) {
if (source.path.includes('.db:')) {
return createSqliteSessionParser(source, seenKeys, sqliteConfig)
const result = await readSqliteSessionRecords(source, sqliteConfig)
if (result === null) return null
const envelope = result.records[0] as { sessionId: string }
lastSessionId = envelope.sessionId
lastSqliteCounts = { messageCount: result.messageCount, partCount: result.partCount }
return result.records
}
return createClineParser(source, seenKeys, PROVIDER_NAME)
return readClineRecords(source)
},
}
decode(input) {
const envelope = input.records[0] as { kind: string; sessionId?: string } | undefined
if (!envelope) return { calls: [] }
if (envelope.kind === 'cline-task') {
const { calls } = decodeVscodeCline({
records: input.records,
context: input.context,
seenKeys: input.seenKeys,
})
return { calls: calls.map(call => ({ arm: 'cline' as const, call })) }
}
if (envelope.kind === 'sqlite') {
const { calls, diagnostics } = decodeOpenCodeSession({
records: input.records,
context: input.context,
seenKeys: input.seenKeys,
})
// Preserve the pre-migration CODEBURN_VERBOSE notice for the SQLite arm.
if (calls.length === 0 && lastSqliteCounts && lastSqliteCounts.messageCount > 0
&& process.env['CODEBURN_VERBOSE'] === '1' && lastSessionId) {
const parseFailCount = diagnostics.filter(d => d.code === 'malformed-json').length
const roleSkipCount = diagnostics.filter(d => d.code === 'unknown-shape').length
process.stderr.write(
`codeburn: KiloCode session ${lastSessionId} has ${lastSqliteCounts.messageCount} messages ` +
`(${parseFailCount} unparseable, ${roleSkipCount} non-user/assistant roles) ` +
`but yielded 0 calls. Parts: ${lastSqliteCounts.partCount}.\n`
)
}
return { calls: calls.map(call => ({ arm: 'sqlite' as const, call })) }
}
return { calls: [] }
},
toProviderCall(rich) {
if (rich.arm === 'cline') return toClineProviderCall(rich.call)
return toOpenCodeProviderCall(rich.call)
},
})
}
export const kiloCode = createKiloCodeProvider()

View file

@ -1,15 +1,15 @@
import { readdir, readFile } from 'fs/promises'
import { join } from 'path'
import { buildAssistantCall, sanitize, type MessageData, type PartData } from './session-message.js'
import type { SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import { sanitize } from './session-message.js'
import type { SessionSource } from './types.js'
// OpenCode 1.1+ stores sessions as file-based JSON instead of a SQLite DB:
// storage/session/<projectID>/<sessionID>.json session metadata
// storage/message/<sessionID>/<messageID>.json one file per message
// storage/part/<messageID>/<partID>.json one file per part
// The message/part shape matches the SQLite layout, so the per-message build
// logic is shared via buildAssistantCall.
// logic is shared via @codeburn/core/providers/opencode-session.
type SessionMeta = {
id?: string
@ -18,8 +18,24 @@ type SessionMeta = {
time?: { created?: number }
}
type FileMessageData = MessageData & {
type FileMessageData = {
id?: string
role: string
modelID?: string
model?: string
cost?: number
tokens?: {
input?: number
output?: number
reasoning?: number
cache?: { read?: number; write?: number }
}
usage?: {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
}
time?: { created?: number }
}
@ -31,23 +47,6 @@ async function readJson<T>(path: string): Promise<T | null> {
}
}
async function readParts(dataDir: string, messageId: string): Promise<PartData[]> {
const dir = join(dataDir, 'storage', 'part', messageId)
let files: string[]
try {
files = (await readdir(dir)).sort()
} catch {
return []
}
const parts: PartData[] = []
for (const f of files) {
if (!f.endsWith('.json')) continue
const part = await readJson<PartData>(join(dir, f))
if (part) parts.push(part)
}
return parts
}
export async function discoverOpenCodeFileSessions(
dataDir: string,
providerName: string,
@ -83,72 +82,62 @@ export async function discoverOpenCodeFileSessions(
return sources
}
export function createOpenCodeFileSessionParser(
export async function readOpenCodeFileRecords(
source: SessionSource,
seenKeys: Set<string>,
dataDir: string,
providerName: string,
): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const meta = await readJson<SessionMeta>(source.path)
if (!meta?.id) return
const sessionId = meta.id
): Promise<unknown[] | null> {
const meta = await readJson<SessionMeta>(source.path)
if (!meta?.id) return null
const sessionId = meta.id
const messageDir = join(dataDir, 'storage', 'message', sessionId)
let messageFiles: string[]
try {
messageFiles = await readdir(messageDir)
} catch {
return
}
const messages: Array<{ id: string; data: FileMessageData }> = []
for (const f of messageFiles) {
if (!f.endsWith('.json')) continue
const data = await readJson<FileMessageData>(join(messageDir, f))
if (!data) continue
messages.push({ id: data.id ?? f.replace(/\.json$/, ''), data })
}
messages.sort((a, b) => {
const byTime = (a.data.time?.created ?? 0) - (b.data.time?.created ?? 0)
if (byTime !== 0) return byTime
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
})
let currentUserMessage = ''
for (const { id, data } of messages) {
if (data.role === 'user') {
const parts = await readParts(dataDir, id)
const text = parts
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.filter(Boolean)
.join(' ')
if (text) currentUserMessage = text
continue
}
if (data.role !== 'assistant' && data.role !== 'model') continue
const dedupKey = `${providerName}:${sessionId}:${id}`
if (seenKeys.has(dedupKey)) continue
const parts = await readParts(dataDir, id)
const call = buildAssistantCall({
providerName,
dedupKey,
sessionId,
data,
parts,
timeCreatedMs: data.time?.created ?? meta.time?.created ?? 0,
userMessage: currentUserMessage,
})
if (!call) continue
seenKeys.add(dedupKey)
yield call
}
},
const messageDir = join(dataDir, 'storage', 'message', sessionId)
let messageFiles: string[]
try {
messageFiles = await readdir(messageDir)
} catch {
return null
}
// Message-file JSON.parse stays host-side here (unlike the SQLite arm, where
// core parses), because the message ids determine which part directories to
// read: the parse is a precondition of the I/O, not a step after it.
const messages: Array<{ id: string; data: FileMessageData }> = []
for (const f of messageFiles) {
if (!f.endsWith('.json')) continue
const data = await readJson<FileMessageData>(join(messageDir, f))
if (!data) continue
messages.push({ id: data.id ?? f.replace(/\.json$/, ''), data })
}
// Part files are read eagerly for every message. The original read them lazily
// only for messages that survived role and dedup checks; this is a strict
// superset with identical output, changing only I/O volume.
const partsRawByMessageId = new Map<string, string[]>()
for (const { id } of messages) {
const partDir = join(dataDir, 'storage', 'part', id)
let files: string[]
try {
files = (await readdir(partDir)).sort()
} catch {
continue
}
const rawParts: string[] = []
for (const f of files) {
if (!f.endsWith('.json')) continue
try {
rawParts.push(await readFile(join(partDir, f), 'utf8'))
} catch {
// skip unreadable part file
}
}
partsRawByMessageId.set(id, rawParts)
}
return [{
kind: 'file',
sessionId,
messages,
partsRawByMessageId,
metaTimeCreatedMs: meta.time?.created,
}]
}

View file

@ -1,10 +1,14 @@
import { join } from 'path'
import { homedir } from 'os'
import { decodeOpenCodeSession } from '@codeburn/core/providers/opencode-session'
import type { OpenCodeSessionDecodedCall } from '@codeburn/core/providers/opencode-session'
import { getShortModelName } from '../models.js'
import { discoverSqliteSessions, createSqliteSessionParser, type SqliteProviderConfig } from './sqlite-session-parser.js'
import { discoverOpenCodeFileSessions, createOpenCodeFileSessionParser } from './opencode-file-parser.js'
import type { Provider, ProbeRoot, SessionSource, SessionParser } from './types.js'
import { extractBashCommands } from '../bash-utils.js'
import { discoverSqliteSessions, readSqliteSessionRecords, type SqliteProviderConfig } from './sqlite-session-parser.js'
import { discoverOpenCodeFileSessions, readOpenCodeFileRecords } from './opencode-file-parser.js'
import { createBridgedProvider } from './bridge.js'
import type { Provider, ProbeRoot, SessionSource, ParsedProviderCall } from './types.js'
const toolNameMap: Record<string, string> = {
bash: 'Bash',
@ -55,11 +59,41 @@ function getSqliteConfig(dataDir?: string): SqliteProviderConfig {
}
}
export function toOpenCodeProviderCall(rich: OpenCodeSessionDecodedCall): ParsedProviderCall {
return {
provider: rich.provider,
model: rich.model,
inputTokens: rich.inputTokens,
outputTokens: rich.outputTokens,
cacheCreationInputTokens: rich.cacheCreationInputTokens,
cacheReadInputTokens: rich.cacheReadInputTokens,
cachedInputTokens: rich.cachedInputTokens,
reasoningTokens: rich.reasoningTokens,
webSearchRequests: rich.webSearchRequests,
costBasis: 'estimated',
...(rich.fallbackCostUSD !== undefined ? { fallbackCostUSD: rich.fallbackCostUSD } : {}),
tools: rich.tools,
bashCommands: rich.rawBashCommands.flatMap(c => extractBashCommands(c)),
...(rich.arm === 'session-level' ? {} : { skills: rich.skills, subagentTypes: rich.subagentTypes }),
timestamp: rich.timestamp,
speed: rich.speed,
deduplicationKey: rich.deduplicationKey,
userMessage: rich.userMessage,
sessionId: rich.sessionId,
}
}
export function createOpenCodeProvider(dataDir?: string): Provider {
const sqliteConfig = getSqliteConfig(dataDir)
const resolvedDataDir = getDataDir(dataDir)
return {
// Provider-instance-scoped handoff for the verbose-stderr counts and session
// id. readRecords and decode are called synchronously in sequence inside one
// parse() generator, so this is safe without ordering assumptions.
let lastSqliteCounts: { messageCount: number; partCount: number } | undefined
let lastSessionId: string | undefined
return createBridgedProvider<OpenCodeSessionDecodedCall>({
name: 'opencode',
displayName: 'OpenCode',
@ -91,13 +125,43 @@ export function createOpenCodeProvider(dataDir?: string): Provider {
return [...fileSessions, ...sqliteSessions]
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
async readRecords(source) {
if (source.path.endsWith('.json')) {
return createOpenCodeFileSessionParser(source, seenKeys, resolvedDataDir, 'opencode')
// Clear the SQLite handoff: the file arm has no verbose notice, and a
// stale count from an earlier SQLite source would make decode() emit one
// for a zero-yield file session.
lastSqliteCounts = undefined
lastSessionId = undefined
return readOpenCodeFileRecords(source, resolvedDataDir)
}
return createSqliteSessionParser(source, seenKeys, sqliteConfig)
const result = await readSqliteSessionRecords(source, sqliteConfig)
if (result === null) return null
const envelope = result.records[0] as { sessionId: string }
lastSessionId = envelope.sessionId
lastSqliteCounts = { messageCount: result.messageCount, partCount: result.partCount }
return result.records
},
}
decode(input) {
const { calls, diagnostics } = decodeOpenCodeSession(input)
// The pre-migration decode printed one aggregate diagnostic per zero-yield
// SQLite session under CODEBURN_VERBOSE. The bridge discards diagnostics, so
// the notice is re-emitted here rather than silently dropped.
if (calls.length === 0 && lastSqliteCounts && lastSqliteCounts.messageCount > 0
&& process.env['CODEBURN_VERBOSE'] === '1' && lastSessionId) {
const parseFailCount = diagnostics.filter(d => d.code === 'malformed-json').length
const roleSkipCount = diagnostics.filter(d => d.code === 'unknown-shape').length
process.stderr.write(
`codeburn: OpenCode session ${lastSessionId} has ${lastSqliteCounts.messageCount} messages ` +
`(${parseFailCount} unparseable, ${roleSkipCount} non-user/assistant roles) ` +
`but yielded 0 calls. Parts: ${lastSqliteCounts.partCount}.\n`
)
}
return { calls }
},
toProviderCall: toOpenCodeProviderCall,
})
}
export const opencode = createOpenCodeProvider()

View file

@ -1,155 +1,8 @@
import { extractBashCommands } from '../bash-utils.js'
import type { ParsedProviderCall } from './types.js'
// The message/part shape shared by OpenCode-style stores (OpenCode SQLite, the
// OpenCode file-based JSON layout, and Kilo Code). Token-bearing assistant
// messages carry either the normalized `tokens` object or a raw `usage` block.
export type MessageData = {
role: string
modelID?: string
model?: string
cost?: number
tokens?: {
input?: number
output?: number
reasoning?: number
cache?: { read?: number; write?: number }
}
usage?: {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
}
}
export type PartData = {
type: string
text?: string
tool?: string
state?: { input?: { command?: string; name?: string; subagent_type?: string } }
}
const toolNameMap: Record<string, string> = {
bash: 'Bash',
read: 'Read',
edit: 'Edit',
write: 'Write',
glob: 'Glob',
grep: 'Grep',
task: 'Agent',
fetch: 'WebFetch',
search: 'WebSearch',
todo: 'TodoWrite',
skill: 'Skill',
patch: 'Patch',
}
export function normalizeToolName(rawTool?: string): string {
if (!rawTool) return ''
if (rawTool.startsWith('mcp__')) return rawTool
const builtIn = toolNameMap[rawTool]
if (builtIn) return builtIn
const serverSeparator = rawTool.indexOf('_')
if (serverSeparator > 0 && serverSeparator < rawTool.length - 1) {
const server = rawTool.slice(0, serverSeparator)
const tool = rawTool.slice(serverSeparator + 1)
return `mcp__${server}__${tool}`
}
return rawTool
}
// Discovery-side helper retained from the old OpenCode session-message module.
// The shared decode logic (the assistant-turn builder, tool-name normalization,
// timestamp parsing, and the message/part types) moved to
// @codeburn/core/providers/opencode-session.
export function sanitize(dir: string): string {
return dir.replace(/^\//, '').replace(/\//g, '-')
}
export function parseTimestamp(raw: number): string {
const ms = raw < 1e12 ? raw * 1000 : raw
return new Date(ms).toISOString()
}
// Build a ParsedProviderCall from one assistant message and its parts. Returns
// null when the message has no tokens, no cost, and no substantive parts (an
// empty or errored turn worth skipping). Shared by the SQLite and file-based
// OpenCode parsers so both attribute tokens, tools, and cost identically.
export function buildAssistantCall(opts: {
providerName: string
dedupKey: string
sessionId: string
data: MessageData
parts: PartData[]
timeCreatedMs: number
userMessage: string
}): ParsedProviderCall | null {
const { data, parts } = opts
const tokens = {
input: data.tokens?.input ?? data.usage?.input_tokens ?? 0,
output: data.tokens?.output ?? data.usage?.output_tokens ?? 0,
reasoning: data.tokens?.reasoning ?? 0,
cacheRead: data.tokens?.cache?.read ?? data.usage?.cache_read_input_tokens ?? 0,
cacheWrite: data.tokens?.cache?.write ?? data.usage?.cache_creation_input_tokens ?? 0,
}
const toolParts = parts.filter((p) => (p.type === 'tool' || p.type === 'tool-call' || p.type === 'tool_call') && normalizeToolName(p.tool))
const hasTextOutput = parts.some((p) => p.type === 'text' && typeof p.text === 'string' && p.text.trim().length > 0)
const hasToolOrTextParts = hasTextOutput || toolParts.length > 0
const hasAnySubstantiveParts = parts.some((p) =>
p.type === 'text' || p.type === 'tool' || p.type === 'tool-call' || p.type === 'tool_call' ||
p.type === 'tool-result' || p.type === 'tool_result' || p.type === 'reasoning' || p.type === 'file'
)
const hasActivity = hasToolOrTextParts || hasAnySubstantiveParts
const allZero =
tokens.input === 0 &&
tokens.output === 0 &&
tokens.reasoning === 0 &&
tokens.cacheRead === 0 &&
tokens.cacheWrite === 0
if (allZero && (data.cost ?? 0) === 0 && !hasActivity) return null
const tools = toolParts
.map((p) => normalizeToolName(p.tool))
.filter(Boolean)
const bashCommands = toolParts
.filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string')
.flatMap((p) => extractBashCommands(p.state!.input!.command!))
// The skill/subagent name lives in the tool-call input, not the tool name, so
// the Skills & Agents breakdown needs these extracted alongside the tool list.
const skills = toolParts
.filter((p) => p.tool === 'skill' && typeof p.state?.input?.name === 'string')
.map((p) => p.state!.input!.name!)
.filter(Boolean)
const subagentTypes = toolParts
.filter((p) => p.tool === 'task' && typeof p.state?.input?.subagent_type === 'string')
.map((p) => p.state!.input!.subagent_type!)
.filter(Boolean)
const model = data.modelID ?? data.model ?? 'unknown'
return {
provider: opts.providerName,
model,
inputTokens: tokens.input,
outputTokens: tokens.output,
cacheCreationInputTokens: tokens.cacheWrite,
cacheReadInputTokens: tokens.cacheRead,
cachedInputTokens: tokens.cacheRead,
reasoningTokens: tokens.reasoning,
webSearchRequests: 0,
costBasis: 'estimated',
...(typeof data.cost === 'number' ? { fallbackCostUSD: data.cost } : {}),
tools,
bashCommands,
skills,
subagentTypes,
timestamp: parseTimestamp(opts.timeCreatedMs),
speed: 'standard',
deduplicationKey: opts.dedupKey,
userMessage: opts.userMessage,
sessionId: opts.sessionId,
}
}

View file

@ -2,11 +2,9 @@ import { readdir } from 'fs/promises'
import { join } from 'path'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js'
import { buildAssistantCall, parseTimestamp, sanitize, type MessageData, type PartData } from './session-message.js'
import { sanitize } from './session-message.js'
import type {
SessionSource,
SessionParser,
ParsedProviderCall,
} from './types.js'
type MessageRow = {
@ -38,7 +36,7 @@ type SessionTokenRow = {
model_id?: string
}
function tryQuerySessionTokens(db: SqliteDatabase, sessionId: string): {
export function tryQuerySessionTokens(db: SqliteDatabase, sessionId: string): {
cost: number; input: number; output: number; reasoning: number
cacheRead: number; cacheWrite: number; model: string | undefined
} | null {
@ -100,173 +98,89 @@ export type SqliteProviderConfig = {
dbFilePrefix: string
}
export function createSqliteSessionParser(
export async function readSqliteSessionRecords(
source: SessionSource,
seenKeys: Set<string>,
config: SqliteProviderConfig,
): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
if (!isSqliteAvailable()) {
process.stderr.write(getSqliteLoadError() + '\n')
return
}
): Promise<{ records: unknown[]; messageCount: number; partCount: number } | null> {
if (!isSqliteAvailable()) {
process.stderr.write(getSqliteLoadError() + '\n')
return null
}
const segments = source.path.split(':')
const sessionId = segments[segments.length - 1]!
const dbPath = segments.slice(0, -1).join(':')
const segments = source.path.split(':')
const sessionId = segments[segments.length - 1]!
const dbPath = segments.slice(0, -1).join(':')
let db: SqliteDatabase
try {
db = openDatabase(dbPath)
} catch (err) {
process.stderr.write(`codeburn: cannot open ${config.displayName} database: ${err instanceof Error ? err.message : err}\n`)
return
}
let db: SqliteDatabase
try {
db = openDatabase(dbPath)
} catch (err) {
process.stderr.write(`codeburn: cannot open ${config.displayName} database: ${err instanceof Error ? err.message : err}\n`)
return null
}
try {
const schema = validateSchemaDetailed(db)
if (!schema.ok) {
warnUnrecognizedSchemaOnce(config.displayName, schema.missing)
return
}
try {
const schema = validateSchemaDetailed(db)
if (!schema.ok) {
warnUnrecognizedSchemaOnce(config.displayName, schema.missing)
return null
}
const messages = db.query<MessageRow>(
`WITH RECURSIVE session_tree(id) AS (
SELECT id FROM session WHERE id = ?
UNION
SELECT child.id
FROM session child
JOIN session_tree parent ON child.parent_id = parent.id
WHERE child.time_archived IS NULL
)
SELECT session_id, id, time_created, CAST(data AS BLOB) AS data
FROM message
WHERE session_id IN (SELECT id FROM session_tree)
ORDER BY time_created ASC, id ASC`,
[sessionId],
)
const messages = db.query<MessageRow>(
`WITH RECURSIVE session_tree(id) AS (
SELECT id FROM session WHERE id = ?
UNION
SELECT child.id
FROM session child
JOIN session_tree parent ON child.parent_id = parent.id
WHERE child.time_archived IS NULL
)
SELECT session_id, id, time_created, CAST(data AS BLOB) AS data
FROM message
WHERE session_id IN (SELECT id FROM session_tree)
ORDER BY time_created ASC, id ASC`,
[sessionId],
)
const parts = db.query<PartRow>(
`WITH RECURSIVE session_tree(id) AS (
SELECT id FROM session WHERE id = ?
UNION
SELECT child.id
FROM session child
JOIN session_tree parent ON child.parent_id = parent.id
WHERE child.time_archived IS NULL
)
SELECT message_id, CAST(data AS BLOB) AS data
FROM part
WHERE session_id IN (SELECT id FROM session_tree)
ORDER BY message_id, id`,
[sessionId],
)
const parts = db.query<PartRow>(
`WITH RECURSIVE session_tree(id) AS (
SELECT id FROM session WHERE id = ?
UNION
SELECT child.id
FROM session child
JOIN session_tree parent ON child.parent_id = parent.id
WHERE child.time_archived IS NULL
)
SELECT message_id, CAST(data AS BLOB) AS data
FROM part
WHERE session_id IN (SELECT id FROM session_tree)
ORDER BY message_id, id`,
[sessionId],
)
const partsByMsg = new Map<string, PartData[]>()
for (const part of parts) {
try {
const parsed = JSON.parse(blobToText(part.data)) as PartData
const list = partsByMsg.get(part.message_id) ?? []
list.push(parsed)
partsByMsg.set(part.message_id, list)
} catch {
// skip corrupt part data
}
}
const sessionTokens = tryQuerySessionTokens(db, sessionId)
const currentUserMessageBySession = new Map<string, string>()
let yieldCount = 0
let parseFailCount = 0
let roleSkipCount = 0
for (const msg of messages) {
let data: MessageData
try {
data = JSON.parse(blobToText(msg.data)) as MessageData
} catch {
parseFailCount++
continue
}
if (data.role === 'user') {
const textParts = (partsByMsg.get(msg.id) ?? [])
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.filter(Boolean)
if (textParts.length > 0) {
currentUserMessageBySession.set(msg.session_id, textParts.join(' '))
}
continue
}
if (data.role !== 'assistant' && data.role !== 'model') {
if (data.role !== 'user') roleSkipCount++
continue
}
const dedupKey = `${config.providerName}:${msg.session_id}:${msg.id}`
if (seenKeys.has(dedupKey)) continue
const call = buildAssistantCall({
providerName: config.providerName,
dedupKey,
sessionId,
data,
parts: partsByMsg.get(msg.id) ?? [],
timeCreatedMs: msg.time_created,
userMessage: currentUserMessageBySession.get(msg.session_id) ?? '',
})
if (!call) continue
seenKeys.add(dedupKey)
yieldCount++
yield call
}
if (yieldCount === 0 && messages.length > 0) {
const sessionTokens = tryQuerySessionTokens(db, sessionId)
if (sessionTokens && (sessionTokens.cost > 0 || sessionTokens.input > 0 || sessionTokens.output > 0)) {
const dedupKey = `${config.providerName}:${sessionId}:session-level`
if (!seenKeys.has(dedupKey)) {
seenKeys.add(dedupKey)
const model = sessionTokens.model ?? 'unknown'
yield {
provider: config.providerName,
model,
inputTokens: sessionTokens.input,
outputTokens: sessionTokens.output,
cacheCreationInputTokens: sessionTokens.cacheWrite,
cacheReadInputTokens: sessionTokens.cacheRead,
cachedInputTokens: sessionTokens.cacheRead,
reasoningTokens: sessionTokens.reasoning,
webSearchRequests: 0,
costBasis: 'estimated',
...(sessionTokens.cost > 0 ? { fallbackCostUSD: sessionTokens.cost } : {}),
tools: [],
bashCommands: [],
timestamp: parseTimestamp(messages[0]!.time_created),
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: '',
sessionId,
}
yieldCount++
}
}
if (yieldCount === 0 && process.env['CODEBURN_VERBOSE'] === '1') {
process.stderr.write(
`codeburn: ${config.displayName} session ${sessionId} has ${messages.length} messages ` +
`(${parseFailCount} unparseable, ${roleSkipCount} non-user/assistant roles) ` +
`but yielded 0 calls. Parts: ${parts.length}.\n`
)
}
}
} finally {
db.close()
}
},
return {
records: [{
kind: 'sqlite',
sessionId,
messages: messages.map(m => ({
session_id: m.session_id,
id: m.id,
time_created: m.time_created,
data: blobToText(m.data),
})),
parts: parts.map(p => ({
message_id: p.message_id,
data: blobToText(p.data),
})),
sessionTokens,
}],
messageCount: messages.length,
partCount: parts.length,
}
} finally {
db.close()
}
}
@ -322,4 +236,3 @@ export async function discoverSqliteSessions(
return sessions
}

View file

@ -5,7 +5,7 @@ import { homedir } from 'os'
import { decodeVscodeCline } from '@codeburn/core/providers/vscode-cline'
import type { ClineRecordEnvelope, VscodeClineDecodedCall } from '@codeburn/core/providers/vscode-cline'
import type { SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ParsedProviderCall, SessionSource } from './types.js'
export function getVSCodeGlobalStoragePaths(extensionId: string, homeDir = homedir(), platform = process.platform): string[] {
const pathJoin = platform === 'win32' ? win32.join : posix.join
@ -124,25 +124,3 @@ export function toClineProviderCall(rich: VscodeClineDecodedCall): ParsedProvide
projectPath: rich.projectPath,
}
}
/**
* Legacy-shaped adapter retained for kilo-code, whose second (SQLite) arm does
* not move to core until batch S2. It is I/O + map over the core decode no
* decode logic lives here. Deleted once kilo-code becomes a bridged provider.
*/
export function createClineParser(
source: SessionSource,
seenKeys: Set<string>,
providerName: string,
fallbackModel = 'cline-auto',
): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const records = await readClineRecords(source)
if (records === null) return
const context = { privacyKey: '', providerId: providerName, sourceRef: source.path }
const { calls } = decodeVscodeCline({ records, context, seenKeys, fallbackModel })
for (const rich of calls) yield toClineProviderCall(rich)
},
}
}

File diff suppressed because it is too large Load diff

View file

@ -146,6 +146,10 @@
"./providers/vscode-cline": {
"types": "./dist/providers/vscode-cline/index.d.ts",
"import": "./dist/providers/vscode-cline/index.js"
},
"./providers/opencode-session": {
"types": "./dist/providers/opencode-session/index.d.ts",
"import": "./dist/providers/opencode-session/index.js"
}
},
"files": [

View file

@ -0,0 +1,341 @@
// OpenCode-session rich decode.
//
// Pure record decode: no fs, no SQLite, no env, no process, no pricing, no
// strip-ansi. The CLI hands core a tagged envelope; core returns rich calls.
import type { RecordDiagnostic } from '../../diagnostics.js'
import type { DecodeContext } from '../../contracts.js'
import type {
FileMessageData,
MessageData,
OpenCodeSessionDecodedCall,
OpenCodeSessionEnvelope,
OpenCodeSessionTokens,
PartData,
} from './types.js'
// ── tool-name normalization (moved verbatim from session-message.ts) ─────────
export const openCodeToolNameMap: Record<string, string> = {
bash: 'Bash',
read: 'Read',
edit: 'Edit',
write: 'Write',
glob: 'Glob',
grep: 'Grep',
task: 'Agent',
fetch: 'WebFetch',
search: 'WebSearch',
todo: 'TodoWrite',
skill: 'Skill',
patch: 'Patch',
}
/**
* Normalize an OpenCode-style tool name.
*
* NOTE: this preserves a pre-existing prototype leak: the bare Record lookup
* followed by a truthy hit check means names like 'constructor' return
* Object.prototype.constructor. It is moved byte-for-byte to keep parity with
* the CLI output; do not "harden" it here.
*/
export function normalizeToolName(rawTool?: string): string {
if (!rawTool) return ''
if (rawTool.startsWith('mcp__')) return rawTool
const builtIn = openCodeToolNameMap[rawTool]
if (builtIn) return builtIn
const serverSeparator = rawTool.indexOf('_')
if (serverSeparator > 0 && serverSeparator < rawTool.length - 1) {
const server = rawTool.slice(0, serverSeparator)
const tool = rawTool.slice(serverSeparator + 1)
return `mcp__${server}__${tool}`
}
return rawTool
}
export function parseTimestamp(raw: number): string {
const ms = raw < 1e12 ? raw * 1000 : raw
return new Date(ms).toISOString()
}
// ── shared assistant-turn builder ────────────────────────────────────────────
function buildAssistantCall(opts: {
providerName: string
dedupKey: string
sessionId: string
data: MessageData
parts: PartData[]
timeCreatedMs: number
userMessage: string
}): OpenCodeSessionDecodedCall | null {
const { data, parts } = opts
const tokens = {
input: data.tokens?.input ?? data.usage?.input_tokens ?? 0,
output: data.tokens?.output ?? data.usage?.output_tokens ?? 0,
reasoning: data.tokens?.reasoning ?? 0,
cacheRead: data.tokens?.cache?.read ?? data.usage?.cache_read_input_tokens ?? 0,
cacheWrite: data.tokens?.cache?.write ?? data.usage?.cache_creation_input_tokens ?? 0,
}
const toolParts = parts.filter((p) => (p.type === 'tool' || p.type === 'tool-call' || p.type === 'tool_call') && normalizeToolName(p.tool))
const hasTextOutput = parts.some((p) => p.type === 'text' && typeof p.text === 'string' && p.text.trim().length > 0)
const hasToolOrTextParts = hasTextOutput || toolParts.length > 0
const hasAnySubstantiveParts = parts.some((p) =>
p.type === 'text' || p.type === 'tool' || p.type === 'tool-call' || p.type === 'tool_call' ||
p.type === 'tool-result' || p.type === 'tool_result' || p.type === 'reasoning' || p.type === 'file'
)
const hasActivity = hasToolOrTextParts || hasAnySubstantiveParts
const allZero =
tokens.input === 0 &&
tokens.output === 0 &&
tokens.reasoning === 0 &&
tokens.cacheRead === 0 &&
tokens.cacheWrite === 0
if (allZero && (data.cost ?? 0) === 0 && !hasActivity) return null
const tools = toolParts
.map((p) => normalizeToolName(p.tool))
.filter(Boolean)
const rawBashCommands = toolParts
.filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string')
.map((p) => p.state!.input!.command!)
const skills = toolParts
.filter((p) => p.tool === 'skill' && typeof p.state?.input?.name === 'string')
.map((p) => p.state!.input!.name!)
.filter(Boolean)
const subagentTypes = toolParts
.filter((p) => p.tool === 'task' && typeof p.state?.input?.subagent_type === 'string')
.map((p) => p.state!.input!.subagent_type!)
.filter(Boolean)
const model = data.modelID ?? data.model ?? 'unknown'
return {
arm: 'message',
provider: opts.providerName,
model,
inputTokens: tokens.input,
outputTokens: tokens.output,
cacheCreationInputTokens: tokens.cacheWrite,
cacheReadInputTokens: tokens.cacheRead,
cachedInputTokens: tokens.cacheRead,
reasoningTokens: tokens.reasoning,
webSearchRequests: 0,
...(typeof data.cost === 'number' ? { fallbackCostUSD: data.cost } : {}),
tools,
rawBashCommands,
skills,
subagentTypes,
timestamp: parseTimestamp(opts.timeCreatedMs),
speed: 'standard',
deduplicationKey: opts.dedupKey,
userMessage: opts.userMessage,
sessionId: opts.sessionId,
}
}
// ── arm dispatch ─────────────────────────────────────────────────────────────
export type OpenCodeSessionDecodeInput = {
records: unknown[]
context: DecodeContext
seenKeys?: Set<string>
}
export type OpenCodeSessionDecodeResult = {
calls: OpenCodeSessionDecodedCall[]
diagnostics: RecordDiagnostic[]
}
function decodeSqlite(
envelope: Extract<OpenCodeSessionEnvelope, { kind: 'sqlite' }>,
providerName: string,
seenKeys: Set<string>,
): OpenCodeSessionDecodeResult {
const calls: OpenCodeSessionDecodedCall[] = []
const diagnostics: RecordDiagnostic[] = []
const partsByMsg = new Map<string, PartData[]>()
for (const part of envelope.parts) {
try {
const parsed = JSON.parse(part.data) as PartData
const list = partsByMsg.get(part.message_id) ?? []
list.push(parsed)
partsByMsg.set(part.message_id, list)
} catch {
// skip corrupt part data
}
}
const currentUserMessageBySession = new Map<string, string>()
for (const msg of envelope.messages) {
let data: MessageData
try {
data = JSON.parse(msg.data) as MessageData
} catch {
diagnostics.push({ index: 0, code: 'malformed-json' })
continue
}
if (data.role === 'user') {
const textParts = (partsByMsg.get(msg.id) ?? [])
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.filter(Boolean)
if (textParts.length > 0) {
currentUserMessageBySession.set(msg.session_id, textParts.join(' '))
}
continue
}
if (data.role !== 'assistant' && data.role !== 'model') {
diagnostics.push({ index: 0, code: 'unknown-shape' })
continue
}
const dedupKey = `${providerName}:${msg.session_id}:${msg.id}`
if (seenKeys.has(dedupKey)) continue
const call = buildAssistantCall({
providerName,
dedupKey,
sessionId: envelope.sessionId,
data,
parts: partsByMsg.get(msg.id) ?? [],
timeCreatedMs: msg.time_created,
userMessage: currentUserMessageBySession.get(msg.session_id) ?? '',
})
if (!call) continue
seenKeys.add(dedupKey)
calls.push(call)
}
if (calls.length === 0 && envelope.messages.length > 0) {
const sessionTokens = envelope.sessionTokens
if (
sessionTokens &&
(sessionTokens.cost > 0 || sessionTokens.input > 0 || sessionTokens.output > 0)
) {
const dedupKey = `${providerName}:${envelope.sessionId}:session-level`
if (!seenKeys.has(dedupKey)) {
seenKeys.add(dedupKey)
calls.push({
arm: 'session-level',
provider: providerName,
model: sessionTokens.model ?? 'unknown',
inputTokens: sessionTokens.input,
outputTokens: sessionTokens.output,
cacheCreationInputTokens: sessionTokens.cacheWrite,
cacheReadInputTokens: sessionTokens.cacheRead,
cachedInputTokens: sessionTokens.cacheRead,
reasoningTokens: sessionTokens.reasoning,
webSearchRequests: 0,
...(sessionTokens.cost > 0 ? { fallbackCostUSD: sessionTokens.cost } : {}),
tools: [],
rawBashCommands: [],
skills: [],
subagentTypes: [],
timestamp: parseTimestamp(envelope.messages[0]!.time_created),
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: '',
sessionId: envelope.sessionId,
})
}
}
}
return { calls, diagnostics }
}
function decodeFile(
envelope: Extract<OpenCodeSessionEnvelope, { kind: 'file' }>,
providerName: string,
seenKeys: Set<string>,
): OpenCodeSessionDecodeResult {
const calls: OpenCodeSessionDecodedCall[] = []
const messages = envelope.messages.slice()
messages.sort((a, b) => {
const byTime = (a.data.time?.created ?? 0) - (b.data.time?.created ?? 0)
if (byTime !== 0) return byTime
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
})
let currentUserMessage = ''
for (const { id, data } of messages) {
if (data.role === 'user') {
const parts: PartData[] = []
for (const raw of envelope.partsRawByMessageId.get(id) ?? []) {
try {
parts.push(JSON.parse(raw) as PartData)
} catch {
// skip corrupt part file
}
}
const text = parts
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.filter(Boolean)
.join(' ')
if (text) currentUserMessage = text
continue
}
if (data.role !== 'assistant' && data.role !== 'model') continue
const dedupKey = `${providerName}:${envelope.sessionId}:${id}`
if (seenKeys.has(dedupKey)) continue
const parts: PartData[] = []
for (const raw of envelope.partsRawByMessageId.get(id) ?? []) {
try {
parts.push(JSON.parse(raw) as PartData)
} catch {
// skip corrupt part file
}
}
const call = buildAssistantCall({
providerName,
dedupKey,
sessionId: envelope.sessionId,
data,
parts,
timeCreatedMs: data.time?.created ?? envelope.metaTimeCreatedMs ?? 0,
userMessage: currentUserMessage,
})
if (!call) continue
seenKeys.add(dedupKey)
calls.push(call)
}
return { calls, diagnostics: [] }
}
export function decodeOpenCodeSession(input: OpenCodeSessionDecodeInput): OpenCodeSessionDecodeResult {
const seenKeys = input.seenKeys ?? new Set<string>()
const envelope = input.records[0] as OpenCodeSessionEnvelope | undefined
const providerName = input.context.providerId
if (!envelope) return { calls: [], diagnostics: [] }
switch (envelope.kind) {
case 'sqlite':
return decodeSqlite(envelope, providerName, seenKeys)
case 'file':
return decodeFile(envelope, providerName, seenKeys)
default:
return { calls: [], diagnostics: [{ index: 0, code: 'unknown-shape' }] }
}
}

View file

@ -0,0 +1,30 @@
// @codeburn/core OpenCode-session provider.
//
// Two layers:
// - Rich pure decode (`decodeOpenCodeSession`): host-facing, NOT part of the stable
// minimized surface. Pure over host-supplied session records; all I/O and
// query strings stay CLI-side, per the Category B recipe.
// - Minimizing transform (`toObservations`): maps the rich decode into the
// strict observation envelope; the content-smuggling guarantees bind here.
export {
decodeOpenCodeSession,
type OpenCodeSessionDecodeInput,
type OpenCodeSessionDecodeResult,
} from './decode.js'
export {
toObservations,
type RichOpenCodeSessionDecode,
type OpenCodeSessionToObservationsContext,
} from './observations.js'
export type {
OpenCodeSessionDecodedCall,
OpenCodeSessionEnvelope,
OpenCodeSessionTokens,
MessageData,
PartData,
SessionMeta,
FileMessageData,
} from './types.js'

View file

@ -0,0 +1,84 @@
// Minimizing transform: rich OpenCode-session decode -> the strict observation envelope.
//
// Only opaque ids, fingerprints, enums, numbers, timestamps, and canonical tool
// names cross into the output. The OpenCode-session decoder captures free-text
// fields for the HOST, but they are deliberately absent here.
import { projectRef, sessionRef } from '../../fingerprint.js'
import type { RecordDiagnostic } from '../../diagnostics.js'
import type { CallObservation, SessionObservation } from '../../observations.js'
import type { OpenCodeSessionDecodedCall } from './types.js'
/** One OpenCode-session decode, as the host holds it before minimization. */
export interface RichOpenCodeSessionDecode {
sessionId: string
/** Absolute project path; fingerprinted, never emitted raw. */
projectPath: string
/** Rich calls in decode order. */
calls: OpenCodeSessionDecodedCall[]
}
export interface OpenCodeSessionToObservationsContext {
/** HMAC key that scopes every fingerprint. */
privacyKey: string
/** Provider id stamped onto sessions/calls and folded into sessionRef. */
provider?: string
}
const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/
function toCallObservation(call: OpenCodeSessionDecodedCall, 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: RichOpenCodeSessionDecode,
ctx: OpenCodeSessionToObservationsContext,
): SessionObservation {
const provider = ctx.provider ?? 'opencode'
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]! : ''
return {
sessionRef: sessionRef(ctx.privacyKey, provider, decode.sessionId),
projectRef: projectRef(ctx.privacyKey, decode.projectPath),
providerId: provider,
startedAt,
...(endedAt ? { endedAt } : {}),
calls,
turnCount: calls.length,
}
}
/**
* Map a rich OpenCode-session decode into the minimized observation layer.
* Returns the `sessions` array plus any per-record `diagnostics`.
*/
export function toObservations(
decode: RichOpenCodeSessionDecode | RichOpenCodeSessionDecode[],
ctx: OpenCodeSessionToObservationsContext,
): { sessions: SessionObservation[]; diagnostics: RecordDiagnostic[] } {
const decodes = Array.isArray(decode) ? decode : [decode]
const sessions = decodes.map(d => toSessionObservation(d, ctx))
return { sessions, diagnostics: [] }
}

View file

@ -0,0 +1,113 @@
// OpenCode-session shared decode types.
//
// This module is the core home of the OpenCode-style session decode family
// (OpenCode SQLite, OpenCode file-based JSON, and Kilo Code SQLite). The
// message/part shape is identical across all three stores; only the I/O
// envelope differs.
/** The message blob shared by SQLite and file-based OpenCode stores. */
export type MessageData = {
role: string
modelID?: string
model?: string
cost?: number
tokens?: {
input?: number
output?: number
reasoning?: number
cache?: { read?: number; write?: number }
}
usage?: {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
}
}
/** One part of an assistant/user message. */
export type PartData = {
type: string
text?: string
tool?: string
state?: { input?: { command?: string; name?: string; subagent_type?: string } }
}
/** Session metadata from the file-based JSON layout. */
export type SessionMeta = {
id?: string
directory?: string
title?: string
time?: { created?: number }
}
/** Message file payload in the file-based JSON layout. */
export type FileMessageData = MessageData & {
id?: string
time?: { created?: number }
}
/**
* Tagged input envelope. The CLI reads the raw bytes / rows and core does the
* decode, so the SQLite driver and SQL queries stay CLI-side.
*/
export type OpenCodeSessionEnvelope =
| {
kind: 'sqlite'
/** The ROOT session id from the source path; every call is attributed to it. */
sessionId: string
/** Rows from the message query, in query order. `data` is blob-decoded text. */
messages: Array<{ session_id: string; id: string; time_created: number; data: string }>
/** Rows from the part query, in query order. `data` is blob-decoded text. */
parts: Array<{ message_id: string; data: string }>
/** Pre-fetched session-row rollup for the zero-yield fallback; null when unavailable. */
sessionTokens: OpenCodeSessionTokens | null
}
| {
kind: 'file'
sessionId: string
/** Parsed message files; ordering is decided in core, not here. */
messages: Array<{ id: string; data: FileMessageData }>
/** messageId -> raw JSON strings of its part files, in sorted filename order. */
partsRawByMessageId: ReadonlyMap<string, string[]>
/** meta.time.created, for the timeCreatedMs fallback chain. */
metaTimeCreatedMs: number | undefined
}
/** Pre-fetched session-row token rollup for the SQLite session-level fallback. */
export type OpenCodeSessionTokens = {
cost: number
input: number
output: number
reasoning: number
cacheRead: number
cacheWrite: number
model: string | undefined
}
/** A decoded, cost-free call from an OpenCode-style session. */
export interface OpenCodeSessionDecodedCall {
/** Which arm produced this call. The host switches on it only if it ever needs to. */
arm: 'message' | 'session-level'
provider: string
model: string
inputTokens: number
outputTokens: number
cacheCreationInputTokens: number
cacheReadInputTokens: number
cachedInputTokens: number
reasoningTokens: number
webSearchRequests: number
/** Provider-reported dollars used ONLY as an estimated-path fallback. Never 'measured'. */
fallbackCostUSD?: number
tools: string[]
/** RAW command strings; the host reduces these. */
rawBashCommands: string[]
skills: string[]
subagentTypes: string[]
timestamp: string
speed: 'standard'
deduplicationKey: string
userMessage: string
sessionId: string
}

View file

@ -171,6 +171,8 @@ const USER_MESSAGE_ALLOWLIST = new Set([
'src/providers/copilot/types.ts',
'src/providers/vscode-cline/decode.ts',
'src/providers/vscode-cline/types.ts',
'src/providers/opencode-session/decode.ts',
'src/providers/opencode-session/types.ts',
])
describe('architecture gate: no classification or free text in @codeburn/core source', () => {

View file

@ -36,6 +36,7 @@ import { decodeQuickdesk, toObservations as toQuickdeskObservations } from '../s
import { decodeDevin, toObservations as toDevinObservations } from '../src/providers/devin/index.js'
import { decodeCopilot, toObservations as toCopilotObservations } from '../src/providers/copilot/index.js'
import { decodeVscodeCline, toObservations as toVscodeClineObservations } from '../src/providers/vscode-cline/index.js'
import { decodeOpenCodeSession, toObservations as toOpenCodeSessionObservations } from '../src/providers/opencode-session/index.js'
import type { DecodeContext } from '../src/contracts.js'
import type { ZedThreadRow } from '../src/providers/zed/index.js'
@ -1420,3 +1421,79 @@ describe('content-smuggling guardrail: real devin decode -> toObservations is se
})
})
describe('content-smuggling guardrail: real opencode-session decode -> toObservations is secret-free', () => {
// A hostile OpenCode-session SQLite envelope planting every secret in the
// free-text fields the decode captures: user message text, a bash command, a
// skill name, a subagent type, and a tool NAME carrying a command line.
// Minimizing MUST surface none of them.
const opencodeContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'opencode', sourceRef: 'ref' }
function decodeAndMinimize() {
const records = [{
kind: 'sqlite' as const,
sessionId: 'sess-hostile',
messages: [
{
session_id: 'sess-hostile',
id: 'msg-user',
time_created: 1700000000000,
data: JSON.stringify({
role: 'user',
// user message text parts carry the prompt, api key, and file content
}),
},
{
session_id: 'sess-hostile',
id: 'msg-assistant',
time_created: 1700000001000,
data: JSON.stringify({
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.05,
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
}),
},
],
parts: [
{ message_id: 'msg-user', data: JSON.stringify({ type: 'text', text: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}` }) },
{ message_id: 'msg-assistant', data: JSON.stringify({ type: 'tool', tool: 'bash', state: { input: { command: SECRETS.commandLine } } }) },
{ message_id: 'msg-assistant', data: JSON.stringify({ type: 'tool', tool: 'skill', state: { input: { name: SECRETS.absPath } } }) },
{ message_id: 'msg-assistant', data: JSON.stringify({ type: 'tool', tool: 'task', state: { input: { subagent_type: SECRETS.apiKey } } }) },
// A hostile tool NAME carrying a command line: normalizeToolName passes it
// through unchanged, so the only thing stopping it is the CANONICAL_TOOL_NAME
// filter in observations.ts.
{ message_id: 'msg-assistant', data: JSON.stringify({ type: 'tool', tool: SECRETS.commandLine }) },
],
sessionTokens: null,
}]
const { calls } = decodeOpenCodeSession({ records, context: opencodeContext })
const { sessions } = toOpenCodeSessionObservations(
{ sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls },
{ privacyKey: 'test-privacy-key', provider: 'opencode' },
)
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) 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).not.toContain(SECRETS.commandLine)
})
})

View file

@ -0,0 +1,636 @@
import { describe, expect, it } from 'vitest'
import { decodeOpenCodeSession, toObservations } from '../../src/providers/opencode-session/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 {
FileMessageData,
MessageData,
OpenCodeSessionEnvelope,
PartData,
} from '../../src/providers/opencode-session/types.js'
const context: DecodeContext = { privacyKey: 'k', providerId: 'opencode', sourceRef: 'ref' }
function sqliteEnvelope(overrides: Partial<Extract<OpenCodeSessionEnvelope, { kind: 'sqlite' }>> = {}): Extract<OpenCodeSessionEnvelope, { kind: 'sqlite' }> {
return {
kind: 'sqlite',
sessionId: 'sess-a',
messages: [],
parts: [],
sessionTokens: null,
...overrides,
}
}
function fileEnvelope(overrides: Partial<Extract<OpenCodeSessionEnvelope, { kind: 'file' }>> = {}): Extract<OpenCodeSessionEnvelope, { kind: 'file' }> {
return {
kind: 'file',
sessionId: 'sess-a',
messages: [],
partsRawByMessageId: new Map(),
metaTimeCreatedMs: undefined,
...overrides,
}
}
function msg(data: MessageData, overrides: Partial<{ id: string; session_id: string; time_created: number }> = {}): { session_id: string; id: string; time_created: number; data: string } {
return {
session_id: overrides.session_id ?? 'sess-a',
id: overrides.id ?? 'msg-1',
time_created: overrides.time_created ?? 1700000001000,
data: JSON.stringify(data),
}
}
function part(messageId: string, data: PartData): { message_id: string; data: string } {
return { message_id: messageId, data: JSON.stringify(data) }
}
// ── Shared builder arms ─────────────────────────────────────────────────────
describe('opencode-session rich decode: shared builder', () => {
it('H1-H3: normalizes tokens, usage fallback, and fills both cacheReadInputTokens and cachedInputTokens', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'user' }, { id: 'msg-user' }),
msg({
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.05,
tokens: { input: 100, output: 200, reasoning: 50, cache: { read: 500, write: 300 } },
}),
],
parts: [
part('msg-user', { type: 'text', text: 'hello' }),
],
})],
context,
})
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
inputTokens: 100,
outputTokens: 200,
reasoningTokens: 50,
cacheReadInputTokens: 500,
cacheCreationInputTokens: 300,
cachedInputTokens: 500,
fallbackCostUSD: 0.05,
})
expect(calls[0]!.cacheReadInputTokens).toBe(calls[0]!.cachedInputTokens)
})
it('H2: tokens.* wins over usage.*; usage.* fills null/undefined tokens', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'user' }, { id: 'msg-user' }),
msg({
role: 'assistant',
cost: 0.05,
tokens: { input: 10, output: 20, cache: { read: 50, write: 30 } },
usage: { input_tokens: 999, output_tokens: 888, cache_creation_input_tokens: 777, cache_read_input_tokens: 666 },
}),
],
parts: [],
})],
context,
})
expect(calls[0]).toMatchObject({
inputTokens: 10,
outputTokens: 20,
cacheReadInputTokens: 50,
cacheCreationInputTokens: 30,
})
})
it('H4: counts tool/tool-call/tool_call; tool-result/tool_result are activity but not tools', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 } }),
],
parts: [
part('msg-1', { type: 'tool', tool: 'bash', state: { input: { command: 'ls' } } }),
part('msg-1', { type: 'tool-call', tool: 'read' }),
part('msg-1', { type: 'tool_call', tool: 'grep' }),
part('msg-1', { type: 'tool-result', tool: 'bash' }),
part('msg-1', { type: 'tool_result', tool: 'bash' }),
],
})],
context,
})
expect(calls[0]!.tools).toEqual(['Bash', 'Read', 'Grep'])
expect(calls[0]!.rawBashCommands).toEqual(['ls'])
})
it('H5: normalizeToolName builtins, mcp__ passthrough, server_tool conversion, underscore edge cases', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 } }),
],
parts: [
part('msg-1', { type: 'tool', tool: 'bash' }),
part('msg-1', { type: 'tool', tool: 'mcp__x__y' }),
part('msg-1', { type: 'tool', tool: 'server_tool' }),
part('msg-1', { type: 'tool', tool: '_leading' }),
part('msg-1', { type: 'tool', tool: 'trailing_' }),
],
})],
context,
})
expect(calls[0]!.tools).toEqual(['Bash', 'mcp__x__y', 'mcp__server__tool', '_leading', 'trailing_'])
})
it('H6-H8: extracts rawBashCommands, skills, and subagentTypes', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 } }),
],
parts: [
part('msg-1', { type: 'tool', tool: 'bash', state: { input: { command: 'npm test' } } }),
part('msg-1', { type: 'tool', tool: 'skill', state: { input: { name: 'commit' } } }),
part('msg-1', { type: 'tool', tool: 'task', state: { input: { subagent_type: 'general-purpose' } } }),
],
})],
context,
})
expect(calls[0]!.rawBashCommands).toEqual(['npm test'])
expect(calls[0]!.skills).toEqual(['commit'])
expect(calls[0]!.subagentTypes).toEqual(['general-purpose'])
})
it('H9: skills and subagentTypes are present as empty arrays when absent', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 } })],
parts: [],
})],
context,
})
expect(calls[0]!.skills).toEqual([])
expect(calls[0]!.subagentTypes).toEqual([])
})
it('H10: skips all-zero tokens with falsy cost and no substantive parts', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'assistant', tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } })],
parts: [],
})],
context,
})
expect(calls).toEqual([])
})
it('H11: yields zero-token assistant with non-empty text', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'user' }, { id: 'msg-user' }),
msg({ role: 'assistant', tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, { id: 'msg-2' }),
],
parts: [
part('msg-user', { type: 'text', text: 'prompt' }),
part('msg-2', { type: 'text', text: 'I will help' }),
],
})],
context,
})
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(0)
})
it('H12: yields zero-token assistant with a tool-call part', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'user' }, { id: 'msg-user' }),
msg({ role: 'assistant', tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, { id: 'msg-2' }),
],
parts: [
part('msg-user', { type: 'text', text: 'prompt' }),
part('msg-2', { type: 'tool', tool: 'bash', state: { input: { command: 'ls' } } }),
],
})],
context,
})
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toEqual(['Bash'])
})
it('H13: yields zero-token assistant with reasoning or file part', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'assistant', tokens: { input: 0, output: 0 } }, { id: 'msg-2' }),
msg({ role: 'assistant', tokens: { input: 0, output: 0 } }, { id: 'msg-3' }),
],
parts: [
part('msg-2', { type: 'reasoning' }),
part('msg-3', { type: 'file' }),
],
})],
context,
})
expect(calls).toHaveLength(2)
})
it('H14: yields all-zero tokens when cost > 0', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'assistant', cost: 0.01, tokens: { input: 0, output: 0 } })],
parts: [],
})],
context,
})
expect(calls).toHaveLength(1)
expect(calls[0]!.fallbackCostUSD).toBe(0.01)
})
it('H15: fallbackCostUSD present for cost 0, absent when cost undefined', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'assistant', cost: 0, tokens: { input: 10, output: 10 } }, { id: 'msg-2' }),
msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { id: 'msg-3' }),
],
parts: [],
})],
context,
})
expect(calls[0]!).toHaveProperty('fallbackCostUSD', 0)
expect(calls[1]!).not.toHaveProperty('fallbackCostUSD')
})
it('H16: model precedence modelID -> model -> unknown', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'assistant', tokens: { input: 10, output: 10 }, model: 'from-model' }, { id: 'msg-2' }),
msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { id: 'msg-3' }),
],
parts: [],
})],
context,
})
expect(calls[0]!.model).toBe('from-model')
expect(calls[1]!.model).toBe('unknown')
})
it('H17: parseTimestamp treats <1e12 as seconds and >=1e12 as ms', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { time_created: 1700000000 })],
parts: [],
})],
context,
})
expect(calls[0]!.timestamp).toBe(new Date(1700000000 * 1000).toISOString())
})
it('providerName comes from context.providerId (opencode vs kilo-code)', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'assistant', tokens: { input: 10, output: 10 } })],
parts: [],
})],
context: { ...context, providerId: 'kilo-code' },
})
expect(calls[0]!.provider).toBe('kilo-code')
expect(calls[0]!.deduplicationKey).toBe('kilo-code:sess-a:msg-1')
})
})
// ── SQLite-only arms ────────────────────────────────────────────────────────
describe('opencode-session rich decode: SQLite arm', () => {
it('S1: child and grandchild calls are attributed to the root sessionId', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
sessionId: 'root',
messages: [
msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { id: 'm-child', session_id: 'child' }),
msg({ role: 'assistant', tokens: { input: 20, output: 20 } }, { id: 'm-grandchild', session_id: 'grandchild' }),
],
parts: [],
})],
context,
})
expect(calls).toHaveLength(2)
expect(calls[0]!.sessionId).toBe('root')
expect(calls[1]!.sessionId).toBe('root')
})
it('S3: corrupt message data is counted as malformed-json diagnostic', () => {
const { calls, diagnostics } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
{ session_id: 'sess-a', id: 'msg-bad', time_created: 1700000000000, data: 'not-json' },
msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { id: 'msg-good' }),
],
parts: [],
})],
context,
})
expect(calls).toHaveLength(1)
expect(diagnostics).toEqual([{ index: 0, code: 'malformed-json' }])
})
it('S4: corrupt part data is skipped silently', () => {
const { calls, diagnostics } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'assistant', tokens: { input: 10, output: 10 } })],
parts: [
{ message_id: 'msg-1', data: 'not-json' },
part('msg-1', { type: 'tool', tool: 'read' }),
],
})],
context,
})
expect(calls).toHaveLength(1)
expect(diagnostics).toEqual([])
})
it('S5: user messages are accumulated per msg.session_id and joined with space', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'user' }, { id: 'msg-user', session_id: 'sess-a' }),
msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { id: 'msg-2', session_id: 'sess-a' }),
],
parts: [
part('msg-user', { type: 'text', text: 'first' }),
part('msg-user', { type: 'text', text: 'second' }),
],
})],
context,
})
expect(calls[0]!.userMessage).toBe('first second')
})
it('S6-S7: model role accepted, other roles counted as unknown-shape', () => {
const { calls, diagnostics } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'model', tokens: { input: 10, output: 10 } }, { id: 'msg-model' }),
msg({ role: 'system' }, { id: 'msg-system' }),
],
parts: [],
})],
context,
})
expect(calls).toHaveLength(1)
expect(diagnostics).toEqual([{ index: 0, code: 'unknown-shape' }])
})
// Opposite of the vscode-cline arm, which burns the dedup key BEFORE deciding
// to skip. Here a null build must leave the key unclaimed, so the second
// message sharing that key still yields. Asserting only `seen.has(...)` would
// hold under either ordering — the call count is what discriminates.
it('S8: dedup key added only after successful build', () => {
const seen = new Set<string>()
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [
msg({ role: 'assistant', tokens: { input: 0, output: 0 } }, { id: 'msg-skip' }),
msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { id: 'msg-skip' }),
],
parts: [],
})],
context,
seenKeys: seen,
})
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(10)
expect(calls[0]!.deduplicationKey).toBe('opencode:sess-a:msg-skip')
expect(seen.has('opencode:sess-a:msg-skip')).toBe(true)
})
it('S9-S11: session-level fallback fires with correct shape and cost guard', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'user' }, { id: 'msg-user' })],
parts: [part('msg-user', { type: 'text', text: 'hello' })],
sessionTokens: { cost: 1, input: 100, output: 50, reasoning: 5, cacheRead: 10, cacheWrite: 20, model: 'session-model' },
})],
context,
})
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
arm: 'session-level',
inputTokens: 100,
outputTokens: 50,
reasoningTokens: 5,
cacheReadInputTokens: 10,
cacheCreationInputTokens: 20,
cachedInputTokens: 10,
fallbackCostUSD: 1,
deduplicationKey: 'opencode:sess-a:session-level',
model: 'session-model',
tools: [],
rawBashCommands: [],
userMessage: '',
})
})
it('S10: session-level fallback omitted when session row is all zeros', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'user' }, { id: 'msg-user' })],
parts: [],
sessionTokens: { cost: 0, input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, model: undefined },
})],
context,
})
expect(calls).toEqual([])
})
it('S11: session-level fallback omits fallbackCostUSD when cost is 0', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'user' }, { id: 'msg-user' })],
parts: [],
sessionTokens: { cost: 0, input: 100, output: 50, reasoning: 0, cacheRead: 0, cacheWrite: 0, model: undefined },
})],
context,
})
expect(calls[0]!).not.toHaveProperty('fallbackCostUSD')
})
it('S12: session with only user messages yields nothing', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'user' }, { id: 'msg-user' })],
parts: [part('msg-user', { type: 'text', text: 'hello' })],
sessionTokens: null,
})],
context,
})
expect(calls).toEqual([])
})
})
// ── File arm ────────────────────────────────────────────────────────────────
describe('opencode-session rich decode: file arm', () => {
it('F1-F3: message ordering, id fallback, and sorted parts', async () => {
const messages: Array<{ id: string; data: FileMessageData }> = [
{ id: 'msg_b', data: { role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 }, time: { created: 2 } } },
{ id: 'msg_a', data: { role: 'assistant', cost: 0.05, tokens: { input: 20, output: 20 }, time: { created: 2 } } },
{ id: 'msg_c', data: { role: 'assistant', cost: 0.05, tokens: { input: 30, output: 30 }, time: { created: 3 } } },
]
const parts = new Map<string, string[]>([
['msg_b', [JSON.stringify({ type: 'text', text: 'b-first' }), JSON.stringify({ type: 'text', text: 'b-second' })]],
['msg_a', [JSON.stringify({ type: 'text', text: 'a-wins-tie' })]],
['msg_c', [JSON.stringify({ type: 'text', text: 'c-latest' })]],
])
const { calls } = decodeOpenCodeSession({
records: [fileEnvelope({ messages, partsRawByMessageId: parts })],
context,
})
expect(calls.map(c => c.deduplicationKey)).toEqual([
'opencode:sess-a:msg_a',
'opencode:sess-a:msg_b',
'opencode:sess-a:msg_c',
])
})
it('F2: message id falls back to filename minus .json', () => {
const messages: Array<{ id: string; data: FileMessageData }> = [
{ id: 'from_filename', data: { role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 }, time: { created: 1 } } },
]
const { calls } = decodeOpenCodeSession({
records: [fileEnvelope({ messages, partsRawByMessageId: new Map([['from_filename', [JSON.stringify({ type: 'text', text: 'ok' })]]]) })],
context,
})
expect(calls[0]!.deduplicationKey).toBe('opencode:sess-a:from_filename')
})
it('F4: unparseable part file is skipped', () => {
const messages: Array<{ id: string; data: FileMessageData }> = [
{ id: 'msg_a', data: { role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 }, time: { created: 1 } } },
]
const { calls } = decodeOpenCodeSession({
records: [fileEnvelope({
messages,
partsRawByMessageId: new Map([['msg_a', ['not-json', JSON.stringify({ type: 'text', text: 'ok' })]]]),
})],
context,
})
expect(calls).toHaveLength(1)
})
it('F5: timeCreatedMs precedence data.time.created -> meta.time.created -> 0', () => {
const messages: Array<{ id: string; data: FileMessageData }> = [
{ id: 'msg_a', data: { role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 } } },
]
const { calls } = decodeOpenCodeSession({
records: [fileEnvelope({
messages,
partsRawByMessageId: new Map([['msg_a', [JSON.stringify({ type: 'text', text: 'ok' })]]]),
metaTimeCreatedMs: 1781886356809,
})],
context,
})
expect(calls[0]!.timestamp).toBe(new Date(1781886356809).toISOString())
})
it('F8: currentUserMessage only overwritten by non-empty joined text', () => {
const messages: Array<{ id: string; data: FileMessageData }> = [
{ id: 'msg_user1', data: { role: 'user', time: { created: 1 } } },
{ id: 'msg_user2_empty', data: { role: 'user', time: { created: 2 } } },
{ id: 'msg_assistant', data: { role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 }, time: { created: 3 } } },
]
const { calls } = decodeOpenCodeSession({
records: [fileEnvelope({
messages,
partsRawByMessageId: new Map([
['msg_user1', [JSON.stringify({ type: 'text', text: 'first prompt' })]],
['msg_user2_empty', [JSON.stringify({ type: 'text', text: '' })]],
['msg_assistant', [JSON.stringify({ type: 'text', text: 'reply' })]]
]),
})],
context,
})
expect(calls[0]!.userMessage).toBe('first prompt')
})
// Both messages resolve to the same id, so they share a dedup key. The first
// must build to null — no parts, or it would gain activity from the shared
// part list and yield instead — leaving the key unclaimed for the second.
it('F9: dedup adds only after successful build', () => {
const seen = new Set<string>()
const { calls } = decodeOpenCodeSession({
records: [fileEnvelope({
messages: [
{ id: 'msg_skip', data: { role: 'assistant', tokens: { input: 0, output: 0 }, time: { created: 1 } } },
{ id: 'msg_skip', data: { role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 }, time: { created: 2 } } },
],
partsRawByMessageId: new Map(),
})],
context,
seenKeys: seen,
})
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(10)
expect(calls[0]!.deduplicationKey).toBe('opencode:sess-a:msg_skip')
expect(seen.has('opencode:sess-a:msg_skip')).toBe(true)
})
})
// ── Cross-cutting ───────────────────────────────────────────────────────────
describe('opencode-session rich decode: cross-cutting', () => {
it('cross-run dedup drops a repeated session id', () => {
const seen = new Set<string>()
const first = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'assistant', tokens: { input: 10, output: 10 } })],
parts: [],
})],
context,
seenKeys: seen,
})
expect(first.calls).toHaveLength(1)
const again = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'assistant', tokens: { input: 10, output: 10 } })],
parts: [],
})],
context,
seenKeys: seen,
})
expect(again.calls).toEqual([])
})
it('Map partsRawByMessageId does not return prototype members for constructor/__proto__ keys', () => {
const parts = new Map<string, string[]>()
expect(parts.get('constructor')).toBeUndefined()
expect(parts.get('__proto__')).toBeUndefined()
})
it('toObservations produces a schema-valid envelope', () => {
const { calls } = decodeOpenCodeSession({
records: [sqliteEnvelope({
messages: [msg({ role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 } })],
parts: [],
})],
context,
})
const { sessions } = toObservations(
{ sessionId: 'sess-a', projectPath: '/Users/t/alpha', calls },
{ privacyKey: 'test-privacy-key', provider: 'opencode' },
)
const envelope = {
schemaVersion: OBSERVATION_SCHEMA_VERSION,
generator: { name: '@codeburn/core', version: '0.0.0-test' },
sessions,
}
expect(ObservationEnvelope.safeParse(envelope).success).toBe(true)
})
})

View file

@ -39,6 +39,7 @@ export default defineConfig({
'src/providers/devin/index.ts',
'src/providers/copilot/index.ts',
'src/providers/vscode-cline/index.ts',
'src/providers/opencode-session/index.ts',
],
format: ['esm'],
target: 'node20',