mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-02 21:04:57 +00:00
Merge pull request #822 from getagentseal/phase3/claude-carveout
refactor(core): carve Claude decode into @codeburn/core, CLI parser becomes adapter (phase 3)
This commit is contained in:
commit
698a58cfdc
13 changed files with 2144 additions and 1557 deletions
|
|
@ -1,5 +1,11 @@
|
|||
import { BASH_TOOLS, EDIT_TOOLS } from '@codeburn/core/providers/claude'
|
||||
|
||||
import type { ClassifiedTurn, ParsedTurn, TaskCategory, ToolCall } from './types.js'
|
||||
|
||||
// Re-exported so existing CLI consumers keep importing the tool-name vocab from
|
||||
// './classifier.js'; the canonical definitions live in @codeburn/core.
|
||||
export { BASH_TOOLS, EDIT_TOOLS }
|
||||
|
||||
const TEST_PATTERNS = /\b(test|pytest|vitest|jest|mocha|spec|coverage|npm\s+test|npx\s+vitest|npx\s+jest)\b/i
|
||||
const GIT_PATTERNS = /\bgit\s+(push|pull|commit|merge|rebase|checkout|branch|stash|log|diff|status|add|reset|cherry-pick|tag)\b/i
|
||||
const BUILD_PATTERNS = /\b(npm\s+run\s+build|npm\s+publish|pip\s+install|docker|deploy|make\s+build|npm\s+run\s+dev|npm\s+start|pm2|systemctl|brew|cargo\s+build)\b/i
|
||||
|
|
@ -15,9 +21,7 @@ const FILE_PATTERNS = /\.(py|js|ts|tsx|jsx|json|yaml|yml|toml|sql|sh|go|rs|java|
|
|||
const SCRIPT_PATTERNS = /\b(run\s+\S+\.\w+|execute|scrip?t|curl|api\s+\S+|endpoint|request\s+url|fetch\s+\S+|query|database|db\s+\S+)\b/i
|
||||
const URL_PATTERN = /https?:\/\/\S+/i
|
||||
|
||||
export const EDIT_TOOLS = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit', 'cursor:edit'])
|
||||
const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool'])
|
||||
export const BASH_TOOLS = new Set(['Bash', 'BashTool', 'PowerShellTool'])
|
||||
const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TodoWrite'])
|
||||
const SEARCH_TOOLS = new Set(['WebSearch', 'WebFetch', 'ToolSearch'])
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,89 +1,18 @@
|
|||
export type TokenUsage = {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationInputTokens: number
|
||||
cacheReadInputTokens: number
|
||||
cachedInputTokens: number
|
||||
reasoningTokens: number
|
||||
webSearchRequests: number
|
||||
}
|
||||
// Raw Claude record types + shared token/tool shapes now live in @codeburn/core
|
||||
// (the decode layer owns them). Re-exported here so every CLI consumer keeps
|
||||
// importing them from './types.js' unchanged.
|
||||
export type {
|
||||
TokenUsage,
|
||||
ToolUseBlock,
|
||||
ContentBlock,
|
||||
ApiUsage,
|
||||
ApiUsageIteration,
|
||||
AssistantMessageContent,
|
||||
JournalEntry,
|
||||
ToolCall,
|
||||
} from '@codeburn/core/providers/claude'
|
||||
|
||||
export type ToolUseBlock = {
|
||||
type: 'tool_use'
|
||||
id: string
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ContentBlock =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'thinking'; thinking: string }
|
||||
| ToolUseBlock
|
||||
| { type: string; [key: string]: unknown }
|
||||
|
||||
export type ApiUsage = {
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_creation?: {
|
||||
ephemeral_5m_input_tokens?: number
|
||||
ephemeral_1h_input_tokens?: number
|
||||
}
|
||||
cache_read_input_tokens?: number
|
||||
server_tool_use?: {
|
||||
web_search_requests?: number
|
||||
web_fetch_requests?: number
|
||||
}
|
||||
speed?: 'standard' | 'fast'
|
||||
// Claude Code advisor tool (/advisor): per-turn sub-usage records. A record
|
||||
// with type 'advisor_message' carries the advisor model's own tokens and is
|
||||
// NOT included in the top-level totals above; type 'message' records mirror
|
||||
// the main model and are already covered by the top-level totals.
|
||||
iterations?: ApiUsageIteration[]
|
||||
}
|
||||
|
||||
export type ApiUsageIteration = {
|
||||
type?: string
|
||||
model?: string
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_creation?: {
|
||||
ephemeral_5m_input_tokens?: number
|
||||
ephemeral_1h_input_tokens?: number
|
||||
}
|
||||
cache_read_input_tokens?: number
|
||||
server_tool_use?: {
|
||||
web_search_requests?: number
|
||||
web_fetch_requests?: number
|
||||
}
|
||||
speed?: 'standard' | 'fast'
|
||||
}
|
||||
|
||||
export type AssistantMessageContent = {
|
||||
model: string
|
||||
id?: string
|
||||
type: 'message'
|
||||
role: 'assistant'
|
||||
content: ContentBlock[]
|
||||
usage: ApiUsage
|
||||
stop_reason?: string
|
||||
}
|
||||
|
||||
export type JournalEntry = {
|
||||
type: string
|
||||
uuid?: string
|
||||
parentUuid?: string | null
|
||||
timestamp?: string
|
||||
sessionId?: string
|
||||
cwd?: string
|
||||
version?: string
|
||||
gitBranch?: string
|
||||
promptId?: string
|
||||
message?: AssistantMessageContent | { role: 'user'; content: string | ContentBlock[] }
|
||||
isSidechain?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
import type { TokenUsage, ToolCall } from '@codeburn/core/providers/claude'
|
||||
|
||||
export type ParsedTurn = {
|
||||
userMessage: string
|
||||
|
|
@ -155,12 +84,6 @@ export type ParsedApiCall = {
|
|||
toolErrors?: number
|
||||
}
|
||||
|
||||
export type ToolCall = {
|
||||
tool: string
|
||||
file?: string
|
||||
command?: string
|
||||
}
|
||||
|
||||
export type TaskCategory =
|
||||
| 'coding'
|
||||
| 'debugging'
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@
|
|||
"./contracts": {
|
||||
"types": "./dist/contracts.d.ts",
|
||||
"import": "./dist/contracts.js"
|
||||
},
|
||||
"./providers/claude": {
|
||||
"types": "./dist/providers/claude/index.d.ts",
|
||||
"import": "./dist/providers/claude/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
709
packages/core/src/providers/claude/decode.ts
Normal file
709
packages/core/src/providers/claude/decode.ts
Normal file
|
|
@ -0,0 +1,709 @@
|
|||
// Claude rich decode: pure over supplied JSONL records/lines. No fs, no env, no
|
||||
// clock, no pricing. May carry content in-memory (user text, tool names) — this
|
||||
// is the host-facing rich layer, NOT the minimized observation surface. The CLI
|
||||
// prices and maps this output into its own ParsedApiCall / ParsedTurn shapes;
|
||||
// `toObservations` (sibling module) is the minimizing transform.
|
||||
|
||||
import { BASH_TOOLS, EDIT_TOOLS } from './tool-vocab.js'
|
||||
import {
|
||||
BASH_COMMAND_CAP,
|
||||
LARGE_JSONL_LINE_BYTES,
|
||||
MAX_ADDED_NAMES,
|
||||
MAX_TOOL_BLOCKS,
|
||||
RAW_HEAD_BYTES,
|
||||
USER_TEXT_CAP,
|
||||
getTopLevelRawJsonStringField,
|
||||
parseLargeJsonl,
|
||||
} from './scanner.js'
|
||||
import type {
|
||||
ApiUsageIteration,
|
||||
AssistantMessageContent,
|
||||
ContentBlock,
|
||||
DecodedCall,
|
||||
DecodedTurn,
|
||||
JournalEntry,
|
||||
SessionMeta,
|
||||
TokenUsage,
|
||||
ToolCall,
|
||||
ToolResultMeta,
|
||||
ToolUseBlock,
|
||||
} from './types.js'
|
||||
|
||||
export function parseJsonlLine(line: string | Buffer): JournalEntry | null {
|
||||
if (Buffer.isBuffer(line)) {
|
||||
if (line.length > LARGE_JSONL_LINE_BYTES) return parseLargeJsonl(line)
|
||||
try {
|
||||
return JSON.parse(line.toString('utf-8')) as JournalEntry
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (line.length > LARGE_JSONL_LINE_BYTES) return parseLargeJsonl(line)
|
||||
try {
|
||||
return JSON.parse(line) as JournalEntry
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a message's `content` into an array of content blocks. Defensive
|
||||
/// against a `content` that is a plain string (some agents write it that way);
|
||||
/// a raw string reaching `.filter`/`.some` would throw mid-parse.
|
||||
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 []
|
||||
}
|
||||
|
||||
export function shouldSkipLine(line: string, threshold: string): boolean {
|
||||
const head = line.length > RAW_HEAD_BYTES ? line.slice(0, RAW_HEAD_BYTES) : line
|
||||
const type = getTopLevelRawJsonStringField(head, 'type')
|
||||
if (type !== 'user' && type !== 'assistant') return false
|
||||
const ts = getTopLevelRawJsonStringField(head, 'timestamp')
|
||||
if (!ts || ts.length < 10) return false
|
||||
return ts < threshold
|
||||
}
|
||||
|
||||
export function compactEntry(raw: JournalEntry): JournalEntry {
|
||||
const entry: JournalEntry = { type: raw.type }
|
||||
|
||||
if (raw.timestamp !== undefined) entry.timestamp = raw.timestamp
|
||||
if (raw.sessionId !== undefined) entry.sessionId = raw.sessionId
|
||||
if (raw.cwd !== undefined) entry.cwd = raw.cwd
|
||||
// Preserved so groupIntoTurns can stamp each turn's git branch (rich capture).
|
||||
if (typeof raw.gitBranch === 'string' && raw.gitBranch) entry.gitBranch = raw.gitBranch
|
||||
// Preserved so groupIntoTurns can attribute each PR reference to its turn.
|
||||
// Only `pr-link` entries carry `prUrl`; every other field of theirs is dropped.
|
||||
if (raw.type === 'pr-link') {
|
||||
const prUrl = (raw as Record<string, unknown>)['prUrl']
|
||||
if (typeof prUrl === 'string' && prUrl) (entry as Record<string, unknown>)['prUrl'] = prUrl
|
||||
}
|
||||
|
||||
const att = (raw as Record<string, unknown>)['attachment']
|
||||
if (att && typeof att === 'object') {
|
||||
const a = att as Record<string, unknown>
|
||||
if (a['type'] === 'deferred_tools_delta' && Array.isArray(a['addedNames'])) {
|
||||
const names: string[] = []
|
||||
for (let i = 0; i < Math.min(a['addedNames'].length, MAX_ADDED_NAMES); i++) {
|
||||
const n = a['addedNames'][i]
|
||||
if (typeof n === 'string') names.push(n)
|
||||
}
|
||||
;(entry as Record<string, unknown>)['attachment'] = { type: 'deferred_tools_delta', addedNames: names }
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw.message) return entry
|
||||
|
||||
if (raw.message.role === 'user') {
|
||||
const content = raw.message.content
|
||||
if (typeof content === 'string') {
|
||||
entry.message = { role: 'user', content: content.slice(0, USER_TEXT_CAP) }
|
||||
} else if (Array.isArray(content)) {
|
||||
let remaining = USER_TEXT_CAP
|
||||
const blocks: { type: 'text'; text: string }[] = []
|
||||
for (const b of content) {
|
||||
if (remaining <= 0) break
|
||||
if (!b || typeof b !== 'object' || b.type !== 'text') continue
|
||||
const text = (b as { text?: unknown }).text
|
||||
if (typeof text !== 'string') continue
|
||||
const sliced = text.slice(0, remaining)
|
||||
blocks.push({ type: 'text', text: sliced })
|
||||
remaining -= sliced.length
|
||||
}
|
||||
entry.message = { role: 'user', content: blocks }
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
const msg = raw.message as AssistantMessageContent
|
||||
if (!msg.usage || !msg.model) return entry
|
||||
|
||||
const rawContent = msg.content
|
||||
const contentArr = Array.isArray(rawContent) ? rawContent : []
|
||||
const toolBlocks = contentArr.filter((b): b is ToolUseBlock => b != null && typeof b === 'object' && b.type === 'tool_use')
|
||||
const compactContent: ContentBlock[] = toolBlocks.slice(0, MAX_TOOL_BLOCKS).map(tb => {
|
||||
let input: Record<string, unknown> = {}
|
||||
if (tb.name === 'Skill') {
|
||||
const ri = (tb.input ?? {}) as Record<string, unknown>
|
||||
if (typeof ri['skill'] === 'string') input['skill'] = (ri['skill'] as string).slice(0, 200)
|
||||
if (typeof ri['name'] === 'string') input['name'] = (ri['name'] as string).slice(0, 200)
|
||||
} else if (tb.name === 'Read' || tb.name === 'FileReadTool' || EDIT_TOOLS.has(tb.name)) {
|
||||
const ri = (tb.input ?? {}) as Record<string, unknown>
|
||||
if (typeof ri['file_path'] === 'string') input['file_path'] = (ri['file_path'] as string).slice(0, BASH_COMMAND_CAP)
|
||||
} else if (tb.name === 'Agent' || tb.name === 'Task') {
|
||||
const ri = (tb.input ?? {}) as Record<string, unknown>
|
||||
if (typeof ri['subagent_type'] === 'string') input['subagent_type'] = (ri['subagent_type'] as string).slice(0, 200)
|
||||
} else if (BASH_TOOLS.has(tb.name)) {
|
||||
const ri = (tb.input ?? {}) as Record<string, unknown>
|
||||
if (typeof ri['command'] === 'string') {
|
||||
input['command'] = (ri['command'] as string).slice(0, BASH_COMMAND_CAP)
|
||||
}
|
||||
}
|
||||
return { type: 'tool_use' as const, id: tb.id ?? '', name: tb.name, input }
|
||||
})
|
||||
|
||||
const u = msg.usage
|
||||
const compactUsage: AssistantMessageContent['usage'] = {
|
||||
input_tokens: u.input_tokens,
|
||||
output_tokens: u.output_tokens,
|
||||
}
|
||||
if (u.cache_creation_input_tokens) compactUsage.cache_creation_input_tokens = u.cache_creation_input_tokens
|
||||
if (u.cache_creation) {
|
||||
compactUsage.cache_creation = {
|
||||
...(u.cache_creation.ephemeral_5m_input_tokens ? { ephemeral_5m_input_tokens: u.cache_creation.ephemeral_5m_input_tokens } : {}),
|
||||
...(u.cache_creation.ephemeral_1h_input_tokens ? { ephemeral_1h_input_tokens: u.cache_creation.ephemeral_1h_input_tokens } : {}),
|
||||
}
|
||||
}
|
||||
if (u.cache_read_input_tokens) compactUsage.cache_read_input_tokens = u.cache_read_input_tokens
|
||||
if (u.server_tool_use) {
|
||||
compactUsage.server_tool_use = {
|
||||
...(u.server_tool_use.web_search_requests ? { web_search_requests: u.server_tool_use.web_search_requests } : {}),
|
||||
...(u.server_tool_use.web_fetch_requests ? { web_fetch_requests: u.server_tool_use.web_fetch_requests } : {}),
|
||||
}
|
||||
}
|
||||
if (u.speed) compactUsage.speed = u.speed
|
||||
// Preserve only advisor_message iterations (/advisor sub-usage) so
|
||||
// parseAdvisorCalls can attribute the advisor model's spend; drop the rest to
|
||||
// keep the cache small. Other iteration types (plain `message`, and the
|
||||
// `fallback_message` written when a turn retries on another model) are not
|
||||
// accounted here, a separate pre-existing gap, so they are not preserved.
|
||||
if (Array.isArray(u.iterations)) {
|
||||
const advisorIterations = u.iterations
|
||||
.filter((it): it is ApiUsageIteration => !!it && it.type === 'advisor_message')
|
||||
.map(it => {
|
||||
const compact: ApiUsageIteration = { type: 'advisor_message' }
|
||||
if (typeof it.model === 'string') compact.model = it.model
|
||||
if (it.input_tokens) compact.input_tokens = it.input_tokens
|
||||
if (it.output_tokens) compact.output_tokens = it.output_tokens
|
||||
if (it.cache_creation_input_tokens) compact.cache_creation_input_tokens = it.cache_creation_input_tokens
|
||||
if (it.cache_read_input_tokens) compact.cache_read_input_tokens = it.cache_read_input_tokens
|
||||
if (it.cache_creation) {
|
||||
compact.cache_creation = {
|
||||
...(it.cache_creation.ephemeral_5m_input_tokens ? { ephemeral_5m_input_tokens: it.cache_creation.ephemeral_5m_input_tokens } : {}),
|
||||
...(it.cache_creation.ephemeral_1h_input_tokens ? { ephemeral_1h_input_tokens: it.cache_creation.ephemeral_1h_input_tokens } : {}),
|
||||
}
|
||||
}
|
||||
if (it.server_tool_use?.web_search_requests) compact.server_tool_use = { web_search_requests: it.server_tool_use.web_search_requests }
|
||||
if (it.speed) compact.speed = it.speed
|
||||
return compact
|
||||
})
|
||||
if (advisorIterations.length > 0) compactUsage.iterations = advisorIterations
|
||||
}
|
||||
|
||||
entry.message = {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: msg.model,
|
||||
usage: compactUsage,
|
||||
content: compactContent,
|
||||
...(msg.id ? { id: msg.id } : {}),
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
function extractToolNames(content: ContentBlock[]): string[] {
|
||||
return content
|
||||
.filter((b): b is ToolUseBlock => b.type === 'tool_use')
|
||||
.map(b => b.name)
|
||||
}
|
||||
|
||||
function extractMcpTools(tools: string[]): string[] {
|
||||
return tools.filter(t => t.startsWith('mcp__'))
|
||||
}
|
||||
|
||||
function extractSkillNames(content: ContentBlock[]): string[] {
|
||||
return content
|
||||
.filter((b): b is ToolUseBlock => b.type === 'tool_use' && b.name === 'Skill')
|
||||
.map(b => {
|
||||
const input = (b.input ?? {}) as Record<string, unknown>
|
||||
const raw = input['skill'] ?? input['name']
|
||||
return typeof raw === 'string' ? raw.trim() : ''
|
||||
})
|
||||
.filter(name => name.length > 0)
|
||||
}
|
||||
|
||||
function extractSubagentTypes(content: ContentBlock[]): string[] {
|
||||
return content
|
||||
.filter((b): b is ToolUseBlock => b.type === 'tool_use' && (b.name === 'Agent' || b.name === 'Task'))
|
||||
.map(b => {
|
||||
const input = (b.input ?? {}) as Record<string, unknown>
|
||||
const raw = input['subagent_type']
|
||||
return typeof raw === 'string' ? raw.trim() : ''
|
||||
})
|
||||
.filter(name => name.length > 0)
|
||||
}
|
||||
|
||||
// Raw `command` strings from this content's bash-family tool_use blocks, in
|
||||
// order. The host splits each into individual commands (its splitter carries a
|
||||
// strip-ansi dependency that must not enter the zod-only core).
|
||||
function extractRawBashCommands(content: ContentBlock[]): string[] {
|
||||
return content
|
||||
.filter((b): b is ToolUseBlock => b.type === 'tool_use' && BASH_TOOLS.has((b as ToolUseBlock).name))
|
||||
.map(b => (b.input as Record<string, unknown>)?.command)
|
||||
.filter((c): c is string => typeof c === 'string')
|
||||
}
|
||||
|
||||
function getUserMessageText(entry: JournalEntry): string {
|
||||
if (!entry.message || entry.message.role !== 'user') return ''
|
||||
const content = entry.message.content
|
||||
if (typeof content === 'string') return content
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
||||
.map(b => b.text)
|
||||
.join(' ')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function getMessageId(entry: JournalEntry): string | null {
|
||||
if (entry.type !== 'assistant') return null
|
||||
const msg = entry.message as AssistantMessageContent | undefined
|
||||
return msg?.id ?? null
|
||||
}
|
||||
|
||||
export function safeNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
|
||||
}
|
||||
|
||||
export function isPositiveNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
}
|
||||
|
||||
function extractClaudeCacheCreation(usage: {
|
||||
cache_creation_input_tokens?: number
|
||||
cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
|
||||
}): { totalTokens: number; oneHourTokens: number } {
|
||||
const legacyTotal = safeNumber(usage.cache_creation_input_tokens)
|
||||
const cacheCreation = usage.cache_creation
|
||||
const fiveMinuteTokens = safeNumber(cacheCreation?.ephemeral_5m_input_tokens)
|
||||
const oneHourTokens = safeNumber(cacheCreation?.ephemeral_1h_input_tokens)
|
||||
const splitTotal = fiveMinuteTokens + oneHourTokens
|
||||
|
||||
if (splitTotal === 0) return { totalTokens: legacyTotal, oneHourTokens: 0 }
|
||||
|
||||
// Valid Claude usage reports the legacy total and split total as equal.
|
||||
// Keep the larger value so malformed partial splits do not drop tokens.
|
||||
const totalTokens = Math.max(legacyTotal, splitTotal)
|
||||
return {
|
||||
totalTokens,
|
||||
oneHourTokens: Math.min(oneHourTokens, totalTokens),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rich Session Capture (Claude) ──────────────────────────────────────
|
||||
|
||||
// Count added/removed lines from a Claude `toolUseResult.structuredPatch`. Each
|
||||
// hunk's `lines` array holds unified-diff content lines: a leading '+' is an
|
||||
// added line, '-' a removed line, ' ' context. Numbers only — patch text is
|
||||
// never stored. Missing/empty/non-array patches count as zero.
|
||||
export function countStructuredPatchLoc(patch: unknown): { added: number; removed: number } {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
if (!Array.isArray(patch)) return { added, removed }
|
||||
for (const hunk of patch) {
|
||||
const lines = (hunk as { lines?: unknown } | null)?.lines
|
||||
if (!Array.isArray(lines)) continue
|
||||
for (const line of lines) {
|
||||
if (typeof line !== 'string') continue
|
||||
if (line.startsWith('+')) added++
|
||||
else if (line.startsWith('-')) removed++
|
||||
}
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
export function emptySessionMeta(): SessionMeta {
|
||||
return { prLinks: [], isSidechain: false, agentSpawnLinks: {}, ambiguousSpawnAgentIds: [] }
|
||||
}
|
||||
|
||||
// Record tool-result metadata from a raw user entry into `map`, keyed by the
|
||||
// tool_result block's tool_use_id. Must run on the RAW entry (before
|
||||
// compactEntry drops toolUseResult / is_error). Large tool-result lines parsed
|
||||
// as buffers lose toolUseResult (the byte scanner does not extract it) — an
|
||||
// accepted gap for oversized outputs.
|
||||
export function collectToolResultMeta(entry: JournalEntry, map: Map<string, ToolResultMeta>): void {
|
||||
if (entry.type !== 'user') return
|
||||
const msg = entry.message
|
||||
const content = msg && typeof msg === 'object' ? (msg as { content?: unknown }).content : undefined
|
||||
if (!Array.isArray(content)) return
|
||||
const tur = (entry as Record<string, unknown>)['toolUseResult']
|
||||
const turObj = tur && typeof tur === 'object' ? tur as Record<string, unknown> : undefined
|
||||
const loc = countStructuredPatchLoc(turObj?.['structuredPatch'])
|
||||
const interrupted = turObj?.['interrupted'] === true
|
||||
const userModified = turObj?.['userModified'] === true
|
||||
for (const b of content) {
|
||||
if (!b || typeof b !== 'object' || (b as { type?: unknown }).type !== 'tool_result') continue
|
||||
const id = (b as { tool_use_id?: unknown }).tool_use_id
|
||||
if (typeof id !== 'string' || !id) continue
|
||||
const isError = (b as { is_error?: unknown }).is_error === true
|
||||
map.set(id, { locAdded: loc.added, locRemoved: loc.removed, interrupted, userModified, isError })
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate session-level metadata from a raw entry. `ai-title` is last-wins
|
||||
// (Claude refines the title over the session); `pr-link` URLs union; any
|
||||
// sidechain entry marks the session.
|
||||
export function collectSessionMeta(entry: JournalEntry, meta: SessionMeta): void {
|
||||
if (entry.type === 'ai-title') {
|
||||
const t = (entry as Record<string, unknown>)['aiTitle']
|
||||
if (typeof t === 'string' && t.trim()) meta.title = t.trim().slice(0, 200)
|
||||
} else if (entry.type === 'pr-link') {
|
||||
const url = (entry as Record<string, unknown>)['prUrl']
|
||||
if (typeof url === 'string' && url && !meta.prLinks.includes(url)) meta.prLinks.push(url)
|
||||
}
|
||||
if (entry.isSidechain === true) {
|
||||
meta.isSidechain = true
|
||||
// A sidechain entry's own `sessionId` is the id of the session that spawned
|
||||
// it (32/32 on real data; cross-checked against the owning directory at
|
||||
// stamp time). First value wins; every entry in the file carries the same id.
|
||||
const sid = (entry as Record<string, unknown>)['sessionId']
|
||||
if (!meta.parentSessionId && typeof sid === 'string' && sid) meta.parentSessionId = sid
|
||||
}
|
||||
// Parent side: the `Agent`/`Task` spawn result records the spawned agent's id in
|
||||
// `toolUseResult.agentId`; pair it with the `tool_result` block's `tool_use_id`
|
||||
// (the spawn's `tool_use` id) so a child can be folded into the launching turn.
|
||||
// Read from the RAW entry (compaction strips `toolUseResult`).
|
||||
const tur = (entry as Record<string, unknown>)['toolUseResult']
|
||||
if (tur && typeof tur === 'object') {
|
||||
const agentId = (tur as Record<string, unknown>)['agentId']
|
||||
if (typeof agentId === 'string' && agentId && !(agentId in meta.agentSpawnLinks)) {
|
||||
const msg = entry.message
|
||||
const content = msg && typeof msg === 'object' ? (msg as { content?: unknown }).content : undefined
|
||||
if (Array.isArray(content)) {
|
||||
const results = content.filter((b): b is Record<string, unknown> =>
|
||||
!!b && typeof b === 'object' && (b as { type?: unknown }).type === 'tool_result'
|
||||
&& typeof (b as { tool_use_id?: unknown }).tool_use_id === 'string' && !!(b as { tool_use_id?: unknown }).tool_use_id)
|
||||
let spawnId: string | undefined
|
||||
if (results.length === 1) {
|
||||
spawnId = results[0]!['tool_use_id'] as string
|
||||
} else if (results.length > 1) {
|
||||
// Several batched tool results share one entry: pair the agentId with the
|
||||
// block whose `content` is the spawn result (equals `toolUseResult.content`),
|
||||
// so an unrelated sibling block cannot capture the id. When the match is
|
||||
// ambiguous (identical blocks, or none match) the spawn link is left
|
||||
// unset ON PURPOSE: the child then folds via the timestamp-bucket fallback
|
||||
// in resolveChild rather than risk pairing with the wrong id.
|
||||
const turContent = JSON.stringify((tur as Record<string, unknown>)['content'])
|
||||
const matches = results.filter(b => JSON.stringify(b['content']) === turContent)
|
||||
if (matches.length === 1) spawnId = matches[0]!['tool_use_id'] as string
|
||||
}
|
||||
if (spawnId) meta.agentSpawnLinks[agentId] = spawnId
|
||||
// We know this parent spawned `agentId` (its result named it) but could not
|
||||
// pair the exact tool_use: record it as an AMBIGUOUS pairing so a late child
|
||||
// can still fold via the grace window. Not the same as an absent spawn.
|
||||
else if (!meta.ambiguousSpawnAgentIds.includes(agentId)) meta.ambiguousSpawnAgentIds.push(agentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decode one assistant message into a cost-free DecodedCall. Returns null for
|
||||
// non-assistant entries or messages missing usage/model. Pricing and bash
|
||||
// splitting are applied by the host.
|
||||
export function decodeAssistantCall(entry: JournalEntry, toolResultMeta?: Map<string, ToolResultMeta>): DecodedCall | null {
|
||||
if (entry.type !== 'assistant') return null
|
||||
const msg = entry.message as AssistantMessageContent | undefined
|
||||
if (!msg?.usage || !msg?.model) return null
|
||||
|
||||
const usage = msg.usage
|
||||
const cacheCreation = extractClaudeCacheCreation(usage)
|
||||
const tokens: TokenUsage = {
|
||||
inputTokens: usage.input_tokens ?? 0,
|
||||
outputTokens: usage.output_tokens ?? 0,
|
||||
cacheCreationInputTokens: cacheCreation.totalTokens,
|
||||
cacheReadInputTokens: usage.cache_read_input_tokens ?? 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: usage.server_tool_use?.web_search_requests ?? 0,
|
||||
}
|
||||
|
||||
// Defensive: a message whose `content` is a string (not an array of blocks)
|
||||
// would crash the helpers below; normalize so one bad record can't abort the
|
||||
// whole backfill (issue #441).
|
||||
const contentBlocks = normalizeContentBlocks(msg.content)
|
||||
const tools = extractToolNames(contentBlocks)
|
||||
const skills = extractSkillNames(contentBlocks)
|
||||
const subagentTypes = extractSubagentTypes(contentBlocks)
|
||||
|
||||
const rawBashCommands = extractRawBashCommands(contentBlocks)
|
||||
|
||||
// Subagent-spawn `tool_use` ids in this message (`Agent`/`Task` blocks). Kept so
|
||||
// groupIntoTurns can attach them to the turn and by-PR attribution can fold each
|
||||
// spawned sidechain back into the turn that launched it.
|
||||
const spawnIds = contentBlocks
|
||||
.filter((b): b is ToolUseBlock => b.type === 'tool_use' && (b.name === 'Agent' || b.name === 'Task') && !!b.id)
|
||||
.map(b => b.id)
|
||||
|
||||
const toolSeq: ToolCall[][] = contentBlocks
|
||||
.filter((b): b is ToolUseBlock => b.type === 'tool_use')
|
||||
.map(b => {
|
||||
const call: ToolCall = { tool: b.name }
|
||||
const inp = (b.input ?? {}) as Record<string, unknown>
|
||||
if (typeof inp['file_path'] === 'string') call.file = inp['file_path'] as string
|
||||
if (typeof inp['command'] === 'string') call.command = inp['command'] as string
|
||||
return [call]
|
||||
})
|
||||
|
||||
// Attribute tool-result metadata (edit LOC, interruptions, errors) to this
|
||||
// call by summing over the tool_use ids it issued. Omitted entirely when no
|
||||
// meta map is supplied (e.g. the guard usage path) or nothing was recorded.
|
||||
let locAdded = 0
|
||||
let locRemoved = 0
|
||||
let toolErrors = 0
|
||||
let interrupted = false
|
||||
let userModified = false
|
||||
if (toolResultMeta && toolResultMeta.size > 0) {
|
||||
for (const b of contentBlocks) {
|
||||
if (b.type !== 'tool_use') continue
|
||||
const m = toolResultMeta.get((b as ToolUseBlock).id)
|
||||
if (!m) continue
|
||||
locAdded += m.locAdded
|
||||
locRemoved += m.locRemoved
|
||||
if (m.isError) toolErrors++
|
||||
if (m.interrupted) interrupted = true
|
||||
if (m.userModified) userModified = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'claude',
|
||||
model: msg.model,
|
||||
usage: tokens,
|
||||
tools,
|
||||
mcpTools: extractMcpTools(tools),
|
||||
skills,
|
||||
subagentTypes,
|
||||
hasAgentSpawn: tools.includes('Agent'),
|
||||
hasPlanMode: tools.includes('EnterPlanMode'),
|
||||
speed: usage.speed ?? 'standard',
|
||||
timestamp: entry.timestamp ?? '',
|
||||
rawBashCommands,
|
||||
deduplicationKey: msg.id ?? `claude:${entry.timestamp}`,
|
||||
cacheCreationOneHourTokens: cacheCreation.oneHourTokens || undefined,
|
||||
toolSequence: toolSeq.length > 0 ? toolSeq : undefined,
|
||||
...(spawnIds.length > 0 ? { spawnToolUseIds: spawnIds } : {}),
|
||||
...(locAdded ? { locAdded } : {}),
|
||||
...(locRemoved ? { locRemoved } : {}),
|
||||
...(interrupted ? { interrupted: true } : {}),
|
||||
...(userModified ? { userModified: true } : {}),
|
||||
...(toolErrors ? { toolErrors } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude Code's advisor tool (/advisor) escalates hard decisions to a stronger
|
||||
/// advisor model mid-turn. Those tokens are recorded as `advisor_message`
|
||||
/// records inside `message.usage.iterations` under the advisor's own model, and
|
||||
/// are excluded from the top-level `message.usage` totals that decodeAssistantCall
|
||||
/// reads. Emit them as separate calls so the advisor's spend is counted and
|
||||
/// attributed to the advisor model rather than silently dropped.
|
||||
export function decodeAdvisorCalls(entry: JournalEntry): DecodedCall[] {
|
||||
if (entry.type !== 'assistant') return []
|
||||
const msg = entry.message as AssistantMessageContent | undefined
|
||||
const iterations = msg?.usage?.iterations
|
||||
if (!msg?.usage || !Array.isArray(iterations)) return []
|
||||
|
||||
const calls: DecodedCall[] = []
|
||||
const baseKey = msg.id ?? `claude:${entry.timestamp}`
|
||||
// Ordinal among advisor entries (not the raw array index) so the dedup key is
|
||||
// identical whether it is computed from the raw record (guard path) or the
|
||||
// compacted record whose non-advisor iterations were dropped (report path).
|
||||
let advisorOrdinal = 0
|
||||
for (const it of iterations) {
|
||||
if (!it || it.type !== 'advisor_message') continue
|
||||
const model = typeof it.model === 'string' && it.model ? it.model : msg.model
|
||||
if (!model) continue
|
||||
const index = advisorOrdinal++
|
||||
|
||||
const cacheCreation = extractClaudeCacheCreation(it)
|
||||
const tokens: TokenUsage = {
|
||||
inputTokens: it.input_tokens ?? 0,
|
||||
outputTokens: it.output_tokens ?? 0,
|
||||
cacheCreationInputTokens: cacheCreation.totalTokens,
|
||||
cacheReadInputTokens: it.cache_read_input_tokens ?? 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: it.server_tool_use?.web_search_requests ?? 0,
|
||||
}
|
||||
const speed = it.speed ?? msg.usage.speed ?? 'standard'
|
||||
|
||||
calls.push({
|
||||
provider: 'claude',
|
||||
model,
|
||||
usage: tokens,
|
||||
tools: [],
|
||||
mcpTools: [],
|
||||
skills: [],
|
||||
subagentTypes: [],
|
||||
hasAgentSpawn: false,
|
||||
hasPlanMode: false,
|
||||
speed,
|
||||
timestamp: entry.timestamp ?? '',
|
||||
rawBashCommands: [],
|
||||
deduplicationKey: `${baseKey}:advisor:${index}`,
|
||||
cacheCreationOneHourTokens: cacheCreation.oneHourTokens || undefined,
|
||||
})
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
export function dedupeStreamingMessageIds(entries: JournalEntry[]): JournalEntry[] {
|
||||
const firstIdxById = new Map<string, number>()
|
||||
const lastIdxById = new Map<string, number>()
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const id = getMessageId(entries[i]!)
|
||||
if (!id) continue
|
||||
if (!firstIdxById.has(id)) firstIdxById.set(id, i)
|
||||
lastIdxById.set(id, i)
|
||||
}
|
||||
if (lastIdxById.size === 0) return entries
|
||||
const result: JournalEntry[] = []
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const id = getMessageId(entries[i]!)
|
||||
if (id && lastIdxById.get(id) !== i) continue
|
||||
if (id && firstIdxById.get(id) !== i) {
|
||||
const firstTs = entries[firstIdxById.get(id)!]!.timestamp
|
||||
result.push({ ...entries[i]!, timestamp: firstTs ?? entries[i]!.timestamp })
|
||||
continue
|
||||
}
|
||||
result.push(entries[i]!)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>, toolResultMeta?: Map<string, ToolResultMeta>): DecodedTurn[] {
|
||||
const turns: DecodedTurn[] = []
|
||||
let currentUserMessage = ''
|
||||
let currentCalls: DecodedCall[] = []
|
||||
let currentTimestamp = ''
|
||||
let currentSessionId = ''
|
||||
// Git branch of the turn currently being accumulated. Captured at turn start
|
||||
// from the user entry (gitBranch is on every user/assistant entry); a
|
||||
// continuation turn with no leading user text falls back to its first call.
|
||||
let currentBranch: string | undefined
|
||||
// GitHub PR URLs referenced within the turn currently being accumulated. A
|
||||
// `pr-link` entry is emitted after the assistant creates/references a PR, so it
|
||||
// lands inside the same turn (before the next user message) and attaches here.
|
||||
let currentPrRefs: string[] = []
|
||||
// Subagent-spawn `tool_use` ids emitted within the current turn (deduped),
|
||||
// carried from each call's `spawnToolUseIds`.
|
||||
let currentSpawnIds: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryBranch = typeof entry.gitBranch === 'string' && entry.gitBranch ? entry.gitBranch : undefined
|
||||
if (entry.type === 'user') {
|
||||
const text = getUserMessageText(entry)
|
||||
if (text.trim()) {
|
||||
if (currentCalls.length > 0) {
|
||||
turns.push({
|
||||
userMessage: currentUserMessage,
|
||||
assistantCalls: currentCalls,
|
||||
timestamp: currentTimestamp,
|
||||
sessionId: currentSessionId,
|
||||
...(currentBranch ? { gitBranch: currentBranch } : {}),
|
||||
...(currentPrRefs.length > 0 ? { prRefs: [...currentPrRefs].sort() } : {}),
|
||||
...(currentSpawnIds.length > 0 ? { spawnToolUseIds: currentSpawnIds } : {}),
|
||||
})
|
||||
}
|
||||
currentUserMessage = text
|
||||
currentCalls = []
|
||||
currentTimestamp = entry.timestamp ?? ''
|
||||
currentSessionId = entry.sessionId ?? ''
|
||||
currentBranch = entryBranch
|
||||
currentPrRefs = extractPrUrlsFromText(text)
|
||||
currentSpawnIds = []
|
||||
}
|
||||
} else if (entry.type === 'assistant') {
|
||||
if (entryBranch && !currentBranch) currentBranch = entryBranch
|
||||
const msgId = getMessageId(entry)
|
||||
if (msgId && seenMsgIds.has(msgId)) continue
|
||||
if (msgId) seenMsgIds.add(msgId)
|
||||
const call = decodeAssistantCall(entry, toolResultMeta)
|
||||
if (call) {
|
||||
currentCalls.push(call)
|
||||
if (call.spawnToolUseIds) for (const id of call.spawnToolUseIds) if (!currentSpawnIds.includes(id)) currentSpawnIds.push(id)
|
||||
}
|
||||
for (const advisorCall of decodeAdvisorCalls(entry)) currentCalls.push(advisorCall)
|
||||
} else if (entry.type === 'pr-link') {
|
||||
const url = (entry as Record<string, unknown>)['prUrl']
|
||||
if (typeof url === 'string' && url && !currentPrRefs.includes(url)) currentPrRefs.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCalls.length > 0) {
|
||||
turns.push({
|
||||
userMessage: currentUserMessage,
|
||||
assistantCalls: currentCalls,
|
||||
timestamp: currentTimestamp,
|
||||
sessionId: currentSessionId,
|
||||
...(currentBranch ? { gitBranch: currentBranch } : {}),
|
||||
...(currentPrRefs.length > 0 ? { prRefs: [...currentPrRefs].sort() } : {}),
|
||||
...(currentSpawnIds.length > 0 ? { spawnToolUseIds: currentSpawnIds } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
return turns
|
||||
}
|
||||
|
||||
// Map each subagent-spawn `tool_use` id to the PR set active at the turn that
|
||||
// emitted it, walking the FULL turn list in order. A turn's own `prRefs` apply to
|
||||
// spawns within it; otherwise the carried set does. First occurrence of a spawn id
|
||||
// wins deterministically (tool_use ids are unique in practice; this only guards a
|
||||
// pathological restatement). Drives cross-range subagent PR attribution.
|
||||
export function buildSpawnPrSets(turns: Array<{ prRefs?: string[]; spawnToolUseIds?: string[] }>): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {}
|
||||
let cur: string[] = []
|
||||
for (const turn of turns) {
|
||||
const active = turn.prRefs?.length ? turn.prRefs : cur
|
||||
for (const id of turn.spawnToolUseIds ?? []) if (!(id in out)) out[id] = active
|
||||
if (turn.prRefs?.length) cur = turn.prRefs
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Fully-qualified MCP tool name shape: `mcp__<server>__<tool>`. Both server
|
||||
// and tool segments must be non-empty.
|
||||
function isMcpToolName(name: string): boolean {
|
||||
if (!name.startsWith('mcp__')) return false
|
||||
const rest = name.slice(5) // strip `mcp__`
|
||||
const sep = rest.indexOf('__')
|
||||
if (sep <= 0) return false // missing or empty server
|
||||
if (sep >= rest.length - 2) return false // missing or empty tool
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract MCP tool inventory observed across a session's JSONL entries.
|
||||
* Claude Code emits `attachment.type === "deferred_tools_delta"` entries whose
|
||||
* `addedNames` array lists every tool available at that turn; union every
|
||||
* occurrence (tool inventory can change mid-session) and keep only `mcp__*`.
|
||||
*/
|
||||
export function extractMcpInventory(entries: JournalEntry[]): string[] {
|
||||
const inventory = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
const att = entry['attachment']
|
||||
if (!att || typeof att !== 'object') continue
|
||||
const a = att as { type?: unknown; addedNames?: unknown }
|
||||
if (a.type !== 'deferred_tools_delta') continue
|
||||
if (!Array.isArray(a.addedNames)) continue
|
||||
for (const name of a.addedNames) {
|
||||
if (typeof name !== 'string') continue
|
||||
if (!isMcpToolName(name)) continue
|
||||
inventory.add(name)
|
||||
}
|
||||
}
|
||||
if (inventory.size === 0) return []
|
||||
return Array.from(inventory).sort()
|
||||
}
|
||||
|
||||
const PR_URL_IN_TEXT_RE = /https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+/g
|
||||
|
||||
export function extractPrUrlsFromText(text: string): string[] {
|
||||
return [...new Set(text.match(PR_URL_IN_TEXT_RE) ?? [])].sort()
|
||||
}
|
||||
61
packages/core/src/providers/claude/index.ts
Normal file
61
packages/core/src/providers/claude/index.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// @codeburn/core Claude provider.
|
||||
//
|
||||
// Two layers:
|
||||
// - Rich pure decode (this file's re-exports of scanner/decode): host-facing,
|
||||
// NOT part of the stable minimized surface. Pure over supplied records/lines,
|
||||
// may carry content in-memory.
|
||||
// - Minimizing transform (`toObservations`): maps the rich decode into the
|
||||
// strict observation envelope; content-smuggling guarantees bind here.
|
||||
|
||||
export {
|
||||
BASH_TOOLS,
|
||||
EDIT_TOOLS,
|
||||
} from './tool-vocab.js'
|
||||
|
||||
export {
|
||||
LARGE_JSONL_LINE_BYTES,
|
||||
RAW_HEAD_BYTES,
|
||||
parseLargeJsonl,
|
||||
getTopLevelRawJsonStringField,
|
||||
} from './scanner.js'
|
||||
|
||||
export {
|
||||
parseJsonlLine,
|
||||
shouldSkipLine,
|
||||
compactEntry,
|
||||
safeNumber,
|
||||
isPositiveNumber,
|
||||
countStructuredPatchLoc,
|
||||
emptySessionMeta,
|
||||
collectToolResultMeta,
|
||||
collectSessionMeta,
|
||||
decodeAssistantCall,
|
||||
decodeAdvisorCalls,
|
||||
getMessageId,
|
||||
dedupeStreamingMessageIds,
|
||||
groupIntoTurns,
|
||||
buildSpawnPrSets,
|
||||
extractMcpInventory,
|
||||
extractPrUrlsFromText,
|
||||
} from './decode.js'
|
||||
|
||||
export {
|
||||
toObservations,
|
||||
type RichSessionDecode,
|
||||
type ToObservationsContext,
|
||||
} from './observations.js'
|
||||
|
||||
export type {
|
||||
ApiUsage,
|
||||
ApiUsageIteration,
|
||||
AssistantMessageContent,
|
||||
ContentBlock,
|
||||
DecodedCall,
|
||||
DecodedTurn,
|
||||
JournalEntry,
|
||||
SessionMeta,
|
||||
TokenUsage,
|
||||
ToolCall,
|
||||
ToolResultMeta,
|
||||
ToolUseBlock,
|
||||
} from './types.js'
|
||||
108
packages/core/src/providers/claude/observations.ts
Normal file
108
packages/core/src/providers/claude/observations.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Minimizing transform: rich Claude decode -> the strict Phase-2 observation
|
||||
// envelope. This is where the content-smuggling guarantees bind. Only opaque
|
||||
// ids, fingerprints, enums, numbers, timestamps, and CANONICAL tool names cross
|
||||
// into the output — never user text, titles, paths, commands, or tool
|
||||
// arguments. `.strict()` on the schemas rejects any extra field; this transform
|
||||
// simply never emits one.
|
||||
|
||||
import { branchRef, projectRef, sessionRef } from '../../fingerprint.js'
|
||||
import type { RecordDiagnostic } from '../../diagnostics.js'
|
||||
import type { CallObservation, SessionObservation } from '../../observations.js'
|
||||
import type { DecodedCall, DecodedTurn } from './types.js'
|
||||
|
||||
/** One session's rich decode, as the host holds it before minimization. */
|
||||
export interface RichSessionDecode {
|
||||
sessionId: string
|
||||
/** Absolute project path; fingerprinted, never emitted raw. */
|
||||
projectPath: string
|
||||
/** Raw git branch; fingerprinted, never emitted raw. */
|
||||
gitBranch?: string
|
||||
isSidechain?: boolean
|
||||
turns: DecodedTurn[]
|
||||
}
|
||||
|
||||
export interface ToObservationsContext {
|
||||
/** 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 tool
|
||||
// name that does not match (a smuggled command with spaces/slashes, an argument
|
||||
// blob) is dropped rather than emitted — the structural anti-smuggling property
|
||||
// enforced at the source, so the output also survives strict schema parse.
|
||||
const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/
|
||||
|
||||
function toCallObservation(call: DecodedCall, turnIndex: number): CallObservation {
|
||||
return {
|
||||
provider: call.provider,
|
||||
model: call.model,
|
||||
tokens: {
|
||||
input: call.usage.inputTokens,
|
||||
output: call.usage.outputTokens,
|
||||
reasoning: call.usage.reasoningTokens,
|
||||
cacheRead: call.usage.cacheReadInputTokens,
|
||||
cacheCreate: call.usage.cacheCreationInputTokens,
|
||||
},
|
||||
webSearchRequests: call.usage.webSearchRequests,
|
||||
speed: call.speed,
|
||||
// Claude native 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,
|
||||
...(call.locAdded !== undefined ? { locAdded: call.locAdded } : {}),
|
||||
...(call.locRemoved !== undefined ? { locRemoved: call.locRemoved } : {}),
|
||||
...(call.interrupted !== undefined ? { interrupted: call.interrupted } : {}),
|
||||
...(call.userModified !== undefined ? { userModified: call.userModified } : {}),
|
||||
...(call.toolErrors !== undefined ? { toolErrors: call.toolErrors } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function toSessionObservation(decode: RichSessionDecode, ctx: ToObservationsContext): SessionObservation {
|
||||
const provider = ctx.provider ?? 'claude'
|
||||
const calls: CallObservation[] = []
|
||||
decode.turns.forEach((turn, turnIndex) => {
|
||||
for (const call of turn.assistantCalls) calls.push(toCallObservation(call, turnIndex))
|
||||
})
|
||||
|
||||
const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort()
|
||||
const fallbackTs = decode.turns.find(t => t.timestamp)?.timestamp ?? ''
|
||||
const startedAt = timestamps[0] ?? fallbackTs
|
||||
const endedAt = timestamps.length > 0 ? timestamps[timestamps.length - 1]! : fallbackTs
|
||||
|
||||
const session: SessionObservation = {
|
||||
sessionRef: sessionRef(ctx.privacyKey, provider, decode.sessionId),
|
||||
projectRef: projectRef(ctx.privacyKey, decode.projectPath),
|
||||
providerId: provider,
|
||||
startedAt,
|
||||
...(endedAt ? { endedAt } : {}),
|
||||
...(decode.gitBranch ? { gitBranchRef: branchRef(ctx.privacyKey, decode.gitBranch) } : {}),
|
||||
...(decode.isSidechain !== undefined ? { isSidechain: decode.isSidechain } : {}),
|
||||
calls,
|
||||
turnCount: decode.turns.length,
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a rich Claude decode (one or many sessions) into the minimized
|
||||
* observation layer. Returns the `sessions` array plus any per-record
|
||||
* `diagnostics` — the caller wraps these into an ObservationEnvelope.
|
||||
*
|
||||
* Content-smuggling guarantee: no free text (user message, title, path,
|
||||
* command, tool argument, file content) is ever copied into the result. Only
|
||||
* fingerprints, enums, numbers, timestamps, dedup keys, and canonical tool
|
||||
* names cross the boundary.
|
||||
*/
|
||||
export function toObservations(
|
||||
decode: RichSessionDecode | RichSessionDecode[],
|
||||
ctx: ToObservationsContext,
|
||||
): { sessions: SessionObservation[]; diagnostics: RecordDiagnostic[] } {
|
||||
const decodes = Array.isArray(decode) ? decode : [decode]
|
||||
const sessions = decodes.map(d => toSessionObservation(d, ctx))
|
||||
return { sessions, diagnostics: [] }
|
||||
}
|
||||
760
packages/core/src/providers/claude/scanner.ts
Normal file
760
packages/core/src/providers/claude/scanner.ts
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
// Large-line JSONL scanner for Claude transcripts.
|
||||
//
|
||||
// Claude session lines can be multi-KB (big tool blobs). Rather than JSON.parse
|
||||
// the whole line into V8 strings, this scans the raw string/Buffer in place,
|
||||
// extracting only the small set of fields the decode needs. Pure over the
|
||||
// supplied line — no fs, no env, no clock, no pricing. Do NOT convert or
|
||||
// re-buffer the input: Buffers and strings are scanned by index as given.
|
||||
|
||||
import { BASH_TOOLS, EDIT_TOOLS } from './tool-vocab.js'
|
||||
import type { ApiUsageIteration, AssistantMessageContent, JournalEntry, ToolUseBlock } from './types.js'
|
||||
|
||||
export const LARGE_JSONL_LINE_BYTES = 32 * 1024
|
||||
|
||||
export const RAW_HEAD_BYTES = 2048
|
||||
|
||||
export const USER_TEXT_CAP = 2000
|
||||
export const BASH_COMMAND_CAP = 2000
|
||||
export const MAX_TOOL_BLOCKS = 500
|
||||
export const MAX_ADDED_NAMES = 1000
|
||||
|
||||
type JsonValueBounds = {
|
||||
start: number
|
||||
end: number
|
||||
kind: 'string' | 'object' | 'array' | 'scalar'
|
||||
}
|
||||
|
||||
type JsonIndexedSource = string | Buffer
|
||||
|
||||
type JsonSource = {
|
||||
readonly raw: JsonIndexedSource
|
||||
readonly length: number
|
||||
readonly slice: (start: number, end: number, maxChars?: number) => string
|
||||
}
|
||||
|
||||
function isAsciiWhitespace(ch: number | undefined): boolean {
|
||||
return ch === 0x20 || ch === 0x0a || ch === 0x0d || ch === 0x09 || ch === 0x0b || ch === 0x0c
|
||||
}
|
||||
|
||||
function isBufferWhitespaceAt(source: Buffer, index: number): boolean {
|
||||
const byte = source[index]
|
||||
if (isAsciiWhitespace(byte)) return true
|
||||
if (byte === undefined || byte < 0x80) return false
|
||||
|
||||
let start = index
|
||||
while (start > 0) {
|
||||
const preceding = source[start]
|
||||
if (preceding === undefined || (preceding & 0xc0) !== 0x80) break
|
||||
start--
|
||||
}
|
||||
const first = source[start]
|
||||
if (first === undefined) return false
|
||||
let codePoint: number | undefined
|
||||
let byteLength = 0
|
||||
if (first >= 0xc2 && first <= 0xdf) {
|
||||
const second = source[start + 1]
|
||||
if (second === undefined || (second & 0xc0) !== 0x80) return false
|
||||
codePoint = ((first & 0x1f) << 6) | (second & 0x3f)
|
||||
byteLength = 2
|
||||
} else if (first >= 0xe0 && first <= 0xef) {
|
||||
const second = source[start + 1]
|
||||
const third = source[start + 2]
|
||||
if (second === undefined || third === undefined || (second & 0xc0) !== 0x80 || (third & 0xc0) !== 0x80) return false
|
||||
codePoint = ((first & 0x0f) << 12) | ((second & 0x3f) << 6) | (third & 0x3f)
|
||||
byteLength = 3
|
||||
} else if (first >= 0xf0 && first <= 0xf4) {
|
||||
const second = source[start + 1]
|
||||
const third = source[start + 2]
|
||||
const fourth = source[start + 3]
|
||||
if (second === undefined || third === undefined || fourth === undefined || (second & 0xc0) !== 0x80 || (third & 0xc0) !== 0x80 || (fourth & 0xc0) !== 0x80) {
|
||||
return false
|
||||
}
|
||||
codePoint = ((first & 0x07) << 18) | ((second & 0x3f) << 12) | ((third & 0x3f) << 6) | (fourth & 0x3f)
|
||||
byteLength = 4
|
||||
}
|
||||
if (codePoint === undefined || index >= start + byteLength) return false
|
||||
return codePoint === 0x00a0 || codePoint === 0x1680 || (codePoint >= 0x2000 && codePoint <= 0x200a) || codePoint === 0x2028 || codePoint === 0x2029 || codePoint === 0x202f || codePoint === 0x205f || codePoint === 0x3000 || codePoint === 0xfeff
|
||||
}
|
||||
|
||||
function safeBufferSegmentEnd(source: Buffer, index: number): number {
|
||||
while (index > 0 && ((source[index] ?? 0) & 0xc0) === 0x80) index--
|
||||
return index
|
||||
}
|
||||
|
||||
function createJsonSource(source: string | Buffer): JsonSource {
|
||||
if (typeof source === 'string') {
|
||||
return {
|
||||
raw: source,
|
||||
length: source.length,
|
||||
slice: (start, end, maxChars = Number.POSITIVE_INFINITY) => source.slice(start, Math.min(end, start + maxChars)),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
raw: source,
|
||||
length: source.length,
|
||||
slice: (start, end, maxChars = Number.POSITIVE_INFINITY) => {
|
||||
const cappedEnd = Number.isFinite(maxChars) ? safeBufferSegmentEnd(source, Math.min(end, start + maxChars * 4)) : end
|
||||
return source.subarray(start, cappedEnd).toString('utf-8').slice(0, maxChars)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function jsonCharCodeAt(source: JsonSource, index: number): number {
|
||||
return typeof source.raw === 'string' ? source.raw.charCodeAt(index) : source.raw[index] ?? Number.NaN
|
||||
}
|
||||
|
||||
function skipJsonWhitespace(source: JsonSource, start: number, limit = source.length): number {
|
||||
if (typeof source.raw === 'string') {
|
||||
let i = start
|
||||
while (i < limit && /\s/.test(source.raw[i]!)) i++
|
||||
return i
|
||||
}
|
||||
let i = start
|
||||
while (i < limit && isBufferWhitespaceAt(source.raw, i)) i++
|
||||
return i
|
||||
}
|
||||
|
||||
function findJsonStringEnd(source: JsonSource, start: number, limit = source.length): number {
|
||||
return typeof source.raw === 'string'
|
||||
? findJsonStringEndString(source.raw, start, limit)
|
||||
: findJsonStringEndBuffer(source.raw, start, limit)
|
||||
}
|
||||
|
||||
function findJsonContainerEnd(source: JsonSource, start: number, open: number, close: number, limit = source.length): number {
|
||||
return typeof source.raw === 'string'
|
||||
? findJsonContainerEndString(source.raw, start, open, close, limit)
|
||||
: findJsonContainerEndBuffer(source.raw, start, open, close, limit)
|
||||
}
|
||||
|
||||
function findObjectFieldValue(source: JsonSource, objectStart: number, objectEnd: number, field: string): JsonValueBounds | null {
|
||||
return typeof source.raw === 'string'
|
||||
? findObjectFieldValueString(source.raw, objectStart, objectEnd, field)
|
||||
: findObjectFieldValueBuffer(source.raw, objectStart, objectEnd, field)
|
||||
}
|
||||
|
||||
function findJsonValueBounds(source: JsonSource, start: number, limit = source.length): JsonValueBounds | null {
|
||||
return typeof source.raw === 'string'
|
||||
? findJsonValueBoundsString(source.raw, start, limit)
|
||||
: findJsonValueBoundsBuffer(source.raw, start, limit)
|
||||
}
|
||||
|
||||
function readJsonString(source: JsonSource, bounds: JsonValueBounds | null, cap = Number.POSITIVE_INFINITY): string | undefined {
|
||||
if (typeof source.raw === 'string') return readJsonStringString(source.raw, bounds, cap)
|
||||
return readJsonStringBuffer(source.raw, bounds, cap)
|
||||
}
|
||||
|
||||
function readJsonNumberField(source: JsonSource, objectBounds: JsonValueBounds | null, field: string): number | undefined {
|
||||
if (!objectBounds || objectBounds.kind !== 'object') return undefined
|
||||
const bounds = findObjectFieldValue(source, objectBounds.start, objectBounds.end, field)
|
||||
if (!bounds) return undefined
|
||||
const value = Number(source.slice(bounds.start, bounds.end))
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
// The large-line parsers avoid JSON.parse on the whole (multi-KB) line, but the
|
||||
// usage object itself is tiny; parse just that slice to recover advisor
|
||||
// (/advisor) iterations, which the byte-scanner cannot cheaply extract. Without
|
||||
// this, an advisor escalation on a large assistant turn would be dropped.
|
||||
function extractAdvisorIterations(usageObjectJson: string): ApiUsageIteration[] | undefined {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(usageObjectJson)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
const iterations = (parsed as { iterations?: unknown }).iterations
|
||||
if (!Array.isArray(iterations)) return undefined
|
||||
const advisor = iterations.filter(
|
||||
(it): it is ApiUsageIteration =>
|
||||
!!it && typeof it === 'object' && (it as { type?: unknown }).type === 'advisor_message',
|
||||
)
|
||||
return advisor.length > 0 ? advisor : undefined
|
||||
}
|
||||
|
||||
function parseLargeUsage(source: JsonSource, usageBounds: JsonValueBounds | null) {
|
||||
const usage: AssistantMessageContent['usage'] = {
|
||||
input_tokens: readJsonNumberField(source, usageBounds, 'input_tokens') ?? 0,
|
||||
output_tokens: readJsonNumberField(source, usageBounds, 'output_tokens') ?? 0,
|
||||
cache_creation_input_tokens: readJsonNumberField(source, usageBounds, 'cache_creation_input_tokens'),
|
||||
cache_read_input_tokens: readJsonNumberField(source, usageBounds, 'cache_read_input_tokens'),
|
||||
}
|
||||
|
||||
if (usageBounds?.kind === 'object') {
|
||||
const cacheCreation = findObjectFieldValue(source, usageBounds.start, usageBounds.end, 'cache_creation')
|
||||
const ephemeral5m = readJsonNumberField(source, cacheCreation, 'ephemeral_5m_input_tokens')
|
||||
const ephemeral1h = readJsonNumberField(source, cacheCreation, 'ephemeral_1h_input_tokens')
|
||||
if (ephemeral5m !== undefined || ephemeral1h !== undefined) {
|
||||
;(usage as AssistantMessageContent['usage']).cache_creation = {
|
||||
...(ephemeral5m !== undefined ? { ephemeral_5m_input_tokens: ephemeral5m } : {}),
|
||||
...(ephemeral1h !== undefined ? { ephemeral_1h_input_tokens: ephemeral1h } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const serverToolUse = findObjectFieldValue(source, usageBounds.start, usageBounds.end, 'server_tool_use')
|
||||
const webSearch = readJsonNumberField(source, serverToolUse, 'web_search_requests')
|
||||
const webFetch = readJsonNumberField(source, serverToolUse, 'web_fetch_requests')
|
||||
if (webSearch !== undefined || webFetch !== undefined) {
|
||||
;(usage as AssistantMessageContent['usage']).server_tool_use = {
|
||||
...(webSearch !== undefined ? { web_search_requests: webSearch } : {}),
|
||||
...(webFetch !== undefined ? { web_fetch_requests: webFetch } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const speed = readJsonString(source, findObjectFieldValue(source, usageBounds.start, usageBounds.end, 'speed'))
|
||||
if (speed === 'standard' || speed === 'fast') usage.speed = speed
|
||||
|
||||
const advisor = extractAdvisorIterations(source.slice(usageBounds.start, usageBounds.end))
|
||||
if (advisor) usage.iterations = advisor
|
||||
}
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
function extractLargeToolBlocks(source: JsonSource, contentBounds: JsonValueBounds | null): ToolUseBlock[] {
|
||||
if (!contentBounds || contentBounds.kind !== 'array') return []
|
||||
const tools: ToolUseBlock[] = []
|
||||
let i = contentBounds.start + 1
|
||||
while (i < contentBounds.end - 1 && tools.length < MAX_TOOL_BLOCKS) {
|
||||
i = skipJsonWhitespace(source, i, contentBounds.end)
|
||||
if (jsonCharCodeAt(source, i) === 0x2c) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (jsonCharCodeAt(source, i) !== 0x7b) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const objectEnd = findJsonContainerEnd(source, i, 0x7b, 0x7d, contentBounds.end)
|
||||
if (objectEnd === -1) break
|
||||
const objectBounds = { start: i, end: objectEnd + 1, kind: 'object' as const }
|
||||
const blockType = readJsonString(source, findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'type'))
|
||||
if (blockType === 'tool_use') {
|
||||
const name = readJsonString(source, findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'name')) ?? ''
|
||||
const id = readJsonString(source, findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'id')) ?? ''
|
||||
const inputBounds = findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'input')
|
||||
const input: Record<string, unknown> = {}
|
||||
if (inputBounds?.kind === 'object') {
|
||||
if (name === 'Skill') {
|
||||
const skill = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'skill'), 200)
|
||||
const skillName = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'name'), 200)
|
||||
if (skill !== undefined) input['skill'] = skill
|
||||
if (skillName !== undefined) input['name'] = skillName
|
||||
} else if (name === 'Read' || name === 'FileReadTool' || EDIT_TOOLS.has(name)) {
|
||||
const filePath = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'file_path'), BASH_COMMAND_CAP)
|
||||
if (filePath !== undefined) input['file_path'] = filePath
|
||||
} else if (name === 'Agent' || name === 'Task') {
|
||||
const subagentType = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'subagent_type'), 200)
|
||||
if (subagentType !== undefined) input['subagent_type'] = subagentType
|
||||
} else if (BASH_TOOLS.has(name)) {
|
||||
const command = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'command'), BASH_COMMAND_CAP)
|
||||
if (command !== undefined) input['command'] = command
|
||||
}
|
||||
}
|
||||
tools.push({ type: 'tool_use', id, name, input })
|
||||
}
|
||||
i = objectEnd + 1
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
function extractLargeUserText(source: JsonSource, contentBounds: JsonValueBounds | null): string | undefined {
|
||||
if (!contentBounds) return undefined
|
||||
if (contentBounds.kind === 'string') return readJsonString(source, contentBounds, USER_TEXT_CAP)
|
||||
if (contentBounds.kind !== 'array') return undefined
|
||||
|
||||
let text = ''
|
||||
let i = contentBounds.start + 1
|
||||
while (i < contentBounds.end - 1 && text.length < USER_TEXT_CAP) {
|
||||
i = skipJsonWhitespace(source, i, contentBounds.end)
|
||||
if (jsonCharCodeAt(source, i) === 0x2c) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (jsonCharCodeAt(source, i) !== 0x7b) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const objectEnd = findJsonContainerEnd(source, i, 0x7b, 0x7d, contentBounds.end)
|
||||
if (objectEnd === -1) break
|
||||
const objectBounds = { start: i, end: objectEnd + 1, kind: 'object' as const }
|
||||
const type = readJsonString(source, findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'type'))
|
||||
if (type === 'text' || type === 'input_text') {
|
||||
const part = readJsonString(
|
||||
source,
|
||||
findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'text'),
|
||||
USER_TEXT_CAP - text.length,
|
||||
)
|
||||
if (part) text += (text ? ' ' : '') + part
|
||||
}
|
||||
i = objectEnd + 1
|
||||
}
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
function extractLargeAddedNames(source: JsonSource, attachmentBounds: JsonValueBounds | null): string[] {
|
||||
if (!attachmentBounds || attachmentBounds.kind !== 'object') return []
|
||||
const attachmentType = readJsonString(source, findObjectFieldValue(source, attachmentBounds.start, attachmentBounds.end, 'type'))
|
||||
if (attachmentType !== 'deferred_tools_delta') return []
|
||||
const addedNames = findObjectFieldValue(source, attachmentBounds.start, attachmentBounds.end, 'addedNames')
|
||||
if (!addedNames || addedNames.kind !== 'array') return []
|
||||
const names: string[] = []
|
||||
let i = addedNames.start + 1
|
||||
while (i < addedNames.end - 1 && names.length < MAX_ADDED_NAMES) {
|
||||
i = skipJsonWhitespace(source, i, addedNames.end)
|
||||
if (jsonCharCodeAt(source, i) === 0x2c) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (jsonCharCodeAt(source, i) !== 0x22) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const end = findJsonStringEnd(source, i, addedNames.end)
|
||||
if (end === -1) break
|
||||
const name = readJsonString(source, { start: i, end: end + 1, kind: 'string' }, 500)
|
||||
if (name) names.push(name)
|
||||
i = end + 1
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Does the raw key bytes/chars at [keyStart, keyEnd) equal one of `fields`? This
|
||||
// compares the RAW key (escapes and all), exactly as findObjectFieldValue did, so
|
||||
// a key like "type" still does not match "type". Returns the matched field
|
||||
// name so the caller can bucket the value.
|
||||
function matchCapturedField(
|
||||
source: JsonSource,
|
||||
fieldBuffers: Buffer[] | null,
|
||||
keyStart: number,
|
||||
keyEnd: number,
|
||||
fields: readonly string[],
|
||||
): string | null {
|
||||
if (fieldBuffers === null) {
|
||||
const key = (source.raw as string).slice(keyStart, keyEnd)
|
||||
return fields.includes(key) ? key : null
|
||||
}
|
||||
const raw = source.raw as Buffer
|
||||
const keyLength = keyEnd - keyStart
|
||||
for (let k = 0; k < fields.length; k++) {
|
||||
const fieldBuffer = fieldBuffers[k]!
|
||||
if (keyLength === fieldBuffer.length && raw.subarray(keyStart, keyEnd).equals(fieldBuffer)) return fields[k]!
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Single pass over one JSON object, capturing the bounds of several top-level
|
||||
// fields at once. This is the multi-field generalization of findObjectFieldValue:
|
||||
// it reproduces that walk exactly — same whitespace/comma handling, same
|
||||
// first-match-wins on duplicate keys, and the same "stop on a truncated key or an
|
||||
// unparseable value" behavior that findObjectFieldValue expressed as `return null`
|
||||
// — but visits each byte once instead of re-walking the object per field. On large
|
||||
// Claude lines a multi-KB tool blob often precedes these keys, so a per-field walk
|
||||
// re-scanned that blob once for every field it trailed.
|
||||
function extractObjectFields(
|
||||
source: JsonSource,
|
||||
objectStart: number,
|
||||
objectEnd: number,
|
||||
fields: readonly string[],
|
||||
): Record<string, JsonValueBounds | null> {
|
||||
const captured: Record<string, JsonValueBounds | null> = {}
|
||||
for (const field of fields) captured[field] = null
|
||||
if (jsonCharCodeAt(source, objectStart) !== 0x7b) return captured
|
||||
|
||||
const fieldBuffers = typeof source.raw === 'string' ? null : fields.map((f) => Buffer.from(f))
|
||||
let remaining = fields.length
|
||||
let i = objectStart + 1
|
||||
while (i < objectEnd - 1 && remaining > 0) {
|
||||
i = skipJsonWhitespace(source, i, objectEnd)
|
||||
const ch = jsonCharCodeAt(source, i)
|
||||
if (ch === 0x2c) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// Any non-'"' byte here is stray content between members; step over it and
|
||||
// resync on the next quote, exactly as the per-field walk did.
|
||||
if (ch !== 0x22) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const keyEnd = findJsonStringEnd(source, i, objectEnd)
|
||||
if (keyEnd === -1) break // truncated key: findObjectFieldValue returned null here
|
||||
const keyStart = i + 1
|
||||
i = skipJsonWhitespace(source, keyEnd + 1, objectEnd)
|
||||
if (jsonCharCodeAt(source, i) !== 0x3a) continue // missing ':' — resync on the next member
|
||||
const value = findJsonValueBounds(source, i + 1, objectEnd)
|
||||
if (!value) break // unparseable value: findObjectFieldValue returned null here
|
||||
const matched = matchCapturedField(source, fieldBuffers, keyStart, keyEnd, fields)
|
||||
if (matched !== null && captured[matched] === null) {
|
||||
captured[matched] = value // keep the first occurrence, like findObjectFieldValue
|
||||
remaining-- // once every field is found the rest of the object is dead weight
|
||||
}
|
||||
i = value.end
|
||||
}
|
||||
return captured
|
||||
}
|
||||
|
||||
const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message'] as const
|
||||
const LARGE_ASSISTANT_MESSAGE_FIELDS = ['model', 'usage', 'id', 'content'] as const
|
||||
|
||||
export function parseLargeJsonl(line: string | Buffer): JournalEntry | null {
|
||||
const source = createJsonSource(line)
|
||||
const rootStart = skipJsonWhitespace(source, 0)
|
||||
const rootEnd = findJsonContainerEnd(source, rootStart, 0x7b, 0x7d)
|
||||
if (rootEnd === -1) return null
|
||||
const rootLimit = rootEnd + 1
|
||||
const root = extractObjectFields(source, rootStart, rootLimit, LARGE_ROOT_FIELDS)
|
||||
const type = readJsonString(source, root['type'])
|
||||
if (!type) return null
|
||||
|
||||
const entry: JournalEntry = { type }
|
||||
const timestamp = readJsonString(source, root['timestamp'])
|
||||
const sessionId = readJsonString(source, root['sessionId'])
|
||||
const cwd = readJsonString(source, root['cwd'])
|
||||
const gitBranch = readJsonString(source, root['gitBranch'])
|
||||
if (timestamp !== undefined) entry.timestamp = timestamp
|
||||
if (sessionId !== undefined) entry.sessionId = sessionId
|
||||
if (cwd !== undefined) entry.cwd = cwd
|
||||
if (gitBranch !== undefined) entry.gitBranch = gitBranch
|
||||
const addedNames = extractLargeAddedNames(source, root['attachment'])
|
||||
if (addedNames.length > 0) {
|
||||
;(entry as Record<string, unknown>)['attachment'] = { type: 'deferred_tools_delta', addedNames }
|
||||
}
|
||||
|
||||
const message = root['message']
|
||||
if (type === 'user') {
|
||||
if (message?.kind === 'object') {
|
||||
const content = findObjectFieldValue(source, message.start, message.end, 'content')
|
||||
const text = extractLargeUserText(source, content)
|
||||
if (text !== undefined) entry.message = { role: 'user', content: text }
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
if (type !== 'assistant') return entry
|
||||
if (message?.kind !== 'object') return entry
|
||||
const messageFields = extractObjectFields(source, message.start, message.end, LARGE_ASSISTANT_MESSAGE_FIELDS)
|
||||
const model = readJsonString(source, messageFields['model'])
|
||||
const usageBounds = messageFields['usage']
|
||||
if (!model || usageBounds?.kind !== 'object') return entry
|
||||
const id = readJsonString(source, messageFields['id'])
|
||||
const contentBounds = messageFields['content']
|
||||
|
||||
entry.message = {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model,
|
||||
...(id !== undefined ? { id } : {}),
|
||||
content: extractLargeToolBlocks(source, contentBounds),
|
||||
usage: parseLargeUsage(source, usageBounds),
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
function findJsonStringEndString(source: string, start: number, limit = source.length): number {
|
||||
for (let i = start + 1; i < limit; i++) {
|
||||
const ch = source.charCodeAt(i)
|
||||
if (ch === 0x5c) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === 0x22) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function findJsonContainerEndString(source: string, start: number, open: number, close: number, limit = source.length): number {
|
||||
let depth = 0
|
||||
let inString = false
|
||||
for (let i = start; i < limit; i++) {
|
||||
const ch = source.charCodeAt(i)
|
||||
if (inString) {
|
||||
if (ch === 0x5c) {
|
||||
i++
|
||||
} else if (ch === 0x22) {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ch === 0x22) {
|
||||
inString = true
|
||||
} else if (ch === open) {
|
||||
depth++
|
||||
} else if (ch === close) {
|
||||
depth--
|
||||
if (depth === 0) return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function findJsonValueBoundsString(source: string, start: number, limit = source.length): JsonValueBounds | null {
|
||||
let i = start
|
||||
while (i < limit && /\s/.test(source[i]!)) i++
|
||||
if (i >= limit) return null
|
||||
const ch = source.charCodeAt(i)
|
||||
if (ch === 0x22) {
|
||||
const end = findJsonStringEndString(source, i, limit)
|
||||
return end === -1 ? null : { start: i, end: end + 1, kind: 'string' }
|
||||
}
|
||||
if (ch === 0x7b) {
|
||||
const end = findJsonContainerEndString(source, i, 0x7b, 0x7d, limit)
|
||||
return end === -1 ? null : { start: i, end: end + 1, kind: 'object' }
|
||||
}
|
||||
if (ch === 0x5b) {
|
||||
const end = findJsonContainerEndString(source, i, 0x5b, 0x5d, limit)
|
||||
return end === -1 ? null : { start: i, end: end + 1, kind: 'array' }
|
||||
}
|
||||
let end = i
|
||||
while (end < limit) {
|
||||
const c = source.charCodeAt(end)
|
||||
if (c === 0x2c || c === 0x7d || c === 0x5d || /\s/.test(source[end]!)) break
|
||||
end++
|
||||
}
|
||||
return { start: i, end, kind: 'scalar' }
|
||||
}
|
||||
|
||||
function findJsonStringEndBuffer(source: Buffer, start: number, limit = source.length): number {
|
||||
for (let i = start + 1; i < limit; i++) {
|
||||
const ch = source[i]
|
||||
if (ch === 0x5c) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === 0x22) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function findJsonContainerEndBuffer(source: Buffer, start: number, open: number, close: number, limit = source.length): number {
|
||||
let depth = 0
|
||||
let inString = false
|
||||
for (let i = start; i < limit; i++) {
|
||||
const ch = source[i]
|
||||
if (inString) {
|
||||
if (ch === 0x5c) {
|
||||
i++
|
||||
} else if (ch === 0x22) {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ch === 0x22) {
|
||||
inString = true
|
||||
} else if (ch === open) {
|
||||
depth++
|
||||
} else if (ch === close) {
|
||||
depth--
|
||||
if (depth === 0) return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function findJsonValueBoundsBuffer(source: Buffer, start: number, limit = source.length): JsonValueBounds | null {
|
||||
let i = start
|
||||
while (i < limit && isBufferWhitespaceAt(source, i)) i++
|
||||
if (i >= limit) return null
|
||||
const ch = source[i]
|
||||
if (ch === 0x22) {
|
||||
const end = findJsonStringEndBuffer(source, i, limit)
|
||||
return end === -1 ? null : { start: i, end: end + 1, kind: 'string' }
|
||||
}
|
||||
if (ch === 0x7b) {
|
||||
const end = findJsonContainerEndBuffer(source, i, 0x7b, 0x7d, limit)
|
||||
return end === -1 ? null : { start: i, end: end + 1, kind: 'object' }
|
||||
}
|
||||
if (ch === 0x5b) {
|
||||
const end = findJsonContainerEndBuffer(source, i, 0x5b, 0x5d, limit)
|
||||
return end === -1 ? null : { start: i, end: end + 1, kind: 'array' }
|
||||
}
|
||||
let end = i
|
||||
while (end < limit) {
|
||||
const c = source[end]
|
||||
if (c === 0x2c || c === 0x7d || c === 0x5d || isBufferWhitespaceAt(source, end)) break
|
||||
end++
|
||||
}
|
||||
return { start: i, end, kind: 'scalar' }
|
||||
}
|
||||
|
||||
function findObjectFieldValueString(source: string, objectStart: number, objectEnd: number, field: string): JsonValueBounds | null {
|
||||
if (source.charCodeAt(objectStart) !== 0x7b) return null
|
||||
let i = objectStart + 1
|
||||
while (i < objectEnd - 1) {
|
||||
while (i < objectEnd && /\s/.test(source[i]!)) i++
|
||||
if (source.charCodeAt(i) === 0x2c) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (source.charCodeAt(i) !== 0x22) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const keyEnd = findJsonStringEndString(source, i, objectEnd)
|
||||
if (keyEnd === -1) return null
|
||||
const keyStart = i + 1
|
||||
i = keyEnd + 1
|
||||
while (i < objectEnd && /\s/.test(source[i]!)) i++
|
||||
if (source.charCodeAt(i) !== 0x3a) continue
|
||||
const value = findJsonValueBoundsString(source, i + 1, objectEnd)
|
||||
if (!value) return null
|
||||
if (source.slice(keyStart, keyEnd) === field) return value
|
||||
i = value.end
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findObjectFieldValueBuffer(source: Buffer, objectStart: number, objectEnd: number, field: string): JsonValueBounds | null {
|
||||
if (source[objectStart] !== 0x7b) return null
|
||||
let i = objectStart + 1
|
||||
while (i < objectEnd - 1) {
|
||||
while (i < objectEnd && isBufferWhitespaceAt(source, i)) i++
|
||||
if (source[i] === 0x2c) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (source[i] !== 0x22) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const keyEnd = findJsonStringEndBuffer(source, i, objectEnd)
|
||||
if (keyEnd === -1) return null
|
||||
const keyStart = i + 1
|
||||
i = keyEnd + 1
|
||||
while (i < objectEnd && isBufferWhitespaceAt(source, i)) i++
|
||||
if (source[i] !== 0x3a) continue
|
||||
const value = findJsonValueBoundsBuffer(source, i + 1, objectEnd)
|
||||
if (!value) return null
|
||||
if (keyEnd - keyStart === field.length && source.subarray(keyStart, keyEnd).equals(Buffer.from(field))) return value
|
||||
i = value.end
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function appendStringJsonSegment(source: string, start: number, end: number, current: string, cap: number): string {
|
||||
if (start >= end || current.length >= cap) return current
|
||||
return current + source.slice(start, Math.min(end, start + cap - current.length))
|
||||
}
|
||||
|
||||
function appendBufferJsonSegment(source: Buffer, start: number, end: number, current: string, cap: number): string {
|
||||
if (start >= end || current.length >= cap) return current
|
||||
const remaining = cap - current.length
|
||||
const cappedEnd = Number.isFinite(cap) ? safeBufferSegmentEnd(source, Math.min(end, start + remaining * 4)) : end
|
||||
return current + source.subarray(start, cappedEnd).toString('utf-8').slice(0, remaining)
|
||||
}
|
||||
|
||||
function readJsonStringString(source: string, bounds: JsonValueBounds | null, cap = Number.POSITIVE_INFINITY): string | undefined {
|
||||
if (!bounds || bounds.kind !== 'string') return undefined
|
||||
let out = ''
|
||||
const contentEnd = bounds.end - 1
|
||||
let segmentStart = bounds.start + 1
|
||||
let i = segmentStart
|
||||
let scanLimit = Number.isFinite(cap) ? Math.min(contentEnd, segmentStart + cap) : contentEnd
|
||||
while (i < contentEnd && out.length < cap) {
|
||||
if (i >= scanLimit) {
|
||||
out = appendStringJsonSegment(source, segmentStart, i, out, cap)
|
||||
if (out.length >= cap) break
|
||||
segmentStart = i
|
||||
scanLimit = Number.isFinite(cap) ? Math.min(contentEnd, i + cap - out.length) : contentEnd
|
||||
continue
|
||||
}
|
||||
const ch = source.charCodeAt(i)
|
||||
if (ch !== 0x5c) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out = appendStringJsonSegment(source, segmentStart, i, out, cap)
|
||||
if (out.length >= cap) break
|
||||
i++
|
||||
const next = source.charCodeAt(i)
|
||||
if (Number.isNaN(next)) break
|
||||
if (next === 0x6e) out += '\n'
|
||||
else if (next === 0x72) out += '\r'
|
||||
else if (next === 0x74) out += '\t'
|
||||
else if (next === 0x62) out += '\b'
|
||||
else if (next === 0x66) out += '\f'
|
||||
else if (next === 0x75 && i + 4 < bounds.end) {
|
||||
const code = Number.parseInt(source.slice(i + 1, i + 5), 16)
|
||||
if (Number.isFinite(code)) out += String.fromCharCode(code)
|
||||
i += 4
|
||||
} else {
|
||||
out += String.fromCharCode(next)
|
||||
}
|
||||
segmentStart = i + 1
|
||||
i++
|
||||
}
|
||||
return appendStringJsonSegment(source, segmentStart, contentEnd, out, cap)
|
||||
}
|
||||
|
||||
function readJsonStringBuffer(source: Buffer, bounds: JsonValueBounds | null, cap = Number.POSITIVE_INFINITY): string | undefined {
|
||||
if (!bounds || bounds.kind !== 'string') return undefined
|
||||
let out = ''
|
||||
const contentEnd = bounds.end - 1
|
||||
let segmentStart = bounds.start + 1
|
||||
let i = segmentStart
|
||||
let scanLimit = Number.isFinite(cap) ? Math.min(contentEnd, segmentStart + cap * 4) : contentEnd
|
||||
while (i < contentEnd && out.length < cap) {
|
||||
if (i >= scanLimit) {
|
||||
const segmentEnd = safeBufferSegmentEnd(source, i)
|
||||
out = appendBufferJsonSegment(source, segmentStart, segmentEnd, out, cap)
|
||||
if (out.length >= cap) break
|
||||
segmentStart = segmentEnd
|
||||
i = segmentEnd
|
||||
scanLimit = Number.isFinite(cap) ? Math.min(contentEnd, i + (cap - out.length) * 4) : contentEnd
|
||||
continue
|
||||
}
|
||||
const ch = source[i]
|
||||
if (ch !== 0x5c) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out = appendBufferJsonSegment(source, segmentStart, i, out, cap)
|
||||
if (out.length >= cap) break
|
||||
i++
|
||||
const next = source[i]
|
||||
if (next === undefined) break
|
||||
if (next === 0x6e) out += '\n'
|
||||
else if (next === 0x72) out += '\r'
|
||||
else if (next === 0x74) out += '\t'
|
||||
else if (next === 0x62) out += '\b'
|
||||
else if (next === 0x66) out += '\f'
|
||||
else if (next === 0x75 && i + 4 < bounds.end) {
|
||||
const code = Number.parseInt(source.subarray(i + 1, i + 5).toString('ascii'), 16)
|
||||
if (Number.isFinite(code)) out += String.fromCharCode(code)
|
||||
i += 4
|
||||
} else {
|
||||
out += String.fromCharCode(next)
|
||||
}
|
||||
segmentStart = i + 1
|
||||
i++
|
||||
}
|
||||
return appendBufferJsonSegment(source, segmentStart, contentEnd, out, cap)
|
||||
}
|
||||
|
||||
export function getTopLevelRawJsonStringField(head: string, field: string): string | null {
|
||||
let i = 0
|
||||
while (i < head.length && /\s/.test(head[i]!)) i++
|
||||
if (head.charCodeAt(i) !== 0x7b) return null
|
||||
i++
|
||||
while (i < head.length) {
|
||||
while (i < head.length && /\s/.test(head[i]!)) i++
|
||||
if (head.charCodeAt(i) === 0x2c) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (head.charCodeAt(i) === 0x7d) return null
|
||||
if (head.charCodeAt(i) !== 0x22) return null
|
||||
const keyEnd = findJsonStringEndString(head, i)
|
||||
if (keyEnd === -1) return null
|
||||
const key = head.slice(i + 1, keyEnd)
|
||||
i = keyEnd + 1
|
||||
while (i < head.length && /\s/.test(head[i]!)) i++
|
||||
if (head.charCodeAt(i) !== 0x3a) return null
|
||||
const value = findJsonValueBoundsString(head, i + 1)
|
||||
if (!value) return null
|
||||
if (key === field) return readJsonStringString(head, value) ?? null
|
||||
i = value.end
|
||||
}
|
||||
return null
|
||||
}
|
||||
7
packages/core/src/providers/claude/tool-vocab.ts
Normal file
7
packages/core/src/providers/claude/tool-vocab.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Canonical tool-name vocabulary shared by the Claude rich decode and the CLI's
|
||||
// host-side classifier. Defined here so the decode (which keys its input-field
|
||||
// capture off these sets) and the classifier stay in lockstep without the CLI
|
||||
// re-declaring them.
|
||||
export const EDIT_TOOLS = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit', 'cursor:edit'])
|
||||
|
||||
export const BASH_TOOLS = new Set(['Bash', 'BashTool', 'PowerShellTool'])
|
||||
176
packages/core/src/providers/claude/types.ts
Normal file
176
packages/core/src/providers/claude/types.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// Raw record + rich-decode types for the Claude provider.
|
||||
//
|
||||
// The record types (JournalEntry, ContentBlock, ...) describe the shape of a
|
||||
// Claude Code JSONL transcript. The Decoded* types are the rich decode layer's
|
||||
// output: pure over the supplied records, carrying content in-memory but no
|
||||
// pricing (the host prices them) — the CLI maps these into its own
|
||||
// ParsedApiCall / ParsedTurn structures.
|
||||
|
||||
export type TokenUsage = {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationInputTokens: number
|
||||
cacheReadInputTokens: number
|
||||
cachedInputTokens: number
|
||||
reasoningTokens: number
|
||||
webSearchRequests: number
|
||||
}
|
||||
|
||||
export type ToolUseBlock = {
|
||||
type: 'tool_use'
|
||||
id: string
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ContentBlock =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'thinking'; thinking: string }
|
||||
| ToolUseBlock
|
||||
| { type: string; [key: string]: unknown }
|
||||
|
||||
export type ApiUsage = {
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_creation?: {
|
||||
ephemeral_5m_input_tokens?: number
|
||||
ephemeral_1h_input_tokens?: number
|
||||
}
|
||||
cache_read_input_tokens?: number
|
||||
server_tool_use?: {
|
||||
web_search_requests?: number
|
||||
web_fetch_requests?: number
|
||||
}
|
||||
speed?: 'standard' | 'fast'
|
||||
// Claude Code advisor tool (/advisor): per-turn sub-usage records. A record
|
||||
// with type 'advisor_message' carries the advisor model's own tokens and is
|
||||
// NOT included in the top-level totals above; type 'message' records mirror
|
||||
// the main model and are already covered by the top-level totals.
|
||||
iterations?: ApiUsageIteration[]
|
||||
}
|
||||
|
||||
export type ApiUsageIteration = {
|
||||
type?: string
|
||||
model?: string
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_creation?: {
|
||||
ephemeral_5m_input_tokens?: number
|
||||
ephemeral_1h_input_tokens?: number
|
||||
}
|
||||
cache_read_input_tokens?: number
|
||||
server_tool_use?: {
|
||||
web_search_requests?: number
|
||||
web_fetch_requests?: number
|
||||
}
|
||||
speed?: 'standard' | 'fast'
|
||||
}
|
||||
|
||||
export type AssistantMessageContent = {
|
||||
model: string
|
||||
id?: string
|
||||
type: 'message'
|
||||
role: 'assistant'
|
||||
content: ContentBlock[]
|
||||
usage: ApiUsage
|
||||
stop_reason?: string
|
||||
}
|
||||
|
||||
export type JournalEntry = {
|
||||
type: string
|
||||
uuid?: string
|
||||
parentUuid?: string | null
|
||||
timestamp?: string
|
||||
sessionId?: string
|
||||
cwd?: string
|
||||
version?: string
|
||||
gitBranch?: string
|
||||
promptId?: string
|
||||
message?: AssistantMessageContent | { role: 'user'; content: string | ContentBlock[] }
|
||||
isSidechain?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ToolCall = {
|
||||
tool: string
|
||||
file?: string
|
||||
command?: string
|
||||
}
|
||||
|
||||
// ── Rich decode output ─────────────────────────────────────────────────
|
||||
|
||||
export type ToolResultMeta = {
|
||||
locAdded: number
|
||||
locRemoved: number
|
||||
interrupted: boolean
|
||||
userModified: boolean
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
// Session-level accumulator: last `ai-title` wins, `pr-link` URLs accumulate,
|
||||
// and any sidechain entry flips `isSidechain`. parentUuid is deliberately not
|
||||
// captured as a session link — it references an intra-file entry uuid, not
|
||||
// another session's id, so it cannot reliably connect two sessions.
|
||||
export type SessionMeta = {
|
||||
title?: string
|
||||
prLinks: string[]
|
||||
isSidechain: boolean
|
||||
// Sidechain side: the parent session id (a sidechain entry's internal
|
||||
// `sessionId`, which is the spawning session). First non-empty value wins.
|
||||
parentSessionId?: string
|
||||
// Parent side: agentId -> the `tool_use` id of the `Agent`/`Task` block that
|
||||
// spawned it, read from the spawn result's `toolUseResult.agentId`. First value
|
||||
// per agentId wins. Empty for sessions that spawned no completed subagent.
|
||||
agentSpawnLinks: Record<string, string>
|
||||
// Parent side: agent ids whose spawn result named them but whose exact launching
|
||||
// tool_use could not be paired (an ambiguous multi-result record). Drives the
|
||||
// grace-window fallback for a late child. Deduped.
|
||||
ambiguousSpawnAgentIds: string[]
|
||||
}
|
||||
|
||||
// The rich decode of one Claude assistant message (or one advisor iteration),
|
||||
// pre-pricing. Mirrors the host's ParsedApiCall minus every priced field: no
|
||||
// costUSD, no local-savings, and bash commands kept as their RAW input strings
|
||||
// (`rawBashCommands`) so the host can normalize them with its own ANSI-aware
|
||||
// splitter. The CLI adapter maps this into ParsedApiCall by pricing and
|
||||
// splitting.
|
||||
export type DecodedCall = {
|
||||
provider: 'claude'
|
||||
model: string
|
||||
usage: TokenUsage
|
||||
tools: string[]
|
||||
mcpTools: string[]
|
||||
skills: string[]
|
||||
subagentTypes: string[]
|
||||
hasAgentSpawn: boolean
|
||||
hasPlanMode: boolean
|
||||
speed: 'standard' | 'fast'
|
||||
timestamp: string
|
||||
// Raw `command` strings from this call's bash-family tool_use blocks, in order.
|
||||
// The host splits each into individual commands; kept raw here to stay free of
|
||||
// the CLI's strip-ansi dependency.
|
||||
rawBashCommands: string[]
|
||||
deduplicationKey: string
|
||||
cacheCreationOneHourTokens?: number
|
||||
toolSequence?: ToolCall[][]
|
||||
spawnToolUseIds?: string[]
|
||||
locAdded?: number
|
||||
locRemoved?: number
|
||||
interrupted?: boolean
|
||||
userModified?: boolean
|
||||
toolErrors?: number
|
||||
}
|
||||
|
||||
// The rich decode of one turn: a user message and the assistant calls it
|
||||
// prompted, with per-turn git branch / PR refs / subagent spawn ids.
|
||||
export type DecodedTurn = {
|
||||
userMessage: string
|
||||
assistantCalls: DecodedCall[]
|
||||
timestamp: string
|
||||
sessionId: string
|
||||
gitBranch?: string
|
||||
prRefs?: string[]
|
||||
spawnToolUseIds?: string[]
|
||||
}
|
||||
|
|
@ -6,6 +6,18 @@ import { describe, expect, it } from 'vitest'
|
|||
|
||||
import { DiagnosticDetail } from '../src/diagnostics.js'
|
||||
import { ObservationEnvelope } from '../src/observations.js'
|
||||
import { OBSERVATION_SCHEMA_VERSION } from '../src/schema.js'
|
||||
import {
|
||||
collectSessionMeta,
|
||||
collectToolResultMeta,
|
||||
compactEntry,
|
||||
dedupeStreamingMessageIds,
|
||||
emptySessionMeta,
|
||||
groupIntoTurns,
|
||||
parseJsonlLine,
|
||||
toObservations,
|
||||
} from '../src/providers/claude/index.js'
|
||||
import type { JournalEntry, ToolResultMeta } from '../src/providers/claude/index.js'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const goldenEnvelope = JSON.parse(
|
||||
|
|
@ -104,6 +116,90 @@ describe('content-smuggling guardrail: accepted output is secret-free', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('content-smuggling guardrail: real claude decode -> toObservations is secret-free', () => {
|
||||
// A hostile Claude transcript planting every secret in the free-text fields a
|
||||
// real decode captures: the user prompt, a bash command, a Read file_path, the
|
||||
// ai-title, the cwd, the git branch, and the project path. Decoding it fully
|
||||
// and minimizing MUST surface none of them.
|
||||
function decodeSession() {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: '2026-07-17T10:00:00.000Z',
|
||||
sessionId: 'sess-hostile',
|
||||
cwd: SECRETS.absPath,
|
||||
gitBranch: 'feature/secret-plan',
|
||||
// prompt, apiKey and file content all planted in the captured user text.
|
||||
message: { role: 'user', content: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}` },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: '2026-07-17T10:00:05.000Z',
|
||||
sessionId: 'sess-hostile',
|
||||
gitBranch: 'feature/secret-plan',
|
||||
message: {
|
||||
id: 'msg-hostile-1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-opus-4-8',
|
||||
usage: { input_tokens: 1200, output_tokens: 340, cache_read_input_tokens: 800, cache_creation_input_tokens: 120 },
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'tu1', name: 'Bash', input: { command: SECRETS.commandLine } },
|
||||
{ type: 'tool_use', id: 'tu2', name: 'Read', input: { file_path: SECRETS.absPath } },
|
||||
// A hostile tool NAME carrying a command line (spaces + slashes): it
|
||||
// fails the canonical charset and must be dropped, not emitted.
|
||||
{ type: 'tool_use', id: 'tu3', name: SECRETS.commandLine, input: {} },
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({ type: 'ai-title', sessionId: 'sess-hostile', aiTitle: SECRETS.prompt }),
|
||||
]
|
||||
|
||||
const raw = lines.map(l => parseJsonlLine(l)).filter((e): e is JournalEntry => e !== null)
|
||||
const meta = emptySessionMeta()
|
||||
const toolResultMeta = new Map<string, ToolResultMeta>()
|
||||
for (const entry of raw) {
|
||||
collectToolResultMeta(entry, toolResultMeta)
|
||||
collectSessionMeta(entry, meta)
|
||||
}
|
||||
const compacted = raw.map(compactEntry)
|
||||
const turns = groupIntoTurns(dedupeStreamingMessageIds(compacted), new Set<string>(), toolResultMeta)
|
||||
return { turns, meta }
|
||||
}
|
||||
|
||||
function buildEnvelope() {
|
||||
const { turns, meta } = decodeSession()
|
||||
const { sessions } = toObservations(
|
||||
{ sessionId: 'sess-hostile', projectPath: SECRETS.absPath, gitBranch: 'feature/secret-plan', isSidechain: meta.isSidechain, turns },
|
||||
{ privacyKey: 'test-privacy-key', provider: 'claude' },
|
||||
)
|
||||
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(buildEnvelope()).success).toBe(true)
|
||||
})
|
||||
|
||||
it('the serialized envelope contains none of the planted secrets', () => {
|
||||
const serialized = JSON.stringify(buildEnvelope())
|
||||
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 = buildEnvelope()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('content-smuggling guardrail: diagnostic detail rejects paths', () => {
|
||||
it('rejects an absolute path', () => {
|
||||
expect(DiagnosticDetail.safeParse(SECRETS.absPath).success).toBe(false)
|
||||
|
|
|
|||
101
packages/core/tests/providers/claude-decode.test.ts
Normal file
101
packages/core/tests/providers/claude-decode.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
compactEntry,
|
||||
decodeAdvisorCalls,
|
||||
decodeAssistantCall,
|
||||
dedupeStreamingMessageIds,
|
||||
groupIntoTurns,
|
||||
parseJsonlLine,
|
||||
} from '../../src/providers/claude/index.js'
|
||||
import type { JournalEntry } from '../../src/providers/claude/index.js'
|
||||
|
||||
function assistantLine(id: string, ts: string, opts: { model?: string; input?: number; output?: number; content?: unknown[]; speed?: string } = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: ts,
|
||||
sessionId: 's1',
|
||||
message: {
|
||||
id,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: opts.model ?? 'claude-opus-4-8',
|
||||
usage: { input_tokens: opts.input ?? 100, output_tokens: opts.output ?? 20, cache_read_input_tokens: 5, ...(opts.speed ? { speed: opts.speed } : {}) },
|
||||
content: opts.content ?? [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function userLine(text: string, ts: string): string {
|
||||
return JSON.stringify({ type: 'user', timestamp: ts, sessionId: 's1', message: { role: 'user', content: text } })
|
||||
}
|
||||
|
||||
describe('claude rich decode (moved to @codeburn/core)', () => {
|
||||
it('decodes an assistant message into a cost-free DecodedCall carrying tokens/tools/meta', () => {
|
||||
const entry = parseJsonlLine(assistantLine('msg_1', '2026-07-17T10:00:00.000Z', {
|
||||
content: [
|
||||
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls -la' } },
|
||||
{ type: 'tool_use', id: 't2', name: 'mcp__server__do', input: {} },
|
||||
],
|
||||
}))!
|
||||
const call = decodeAssistantCall(compactEntry(entry))!
|
||||
expect(call.provider).toBe('claude')
|
||||
expect(call.model).toBe('claude-opus-4-8')
|
||||
expect(call.usage.inputTokens).toBe(100)
|
||||
expect(call.usage.outputTokens).toBe(20)
|
||||
expect(call.usage.cacheReadInputTokens).toBe(5)
|
||||
expect(call.tools).toEqual(['Bash', 'mcp__server__do'])
|
||||
expect(call.mcpTools).toEqual(['mcp__server__do'])
|
||||
expect(call.rawBashCommands).toEqual(['ls -la'])
|
||||
// No pricing crosses into the decode layer.
|
||||
expect(call as Record<string, unknown>).not.toHaveProperty('costUSD')
|
||||
expect(call as Record<string, unknown>).not.toHaveProperty('savingsUSD')
|
||||
})
|
||||
|
||||
it('returns null for non-assistant entries and messages without usage/model', () => {
|
||||
expect(decodeAssistantCall({ type: 'user', message: { role: 'user', content: 'hi' } } as JournalEntry)).toBeNull()
|
||||
expect(decodeAssistantCall({ type: 'assistant', message: { type: 'message', role: 'assistant', content: [] } } as unknown as JournalEntry)).toBeNull()
|
||||
})
|
||||
|
||||
it('emits advisor_message iterations as separate cost-free calls under their own model', () => {
|
||||
const raw = JSON.parse(assistantLine('msg_2', '2026-07-17T10:01:00.000Z')) as JournalEntry
|
||||
;(raw.message as { usage: Record<string, unknown> }).usage.iterations = [
|
||||
{ type: 'advisor_message', model: 'claude-sonnet-4', input_tokens: 40, output_tokens: 8 },
|
||||
{ type: 'message', model: 'ignore-me', input_tokens: 999 },
|
||||
]
|
||||
const advisors = decodeAdvisorCalls(raw)
|
||||
expect(advisors).toHaveLength(1)
|
||||
expect(advisors[0]!.model).toBe('claude-sonnet-4')
|
||||
expect(advisors[0]!.usage.inputTokens).toBe(40)
|
||||
expect(advisors[0]!.deduplicationKey).toBe('msg_2:advisor:0')
|
||||
})
|
||||
|
||||
it('groups user/assistant entries into turns and dedupes streamed message ids', () => {
|
||||
const lines = [
|
||||
userLine('first task', '2026-07-17T10:00:00.000Z'),
|
||||
assistantLine('msg_a', '2026-07-17T10:00:01.000Z'),
|
||||
assistantLine('msg_a', '2026-07-17T10:00:02.000Z'), // streamed restatement of same id
|
||||
userLine('second task', '2026-07-17T10:05:00.000Z'),
|
||||
assistantLine('msg_b', '2026-07-17T10:05:01.000Z'),
|
||||
]
|
||||
const entries = lines.map(l => compactEntry(parseJsonlLine(l)!))
|
||||
const turns = groupIntoTurns(dedupeStreamingMessageIds(entries), new Set<string>())
|
||||
expect(turns).toHaveLength(2)
|
||||
expect(turns[0]!.userMessage).toBe('first task')
|
||||
expect(turns[0]!.assistantCalls).toHaveLength(1) // streamed dup collapsed
|
||||
expect(turns[1]!.userMessage).toBe('second task')
|
||||
})
|
||||
|
||||
it('scans a >32KB assistant line via the buffer path identically to JSON.parse', () => {
|
||||
const bigCommand = 'x'.repeat(40 * 1024)
|
||||
const line = assistantLine('msg_big', '2026-07-17T10:10:00.000Z', {
|
||||
content: [{ type: 'tool_use', id: 'tb', name: 'Bash', input: { command: bigCommand } }],
|
||||
})
|
||||
expect(line.length).toBeGreaterThan(32 * 1024)
|
||||
const fromBuffer = parseJsonlLine(Buffer.from(line, 'utf-8'))!
|
||||
const call = decodeAssistantCall(compactEntry(fromBuffer))!
|
||||
expect(call.model).toBe('claude-opus-4-8')
|
||||
// command captured but capped at 2000 chars by the large-line scanner.
|
||||
expect(call.rawBashCommands[0]!.length).toBe(2000)
|
||||
})
|
||||
})
|
||||
|
|
@ -10,6 +10,7 @@ export default defineConfig({
|
|||
'src/diagnostics.ts',
|
||||
'src/fingerprint.ts',
|
||||
'src/contracts.ts',
|
||||
'src/providers/claude/index.ts',
|
||||
],
|
||||
format: ['esm'],
|
||||
target: 'node20',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue