diff --git a/packages/cli/src/providers/kiro.ts b/packages/cli/src/providers/kiro.ts index 75037d70..19395702 100644 --- a/packages/cli/src/providers/kiro.ts +++ b/packages/cli/src/providers/kiro.ts @@ -4,9 +4,22 @@ import { readdir, readFile, stat } from 'fs/promises' import { basename, dirname, extname, join } from 'path' import { homedir } from 'os' +import type { DecodeContext } from '@codeburn/core' +import type { + KiroCliEntry, + KiroCliSessionMeta, + KiroDecodedCall, + KiroV2SessionMeta, +} from '@codeburn/core/providers/kiro' +import { + decodeKiroIdeFile, + decodeKiroCliSession, + decodeKiroV2Session, + finishKiroWorkspaceSession, + kiroToolNameMap, + prepareKiroWorkspaceSession, +} from '@codeburn/core/providers/kiro' import { readSessionFile } from '../fs-utils.js' -import { estimateTokensFromChars } from '../token-estimate.js' -import type { ToolCall } from '../types.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' // Kiro bills in credits: individual plans are $20/mo for 1,000 credits and @@ -14,8 +27,6 @@ import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from // public overage rate (the marginal price), mirroring the Codebuff provider's // USD_PER_CREDIT approach, so we never understate real cost. const USD_PER_KIRO_CREDIT = 0.04 -const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000 -const MODERN_CONVERSATION_KEYS = ['messages', 'conversation', 'chat', 'transcript', 'entries', 'events'] const modelDisplayNames: Record = { 'claude-sonnet-4-6': 'Sonnet 4.6', @@ -29,819 +40,73 @@ const modelDisplayNames: Record = { const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length) -const toolNameMap: Record = { - readFile: 'Read', - read_file: 'Read', - read: 'Read', - writeFile: 'Edit', - write_file: 'Edit', - write: 'Edit', - editFile: 'Edit', - edit_file: 'Edit', - createFile: 'Write', - create_file: 'Write', - deleteFile: 'Delete', - listDir: 'LS', - list_dir: 'LS', - openFolders: 'LS', - runCommand: 'Bash', - run_command: 'Bash', - shell: 'Bash', - executeBash: 'Bash', - searchFiles: 'Grep', - search_files: 'Grep', - grep: 'Grep', - grepSearch: 'Grep', - findFiles: 'Glob', - find_files: 'Glob', - glob: 'Glob', - fileSearch: 'Glob', - webSearch: 'WebSearch', - web_search: 'WebSearch', - web_fetch: 'WebFetch', - fsWrite: 'Edit', - strReplace: 'Edit', - listDirectory: 'LS', - code: 'Read', - subagent: 'Agent', -} - -type KiroChatMessage = { - role: 'human' | 'bot' | 'tool' - content: string -} - -type KiroChatFile = { - executionId: string - actionId: string - chat: KiroChatMessage[] - metadata: { - modelId: string - modelProvider: string - workflow: string - workflowId: string - startTime: number - endTime: number - } -} - -type KiroModernExecution = Record - -function normalizeModelId(raw: string): string { - return raw.replace(/(\d+)\.(\d+)/g, '$1-$2') -} - -function extractToolNames(content: string): string[] { - const tools: string[] = [] - const regex = /\s*([^<]+)<\/name>/g - let match - while ((match = regex.exec(content)) !== null) { - const name = match[1]!.trim() - tools.push(toolNameMap[name] ?? name) - } - return tools -} - function asRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null } -function stringField(record: Record | null, names: string[]): string { - if (!record) return '' - for (const name of names) { - const value = record[name] - if (typeof value === 'string' && value.trim()) return value.trim() - } - return '' -} - -function timeField(record: Record | null, names: string[]): number | string | undefined { - if (!record) return undefined - for (const name of names) { - const value = record[name] - if (typeof value === 'number' || typeof value === 'string') return value - } - return undefined -} - -function parseKiroTimestamp(value: number | string | undefined): Date | null { - if (value === undefined) return null - - let parsed: number | string = value - if (typeof value === 'string') { - const trimmed = value.trim() - if (!trimmed) return null - parsed = /^-?\d+(\.\d+)?$/.test(trimmed) ? Number(trimmed) : trimmed - } - - if (typeof parsed === 'number') { - if (!Number.isFinite(parsed)) return null - const ms = parsed < MIN_REASONABLE_TIMESTAMP_MS ? parsed * 1000 : parsed - const date = new Date(ms) - return Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS ? null : date - } - - const date = new Date(parsed) - return Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS ? null : date -} - -function textField(record: Record | null, names: string[]): string { - if (!record) return '' - for (const name of names) { - const text = extractText(record[name]) - if (text) return text - } - return '' -} - -function extractText(value: unknown): string { - if (typeof value === 'string') return value - if (Array.isArray(value)) return value.map(extractText).filter(Boolean).join('\n') - const record = asRecord(value) - if (!record) return '' - for (const key of ['content', 'text', 'message', 'value', 'parts', 'entries']) { - const text = extractText(record[key]) - if (text) return text - } - return '' -} - -function messageRole(value: unknown): string { - const record = asRecord(value) - if (!record) return '' - return stringField(record, ['role', 'type', 'author']).toLowerCase() -} - -function extractStructuredToolNames(value: unknown, text: string, options: { includeDirectName?: boolean } = {}): string[] { - const tools = extractToolNames(text) - const record = asRecord(value) - if (!record) return tools - - if (options.includeDirectName ?? true) { - const directName = stringField(record, ['toolName', 'name']) - if (directName) tools.push(toolNameMap[directName] ?? directName) - } - - for (const key of ['toolCalls', 'tool_calls', 'tools']) { - const entries = record[key] - if (!Array.isArray(entries)) continue - for (const entry of entries) { - const name = stringField(asRecord(entry), ['name', 'toolName', 'tool_name']) - if (name) tools.push(toolNameMap[name] ?? name) - } - } - - return tools -} - -function parseChatFile(data: KiroChatFile, sessionId: string, project: string, seenKeys: Set): ParsedProviderCall[] { - const results: ParsedProviderCall[] = [] - const { chat, metadata } = data - - let modelId = normalizeModelId(metadata.modelId ?? '') - if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' - - let pendingUserMessage = '' - const allTools: string[] = [] - const toolSequence: ToolCall[][] = [] - - for (const msg of chat) { - if (msg.role === 'human') { - if (msg.content.startsWith('')) continue - pendingUserMessage = msg.content.slice(0, 500) - } - if (msg.role === 'bot') { - const msgTools = extractToolNames(msg.content) - allTools.push(...msgTools) - if (msgTools.length > 0) toolSequence.push(msgTools.map(t => ({ tool: t }))) - } - } - - const botMessages = chat.filter(m => m.role === 'bot' && m.content.length > 0) - const totalOutputChars = botMessages.reduce((sum, m) => sum + m.content.length, 0) - if (totalOutputChars === 0) return results - - const dedupKey = `kiro:${sessionId}:${data.executionId}` - if (seenKeys.has(dedupKey)) return results - - const outputTokens = estimateTokensFromChars(totalOutputChars) - const inputTokens = estimateTokensFromChars(pendingUserMessage.length) - const tsDate = parseKiroTimestamp(metadata.startTime) - if (!tsDate) return results - const timestamp = tsDate.toISOString() - seenKeys.add(dedupKey) - - results.push({ - provider: 'kiro', - model: modelId, - inputTokens, - outputTokens, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens: 0, - webSearchRequests: 0, - costBasis: 'estimated', - costIsEstimated: true, - tools: [...new Set(allTools)], - bashCommands: [], - toolSequence: toolSequence.length > 1 ? toolSequence : undefined, - timestamp, - speed: 'standard', - deduplicationKey: dedupKey, - userMessage: pendingUserMessage, - sessionId, - }) - - return results -} - -function parseModernExecution(data: KiroModernExecution, sourcePath: string, seenKeys: Set): ParsedProviderCall[] { - const results: ParsedProviderCall[] = [] - if (Array.isArray(data['executions'])) return results - - const metadata = asRecord(data['metadata']) - const modelObj = asRecord(data['model']) - let modelId = normalizeModelId( - stringField(data, ['modelId', 'modelID', 'modelName', 'model']) || - stringField(modelObj, ['id', 'name']) || - stringField(metadata, ['modelId', 'modelID', 'modelName']), - ) - if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' - - const executionId = stringField(data, ['executionId', 'id']) || basename(sourcePath) - const sessionId = stringField(data, ['sessionId', 'chatSessionId', 'conversationId', 'workflowId']) || - stringField(metadata, ['workflowId', 'sessionId']) || - basename(dirname(sourcePath)) || - executionId - - let inputChars = 0 - let outputChars = 0 - let pendingUserMessage = '' - const allTools: string[] = [] - let hasOutputActivity = false - const directInput = textField(data, ['prompt', 'input', 'userMessage', 'user_message', 'request']) - const directOutput = textField(data, ['response', 'output', 'assistantMessage', 'assistant_message', 'result']) - const directTools = extractStructuredToolNames(data, directOutput, { includeDirectName: false }) - - if (directInput) { - inputChars += directInput.length - pendingUserMessage = directInput.slice(0, 500) - } - - if (directOutput) { - outputChars += directOutput.length - hasOutputActivity = true - } - - if (directTools.length > 0) { - hasOutputActivity = true - allTools.push(...directTools) - } - - // Check both data.context[key] and data[key] for conversation arrays. - // Kiro IDE stores messages at data.context.messages in current builds. - const context = asRecord(data['context']) - const conversationSources = context ? [context, data] : [data] - - for (const source of conversationSources) { - let found = false - for (const key of MODERN_CONVERSATION_KEYS) { - const messages = (source as Record)[key] - if (!Array.isArray(messages)) continue - - for (const message of messages) { - const text = extractText(message) - const role = messageRole(message) - const tools = extractStructuredToolNames(message, text) - - if (role === 'human' || role === 'user') { - if (!text) continue - inputChars += text.length - pendingUserMessage = text.slice(0, 500) - } else if (role === 'bot' || role === 'assistant' || role === 'ai' || role === 'model') { - if (text) outputChars += text.length - if (text || tools.length > 0) hasOutputActivity = true - allTools.push(...tools) - } else if (role === 'tool' || role === 'system') { - if (text) inputChars += text.length - allTools.push(...tools) - } - } - found = true - break - } - if (found) break - } - - // Extract tools and metered credits from usageSummary (reliable structured - // data in current Kiro builds). usageSummary is an array of per-turn entries - // with optional usedTools and usage (credits, unit "credit") fields — the - // v1 predecessor of v2's usage_summary.promptTurnSummaries. - let executionCredits = 0 - const usageSummary = data['usageSummary'] - if (Array.isArray(usageSummary)) { - for (const entry of usageSummary) { - const rec = asRecord(entry) - if (!rec) continue - const usage = rec['usage'] - if (typeof usage === 'number' && Number.isFinite(usage)) executionCredits += usage - const usedTools = rec['usedTools'] - if (Array.isArray(usedTools)) { - for (const tool of usedTools) { - if (typeof tool === 'string' && tool) { - // Strip mcp_ prefix for cleaner display (e.g. mcp_aws_sentral_mcp_search_accounts -> aws_sentral_mcp_search_accounts) - const cleaned = tool.startsWith('mcp_') ? tool.slice(4) : tool - allTools.push(toolNameMap[cleaned] ?? cleaned) - hasOutputActivity = true - } - } - } - } - } - - if (!hasOutputActivity) return results - - const dedupKey = `kiro:${sessionId}:${executionId}` - if (seenKeys.has(dedupKey)) return results - - const rawStartTime = timeField(data, ['startTime', 'createdAt', 'timestamp']) ?? - timeField(metadata, ['startTime', 'createdAt', 'timestamp']) - const tsDate = parseKiroTimestamp(rawStartTime) - if (!tsDate) return results - - const inputTokens = estimateTokensFromChars(inputChars) - const outputTokens = estimateTokensFromChars(outputChars) - // Prefer real metered credits at the public overage rate; fall back to - // token-estimated pricing when the execution has no usage data — same - // contract as the CLI and v2 parsers. - seenKeys.add(dedupKey) - - results.push({ - provider: 'kiro', - model: modelId, - inputTokens, - outputTokens, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens: 0, - webSearchRequests: 0, - ...(executionCredits > 0 - ? { costUSD: executionCredits * USD_PER_KIRO_CREDIT, costBasis: 'measured' as const } +export function toProviderCall(rich: KiroDecodedCall): ParsedProviderCall { + const base = { + provider: 'kiro' as const, + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + ...(rich.credits > 0 + ? { costUSD: rich.credits * USD_PER_KIRO_CREDIT, costBasis: 'measured' as const } : { costBasis: 'estimated' as const }), - costIsEstimated: executionCredits === 0, - tools: [...new Set(allTools)], - bashCommands: [], - timestamp: tsDate.toISOString(), - speed: 'standard', - deduplicationKey: dedupKey, - userMessage: pendingUserMessage, - sessionId, - }) - - return results -} - -// --- Kiro CLI session types & parser --- - -type KiroCliEntry = { - version: string - kind: 'Prompt' | 'AssistantMessage' | 'ToolResults' | 'Clear' - data: Record -} - -type KiroCliSessionMeta = { - session_id: string - cwd: string - created_at: string - updated_at: string - title?: string - session_state?: { - rts_model_state?: { model_info?: { model_id?: string } } - conversation_metadata?: { - user_turn_metadatas?: Array<{ - end_timestamp?: string - builtin_tool_uses?: number - metering_usage?: Array<{ value: number; unit: string }> - total_request_count?: number - }> - } - } -} - -function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seenKeys: Set): ParsedProviderCall[] { - const results: ParsedProviderCall[] = [] - const sessionId = meta.session_id - const project = basename(meta.cwd || '') - - let modelId = meta.session_state?.rts_model_state?.model_info?.model_id ?? 'auto' - if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' - else modelId = normalizeModelId(modelId) - - const turns = meta.session_state?.conversation_metadata?.user_turn_metadatas ?? [] - - // Walk through JSONL entries grouping by prompt turns - let turnIndex = 0 - let pendingUserMessage = '' - let outputChars = 0 - let inputChars = 0 - const allTools: string[] = [] - let turnStartTimestamp: string | undefined - - function flushTurn() { - if (outputChars === 0) return - const turnMeta = turns[turnIndex] - const dedupKey = `kiro-cli:${sessionId}:${turnIndex}` - if (seenKeys.has(dedupKey)) { turnIndex++; return } - - const timestamp = turnMeta?.end_timestamp ?? turnStartTimestamp ?? meta.created_at - const tsDate = parseKiroTimestamp(timestamp) - if (!tsDate) { turnIndex++; return } - - const inputTokens = estimateTokensFromChars(inputChars) - const outputTokens = estimateTokensFromChars(outputChars) - // metering_usage values are credits (unit: "credit"), not dollars — - // convert at the public overage rate. Gate on credits > 0, not array - // presence: real sessions carry empty metering_usage arrays (turn still - // in flight when the meta was written), which must fall back to - // token-estimated pricing — same contract as the v1-execution and v2 - // parsers. - const turnCredits = turnMeta?.metering_usage - ? turnMeta.metering_usage.reduce((sum, m) => sum + m.value, 0) - : 0 - seenKeys.add(dedupKey) - - results.push({ - provider: 'kiro', - model: modelId, - inputTokens, - outputTokens, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens: 0, - webSearchRequests: 0, - ...(turnCredits > 0 - ? { costUSD: turnCredits * USD_PER_KIRO_CREDIT, costBasis: 'measured' as const } - : { costBasis: 'estimated' as const }), - costIsEstimated: turnCredits === 0, - tools: [...new Set(allTools)], - bashCommands: [], - timestamp: tsDate.toISOString(), - speed: 'standard', - deduplicationKey: dedupKey, - userMessage: pendingUserMessage, - sessionId, - project, - }) - turnIndex++ - } - - let isFirstPrompt = true - for (const entry of entries) { - if (entry.kind === 'Prompt') { - if (!isFirstPrompt) { - flushTurn() - pendingUserMessage = '' - outputChars = 0 - inputChars = 0 - allTools.length = 0 - } - isFirstPrompt = false - const content = entry.data['content'] - if (Array.isArray(content)) { - for (const item of content) { - const rec = asRecord(item) - if (rec && rec['kind'] === 'text' && typeof rec['data'] === 'string') { - pendingUserMessage = (rec['data'] as string).slice(0, 500) - inputChars += (rec['data'] as string).length - } - } - } - const meta2 = asRecord(entry.data['meta']) - if (meta2) { - const ts = meta2['timestamp'] - if (typeof ts === 'number') turnStartTimestamp = new Date(ts * 1000).toISOString() - } - } else if (entry.kind === 'AssistantMessage') { - const content = entry.data['content'] - if (Array.isArray(content)) { - for (const item of content) { - const rec = asRecord(item) - if (!rec) continue - if (rec['kind'] === 'text' && typeof rec['data'] === 'string') { - outputChars += (rec['data'] as string).length - } else if (rec['kind'] === 'toolUse') { - const toolData = asRecord(rec['data']) - if (toolData) { - const name = typeof toolData['name'] === 'string' ? toolData['name'] : '' - if (name) allTools.push(toolNameMap[name] ?? name) - } - } - } - } - } else if (entry.kind === 'ToolResults') { - // Tool results count as input context - const content = entry.data['content'] - if (Array.isArray(content)) { - for (const item of content) { - const text = extractText(item) - if (text) inputChars += text.length - } - } - } - } - // Flush last turn - flushTurn() - - return results -} - -// --- Kiro IDE workspace-session parser (workspace-sessions//.json) --- -// Newer v1-era Kiro builds store session state here: history[] carries user prompts -// and assistant messages, some of which are stubs referencing per-execution files -// (parsed separately by parseModernExecution). -async function parseWorkspaceSession(record: Record, source: SessionSource, seenKeys: Set): Promise { - const results: ParsedProviderCall[] = [] - const historyArr = record['history'] - if (!Array.isArray(historyArr) || typeof record['sessionId'] !== 'string') return results - - const sessionId = record['sessionId'] - const modelRaw = stringField(record, ['selectedModel']) - let modelId = normalizeModelId(modelRaw) - if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' - - let inputChars = 0 - let outputChars = 0 - let pendingUserMessage = '' - const allTools: string[] = [] - let hasExecutionRefs = false - let hasRealAssistantContent = false - - for (const item of historyArr) { - const rec = asRecord(item) - if (!rec) continue - - // Track if this session references execution files (which are parsed separately) - const execBacked = typeof rec['executionId'] === 'string' - if (execBacked) hasExecutionRefs = true - - const msg = asRecord(rec['message']) - if (!msg) continue - const role = stringField(msg, ['role']) - const text = extractText(msg['content']) - if (role === 'user' && text) { - inputChars += text.length - pendingUserMessage = text.slice(0, 500) - } else if (role === 'assistant' && !execBacked && text && text !== 'On it.') { - // An item carrying an executionId is execution-backed: its content is - // counted from the execution file, so counting it here would double-count. - // 'On it.' is the observed placeholder text Kiro writes for such stubs - // when the executionId rides a separate history item. - outputChars += text.length - hasRealAssistantContent = true - } - } - - // Skip workspace-session entries that are pure execution stubs: - // they reference executionIds (parsed separately as execution files) - // and have no real assistant content beyond "On it." placeholders. - // This avoids double-counting input tokens from both paths. - if (hasExecutionRefs && !hasRealAssistantContent) return results - - // Skip sessions with no meaningful content - if (inputChars === 0 && outputChars === 0) return results - - // Use file mtime as timestamp (workspace-session files don't carry startTime). - // No stat means no usable timestamp: drop the call like the other parse paths. - let timestamp: string - try { - const s = await stat(source.path) - timestamp = new Date(s.mtimeMs).toISOString() - } catch { - return results - } - - const dedupKey = `kiro:ws-session:${sessionId}` - if (seenKeys.has(dedupKey)) return results - seenKeys.add(dedupKey) - - const inputTokens = estimateTokensFromChars(inputChars) - const outputTokens = estimateTokensFromChars(outputChars) - - results.push({ - provider: 'kiro', - model: modelId, - inputTokens, - outputTokens, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens: 0, - webSearchRequests: 0, - costBasis: 'estimated', - costIsEstimated: true, - tools: [...new Set(allTools)], - bashCommands: [], - timestamp, - speed: 'standard', - deduplicationKey: dedupKey, - userMessage: pendingUserMessage, - sessionId, - }) - - return results -} - -// --- Kiro IDE v2 session types & parser (~/.kiro/sessions//sess_*/) --- -// v2 is a self-contained, event-sourced store: one directory per session holding -// session.json (metadata incl. the real modelId) + messages.jsonl (append-only -// event log). Unlike v1 it does NOT use separate execution files — assistant -// content is inline. Usage is billed in credits with no token counts, so tokens -// and cost are estimated from transcript text priced at the real model. - -type KiroV2SessionMeta = { - id?: string - title?: string - modelId?: string - workspacePaths?: string[] - createdAt?: string - lastModifiedAt?: string -} - -async function parseV2Session(source: SessionSource, seenKeys: Set): Promise { - const results: ParsedProviderCall[] = [] - - const content = await readSessionFile(source.path) - if (content === null) return results - - // Companion session.json carries the real model + session metadata. - let meta: KiroV2SessionMeta = {} - try { - const raw = await readFile(join(dirname(source.path), 'session.json'), 'utf-8') - meta = JSON.parse(raw) as KiroV2SessionMeta - } catch { /* fall back to defaults below */ } - - const sessionId = (typeof meta.id === 'string' && meta.id) || basename(dirname(source.path)) - let modelId = normalizeModelId(meta.modelId ?? '') - if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' - - // In-flight turn state. A turn spans turn_start..turn_end sharing an - // executionId; the preceding `user` event carries its prompt. - let pendingUserMessage = '' - let pendingUserChars = 0 - - let inTurn = false - let execId = '' - let turnStartTs: string | undefined - let outputChars = 0 - let reasoningChars = 0 - let toolResultChars = 0 - let turnCredits = 0 - let turnUserMessage = '' - let turnUserChars = 0 - const tools: string[] = [] - - const resetTurn = () => { - inTurn = false; execId = ''; turnStartTs = undefined - outputChars = 0; reasoningChars = 0; toolResultChars = 0; turnCredits = 0 - turnUserMessage = ''; turnUserChars = 0 - tools.length = 0 - } - - const pushTool = (raw: string) => { - if (!raw) return - // Strip mcp_ prefix for cleaner display, matching the modern-execution parser. - const cleaned = raw.startsWith('mcp_') ? raw.slice(4) : raw - tools.push(toolNameMap[cleaned] ?? cleaned) - } - - const flushTurn = () => { - if (!inTurn) { resetTurn(); return } - const hasActivity = outputChars > 0 || reasoningChars > 0 || tools.length > 0 - if (hasActivity) { - const dedupKey = `kiro-v2:${sessionId}:${execId || String(results.length)}` - const tsDate = parseKiroTimestamp(turnStartTs) - if (tsDate && !seenKeys.has(dedupKey)) { - seenKeys.add(dedupKey) - // Tool results are fed back to the model as input context, so count them - // toward input tokens — same treatment as the CLI parser's ToolResults. - const inputTokens = estimateTokensFromChars(turnUserChars + toolResultChars) - // outputTokens and reasoningTokens are kept disjoint — downstream - // aggregation (models-report, audit-report, parser) sums the two - // fields, so folding reasoning into outputTokens would double-count. - const reasoningTokens = estimateTokensFromChars(reasoningChars) - const outputTokens = estimateTokensFromChars(outputChars) - // Prefer real metered credits (converted at the public overage rate) - // over token estimation; fall back to token pricing when the turn has - // no usage_summary (e.g. still in progress or null usage). Reasoning - // text is billed as output, so combine it for pricing only (same as - // the codex provider). - results.push({ - provider: 'kiro', - model: modelId, - inputTokens, - outputTokens, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens, - webSearchRequests: 0, - ...(turnCredits > 0 - ? { costUSD: turnCredits * USD_PER_KIRO_CREDIT, costBasis: 'measured' as const } - : { costBasis: 'estimated' as const }), - costIsEstimated: turnCredits === 0, - tools: [...new Set(tools)], - bashCommands: [], - timestamp: tsDate.toISOString(), - speed: 'standard', - deduplicationKey: dedupKey, - userMessage: turnUserMessage, - sessionId, - project: source.project, - }) - } - } - resetTurn() - } - - for (const line of content.split('\n')) { - if (!line.trim()) continue - let evt: Record - try { evt = JSON.parse(line) as Record } catch { continue } - const payload = asRecord(evt['payload']) - if (!payload) continue - const type = stringField(payload, ['type']) - const ts = typeof evt['timestamp'] === 'string' ? evt['timestamp'] as string : undefined - - if (type === 'user') { - // New prompt: close any in-flight turn defensively, then stash the text - // for the upcoming turn_start. - if (inTurn) flushTurn() - const text = typeof payload['content'] === 'string' ? payload['content'] as string : extractText(payload['content']) - pendingUserMessage = text.slice(0, 500) - pendingUserChars = text.length - } else if (type === 'turn_start') { - if (inTurn) flushTurn() - inTurn = true - execId = stringField(payload, ['executionId']) - turnStartTs = ts - turnUserMessage = pendingUserMessage - turnUserChars = pendingUserChars - pendingUserMessage = '' - pendingUserChars = 0 - } else if (type === 'assistant') { - const text = typeof payload['content'] === 'string' ? payload['content'] as string : extractText(payload['content']) - if (stringField(payload, ['operationType']) === 'Reasoning') reasoningChars += text.length - else outputChars += text.length - if (!inTurn && text.length > 0) inTurn = true - if (!turnStartTs && ts) turnStartTs = ts - } else if (type === 'tool_call') { - pushTool(stringField(payload, ['toolName', 'name'])) - } else if (type === 'tool_result') { - // Tool output re-enters the model as context on the next inference call. - // content is a plain string in observed logs; extractText covers nesting. - const text = typeof payload['content'] === 'string' ? payload['content'] as string : extractText(payload['content']) - toolResultChars += text.length - } else if (type === 'usage_summary') { - // usedTools is a reliable per-turn tool list. Credits are the real billed - // usage (promptTurnSummaries[].usage with unit "credit") — harvest them - // for credit-based cost. usage can be null on some turns. - const summaries = payload['promptTurnSummaries'] - if (Array.isArray(summaries)) { - for (const s of summaries) { - const rec = asRecord(s) - const used = rec?.['usedTools'] - if (Array.isArray(used)) for (const u of used) if (typeof u === 'string') pushTool(u) - const usage = rec?.['usage'] - if (typeof usage === 'number' && Number.isFinite(usage)) turnCredits += usage - } - } - } else if (type === 'turn_end') { - flushTurn() - } - } - // Flush a trailing in-progress turn (session still active, no turn_end yet). - flushTurn() - - return results + costIsEstimated: rich.credits === 0, + tools: rich.tools, + bashCommands: rich.bashCommands, + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + userMessage: rich.userMessage, + sessionId: rich.sessionId, + } + if (rich.arm === 'chat') return { ...base, toolSequence: rich.toolSequence } + if (rich.arm === 'cli' || rich.arm === 'v2') return { ...base, project: rich.project! } + return base } function createParser(source: SessionSource, seenKeys: Set): SessionParser { + const context: DecodeContext = { privacyKey: '', providerId: 'kiro', sourceRef: source.path } return { async *parse(): AsyncGenerator { + const path = source.path + // v2 IDE store: ~/.kiro/sessions//sess_/messages.jsonl — a // self-contained event log. Must be checked BEFORE the generic .jsonl // (CLI) branch, since it also ends in .jsonl but has a different schema. - if (/[/\\]sess_[^/\\]+[/\\]messages\.jsonl$/.test(source.path)) { - for (const call of await parseV2Session(source, seenKeys)) yield call + if (/[/\\]sess_[^/\\]+[/\\]messages\.jsonl$/.test(path)) { + const content = await readSessionFile(path) + if (content === null) return + + // Companion session.json carries the real model + session metadata. + let meta: KiroV2SessionMeta = {} + try { + const raw = await readFile(join(dirname(path), 'session.json'), 'utf-8') + meta = JSON.parse(raw) as KiroV2SessionMeta + } catch { /* fall back to defaults below */ } + + const result = decodeKiroV2Session({ + lines: content, + meta, + fallbackSessionId: basename(dirname(path)), + project: source.project, + context, + seenKeys, + }) + for (const call of result.calls) yield toProviderCall(call) return } // CLI session: path points to a .jsonl file - if (source.path.endsWith('.jsonl')) { - const jsonlContent = await readSessionFile(source.path) + if (path.endsWith('.jsonl')) { + const jsonlContent = await readSessionFile(path) if (jsonlContent === null) return const entries: KiroCliEntry[] = [] @@ -852,24 +117,29 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars if (entries.length === 0) return // Load companion .json for metadata - const metaPath = source.path.replace(/\.jsonl$/, '.json') + const metaPath = path.replace(/\.jsonl$/, '.json') let meta: KiroCliSessionMeta try { const raw = await readFile(metaPath, 'utf-8') meta = JSON.parse(raw) as KiroCliSessionMeta } catch { // Minimal fallback - meta = { session_id: basename(source.path, '.jsonl'), cwd: '', created_at: '', updated_at: '' } + meta = { session_id: basename(path, '.jsonl'), cwd: '', created_at: '', updated_at: '' } } - for (const call of parseCliSession(meta, entries, seenKeys)) { - yield call - } + const result = decodeKiroCliSession({ + meta, + entries, + project: basename(meta.cwd || ''), + context, + seenKeys, + }) + for (const call of result.calls) yield toProviderCall(call) return } // IDE session: original path - const content = await readSessionFile(source.path) + const content = await readSessionFile(path) if (content === null) return let data: unknown @@ -884,18 +154,30 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars // Workspace-session files (newer Kiro builds): have history[] with message.role/content // and a top-level sessionId/selectedModel/workspaceDirectory. - if (Array.isArray(record['history']) && typeof record['sessionId'] === 'string') { - for (const call of await parseWorkspaceSession(record, source, seenKeys)) yield call + const prepared = prepareKiroWorkspaceSession(record) + if (prepared.kind === 'ready') { + let timestamp: string + try { + const s = await stat(source.path) + timestamp = new Date(s.mtimeMs).toISOString() + } catch { + return + } + const result = finishKiroWorkspaceSession(prepared.draft, { timestamp, seenKeys }) + for (const call of result.calls) yield toProviderCall(call) return } - - const metadata = asRecord(record['metadata']) - const calls = Array.isArray(record['chat']) && metadata - ? parseChatFile(record as unknown as KiroChatFile, stringField(metadata, ['workflowId']) || basename(source.path, '.chat'), source.project, seenKeys) - : parseModernExecution(record, source.path, seenKeys) - for (const call of calls) { - yield call - } + if (prepared.kind === 'empty') return + + const result = decodeKiroIdeFile({ + record, + fallbackChatSessionId: basename(path, '.chat'), + fallbackExecutionId: basename(path), + fallbackSessionId: basename(dirname(path)), + context, + seenKeys, + }) + for (const call of result.calls) yield toProviderCall(call) }, } } @@ -1127,7 +409,7 @@ export function createKiroProvider(agentDirOverride?: string, workspaceStorageDi toolDisplayName(rawTool: string): string { if (rawTool.startsWith('mcp__')) return rawTool - return toolNameMap[rawTool] ?? rawTool + return kiroToolNameMap[rawTool] ?? rawTool }, async discoverSessions(): Promise { diff --git a/packages/cli/tests/providers/kiro-golden.test.ts b/packages/cli/tests/providers/kiro-golden.test.ts new file mode 100644 index 00000000..413ca3e9 --- /dev/null +++ b/packages/cli/tests/providers/kiro-golden.test.ts @@ -0,0 +1,813 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm, stat } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { priceProviderCall } from '../../src/pricing-pass.js' +import { kiro, createKiroProvider } from '../../src/providers/kiro.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kiro-golden-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function makeChatFile(opts: { + executionId?: string + modelId?: string + workflowId?: string + startTime?: number + endTime?: number + userPrompt?: string + botResponses?: string[] +}) { + const chat = [ + { role: 'human', content: '\nYou are Kiro.\n' }, + { role: 'bot', content: '' }, + { role: 'tool', content: 'workspace tree...' }, + { role: 'bot', content: 'I will follow these instructions.' }, + ] + if (opts.userPrompt) chat.push({ role: 'human', content: opts.userPrompt }) + for (const resp of opts.botResponses ?? ['Done.']) chat.push({ role: 'bot', content: resp }) + return JSON.stringify({ + executionId: opts.executionId ?? 'exec-001', + actionId: 'act', + context: [], + validations: {}, + chat, + metadata: { + modelId: opts.modelId ?? 'claude-haiku-4-5', + modelProvider: 'qdev', + workflow: 'act', + workflowId: opts.workflowId ?? 'wf-001', + startTime: opts.startTime ?? 1777333000000, + endTime: opts.endTime ?? 1777333010000, + }, + }) +} + +function makeModernExecutionFile(opts: { + executionId?: string + sessionId?: string + modelId?: string + startTime?: number | string + userPrompt?: string + assistantResponse?: string + usageSummary?: Array<{ usedTools?: string[]; usage?: number; unit?: string }> +}) { + const startTime = opts.startTime ?? 1777333000000 + return JSON.stringify({ + executionId: opts.executionId ?? 'exec-modern-001', + sessionId: opts.sessionId ?? 'session-modern-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime, + endTime: typeof startTime === 'number' ? startTime + 10000 : 1777333010000, + modelId: opts.modelId ?? 'claude-sonnet-4.5', + messages: [ + { role: 'user', content: opts.userPrompt ?? 'explain the new kiro storage layout' }, + { role: 'assistant', content: opts.assistantResponse ?? 'Done. runCommand', toolCalls: [{ name: 'readFile' }] }, + ], + usageSummary: opts.usageSummary, + }) +} + +type V2Turn = { + exec: string + ts: string + user: string + say?: string + reasoning?: string + tools?: string[] + toolResults?: string[] + credits?: number +} + +function makeV2Messages(turns: V2Turn[]): string { + const lines: string[] = [] + lines.push(JSON.stringify({ id: 'sess-start', timestamp: turns[0]?.ts ?? '2026-07-14T13:39:00.000Z', payload: { type: 'session_start', agentType: 'vibe' } })) + for (const t of turns) { + lines.push(JSON.stringify({ id: `${t.exec}-u`, timestamp: t.ts, payload: { type: 'user', content: t.user, images: [], documents: [] } })) + lines.push(JSON.stringify({ id: `${t.exec}-ts`, timestamp: t.ts, payload: { type: 'turn_start', executionId: t.exec } })) + if (t.reasoning) { + lines.push(JSON.stringify({ id: `${t.exec}-r`, timestamp: t.ts, payload: { type: 'assistant', operationType: 'Reasoning', content: t.reasoning, executionId: t.exec } })) + } + lines.push(JSON.stringify({ id: `${t.exec}-a`, timestamp: t.ts, payload: { type: 'assistant', operationType: 'Say', content: t.say ?? 'Done.', executionId: t.exec } })) + for (const tool of t.tools ?? []) { + lines.push(JSON.stringify({ id: `${t.exec}-tc`, timestamp: t.ts, payload: { type: 'tool_call', toolName: tool, toolCallId: `${t.exec}-${tool}`, executionId: t.exec } })) + } + for (const [i, result] of (t.toolResults ?? []).entries()) { + lines.push(JSON.stringify({ id: `${t.exec}-tr${i}`, timestamp: t.ts, payload: { type: 'tool_result', toolCallId: `${t.exec}-tr${i}`, content: result, success: true, executionId: t.exec } })) + } + lines.push(JSON.stringify({ id: `${t.exec}-cm`, timestamp: t.ts, payload: { type: 'session_metadata', key: 'contextUsage', value: { usagePercentage: 12.5 }, executionId: t.exec } })) + lines.push(JSON.stringify({ id: `${t.exec}-us`, timestamp: t.ts, payload: { type: 'usage_summary', promptTurnSummaries: [{ unit: 'credit', unitPlural: 'credits', usage: t.credits ?? 1, usedTools: t.tools ?? [] }], status: 'success', executionId: t.exec } })) + lines.push(JSON.stringify({ id: `${t.exec}-te`, timestamp: t.ts, payload: { type: 'turn_end', stopReason: 'end_turn', executionId: t.exec } })) + } + return lines.join('\n') +} + +async function makeV2Session(sessionsRoot: string, opts: { + wsHash?: string + sessionId?: string + modelId?: string + workspacePaths?: string[] + turns: V2Turn[] +}): Promise { + const wsHash = opts.wsHash ?? '4748323122002acb' + const sessionId = opts.sessionId ?? 'sess_test-001' + const sessDir = join(sessionsRoot, wsHash, sessionId) + await mkdir(sessDir, { recursive: true }) + await writeFile(join(sessDir, 'session.json'), JSON.stringify({ + schemaVersion: '1.0.0', + dataModelVersion: 1, + id: sessionId, + title: 'Test v2 session', + agentMode: 'vibe', + workspacePaths: opts.workspacePaths ?? ['/tmp/test-proj'], + modelId: opts.modelId ?? 'claude-opus-4.8', + status: 'in_progress', + })) + await writeFile(join(sessDir, 'messages.jsonl'), makeV2Messages(opts.turns)) + return join(sessDir, 'messages.jsonl') +} + +async function parseSource(source: { path: string; project: string; provider: 'kiro' }, seenKeys?: Set): Promise { + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, seenKeys ?? new Set()).parse()) calls.push(call) + return calls +} + +const BASE18_KEYS = [ + 'bashCommands', + 'cacheCreationInputTokens', + 'cacheReadInputTokens', + 'cachedInputTokens', + 'costBasis', + 'costIsEstimated', + 'deduplicationKey', + 'inputTokens', + 'model', + 'outputTokens', + 'provider', + 'reasoningTokens', + 'sessionId', + 'speed', + 'timestamp', + 'tools', + 'userMessage', + 'webSearchRequests', +] + +describe('kiro golden pins (raw calls, unmodified provider)', () => { + it('G0 — key-set gates for all five arms', async () => { + // A1 chat + { + const wsHash = 'a'.repeat(32) + const wsDir = join(tmpDir, 'g0a1', wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'g0.chat') + await writeFile(chatPath, makeChatFile({ botResponses: ['hi'] })) + const calls = await parseSource({ path: chatPath, project: 'p', provider: 'kiro' }) + expect(calls).toHaveLength(1) + expect(Object.keys(calls[0]!).sort()).toEqual([...BASE18_KEYS.slice(0, 15), 'toolSequence', ...BASE18_KEYS.slice(15)]) + } + + // A2 modern without credits (18 keys) + { + const wsHash = 'a'.repeat(32) + const wsDir = join(tmpDir, 'g0a2none', wsHash, 'sess') + await mkdir(wsDir, { recursive: true }) + const path = join(wsDir, 'exec') + await writeFile(path, makeModernExecutionFile({ userPrompt: 'hi', assistantResponse: 'hello' })) + const calls = await parseSource({ path, project: 'p', provider: 'kiro' }) + expect(calls).toHaveLength(1) + expect(Object.keys(calls[0]!).sort()).toEqual(BASE18_KEYS) + } + + // A2 modern with credits (19 keys) + { + const wsHash = 'b'.repeat(32) + const wsDir = join(tmpDir, 'g0a2some', wsHash, 'sess') + await mkdir(wsDir, { recursive: true }) + const path = join(wsDir, 'exec') + await writeFile(path, makeModernExecutionFile({ userPrompt: 'hi', assistantResponse: 'hello', usageSummary: [{ usage: 1, unit: 'credit' }] })) + const calls = await parseSource({ path, project: 'p', provider: 'kiro' }) + expect(calls).toHaveLength(1) + expect(Object.keys(calls[0]!).sort()).toEqual([...BASE18_KEYS.slice(0, 6), 'costUSD', ...BASE18_KEYS.slice(6)]) + } + + // A3 ws-session + { + const wsSessionsDir = join(tmpDir, 'g0a3', 'workspace-sessions', 'L3RtcC90ZXN0') + await mkdir(wsSessionsDir, { recursive: true }) + const path = join(wsSessionsDir, 'ws.json') + await writeFile(path, JSON.stringify({ + sessionId: 'ws-g0', + selectedModel: 'claude-opus-4.8', + history: [{ message: { role: 'user', content: 'hi' } }, { message: { role: 'assistant', content: 'hello' } }], + })) + const calls = await parseSource({ path, project: 'p', provider: 'kiro' }) + expect(calls).toHaveLength(1) + expect(Object.keys(calls[0]!).sort()).toEqual(BASE18_KEYS) + } + + // A4 cli without credits (19 keys) + { + const cliDir = join(tmpDir, 'g0a4none', 'cli') + await mkdir(cliDir, { recursive: true }) + const sessionId = '00000000-0000-0000-0000-000000000000' + await writeFile(join(cliDir, `${sessionId}.jsonl`), [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { content: [{ kind: 'text', data: 'hi' }] } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'hello' }] } }), + ].join('\n')) + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ session_id: sessionId, cwd: '/tmp/p', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:01:00Z' })) + const calls = await parseSource({ path: join(cliDir, `${sessionId}.jsonl`), project: 'p', provider: 'kiro' }) + expect(calls).toHaveLength(1) + expect(Object.keys(calls[0]!).sort()).toEqual([...BASE18_KEYS.slice(0, 10), 'project', ...BASE18_KEYS.slice(10)]) + } + + // A4 cli with credits (20 keys) + { + const cliDir = join(tmpDir, 'g0a4some', 'cli') + await mkdir(cliDir, { recursive: true }) + const sessionId = '00000000-0000-0000-0000-000000000001' + await writeFile(join(cliDir, `${sessionId}.jsonl`), [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { content: [{ kind: 'text', data: 'hi' }] } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'hello' }] } }), + ].join('\n')) + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, cwd: '/tmp/p', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:01:00Z', + session_state: { conversation_metadata: { user_turn_metadatas: [{ end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [{ value: 1, unit: 'credit' }] }] } }, + })) + const calls = await parseSource({ path: join(cliDir, `${sessionId}.jsonl`), project: 'p', provider: 'kiro' }) + expect(calls).toHaveLength(1) + expect(Object.keys(calls[0]!).sort()).toEqual([...BASE18_KEYS.slice(0, 6), 'costUSD', ...BASE18_KEYS.slice(6, 10), 'project', ...BASE18_KEYS.slice(10)]) + } + + // A5 v2 without credits (19 keys) + { + const sessionsRoot = join(tmpDir, 'g0a5none') + const msgs = await makeV2Session(sessionsRoot, { sessionId: 'sess_g0a5none', turns: [{ exec: 'exec-1', ts: '2026-07-14T13:39:40.000Z', user: 'hi', say: 'hello', credits: 0 }] }) + const calls = await parseSource({ path: msgs, project: 'p', provider: 'kiro' }) + expect(calls).toHaveLength(1) + expect(Object.keys(calls[0]!).sort()).toEqual([...BASE18_KEYS.slice(0, 10), 'project', ...BASE18_KEYS.slice(10)]) + } + + // A5 v2 with credits (20 keys) + { + const sessionsRoot = join(tmpDir, 'g0a5some') + const msgs = await makeV2Session(sessionsRoot, { sessionId: 'sess_g0a5some', turns: [{ exec: 'exec-1', ts: '2026-07-14T13:39:40.000Z', user: 'hi', say: 'hello', credits: 1 }] }) + const calls = await parseSource({ path: msgs, project: 'p', provider: 'kiro' }) + expect(calls).toHaveLength(1) + expect(Object.keys(calls[0]!).sort()).toEqual([...BASE18_KEYS.slice(0, 6), 'costUSD', ...BASE18_KEYS.slice(6, 10), 'project', ...BASE18_KEYS.slice(10)]) + } + }) + + it('G1 — A1 chat whole object (multi-tool, priced)', async () => { + const wsHash = 'a'.repeat(32) + const wsDir = join(tmpDir, 'g1', wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'abc123.chat') + await writeFile(chatPath, makeChatFile({ + executionId: 'exec-g1', + workflowId: 'wf-g1', + userPrompt: 'explain the code', + botResponses: [ + 'First answer with runCommand', + 'Second answer with readFile', + ], + })) + const calls = await parseSource({ path: chatPath, project: 'myproject', provider: 'kiro' }) + expect(calls).toStrictEqual([{ + provider: 'kiro', + model: 'claude-haiku-4-5', + inputTokens: 4, + outputTokens: 39, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + tools: ['Bash', 'Read'], + bashCommands: [], + toolSequence: [[{ tool: 'Bash' }], [{ tool: 'Read' }]], + timestamp: '2026-04-27T23:36:40.000Z', + speed: 'standard', + deduplicationKey: 'kiro:wf-g1:exec-g1', + userMessage: 'explain the code', + sessionId: 'wf-g1', + }]) + expect(Object.keys(calls[0]!).sort()).toEqual([...BASE18_KEYS.slice(0, 15), 'toolSequence', ...BASE18_KEYS.slice(15)]) + // A1 never carries credits, so the raw call has no costUSD and pricing must + // fall back to token estimation (exact value tracks the litellm snapshot). + expect('costUSD' in calls[0]!).toBe(false) + const priced = priceProviderCall(calls[0]!) + expect(priced.costUSD).toBeGreaterThan(0) + expect(priced.costIsEstimated).toBe(true) + }) + + it('G1b — A1 chat dedup key uses the raw executionId field (no path fallback)', async () => { + const wsHash = 'e'.repeat(32) + const wsDir = join(tmpDir, 'g1b', wsHash) + await mkdir(wsDir, { recursive: true }) + + const missing = join(wsDir, 'no-execid.chat') + await writeFile(missing, JSON.stringify({ + actionId: 'act', + chat: [{ role: 'human', content: 'q' }, { role: 'bot', content: 'answer' }], + metadata: { modelId: 'claude-haiku-4-5', workflowId: 'wf-g1b', startTime: 1777333000000 }, + })) + const missingCalls = await parseSource({ path: missing, project: 'p', provider: 'kiro' }) + expect(missingCalls).toHaveLength(1) + expect(missingCalls[0]!.deduplicationKey).toBe('kiro:wf-g1b:undefined') + + const blank = join(wsDir, 'blank-execid.chat') + await writeFile(blank, JSON.stringify({ + executionId: '', + actionId: 'act', + chat: [{ role: 'human', content: 'q' }, { role: 'bot', content: 'answer' }], + metadata: { modelId: 'claude-haiku-4-5', workflowId: 'wf-g1b2', startTime: 1777333000000 }, + })) + const blankCalls = await parseSource({ path: blank, project: 'p', provider: 'kiro' }) + expect(blankCalls).toHaveLength(1) + expect(blankCalls[0]!.deduplicationKey).toBe('kiro:wf-g1b2:') + }) + + it('G2 — A1 input tokens derived from truncated prompt; A2 from full prompt', async () => { + const wsHashA1 = 'a'.repeat(32) + const wsDirA1 = join(tmpDir, 'g2a1', wsHashA1) + await mkdir(wsDirA1, { recursive: true }) + const chatPath = join(wsDirA1, 'trunc.chat') + await writeFile(chatPath, makeChatFile({ + executionId: 'exec-g2a1', + workflowId: 'wf-g2a1', + userPrompt: 'x'.repeat(3000), + botResponses: ['short'], + })) + const a1 = await parseSource({ path: chatPath, project: 'p', provider: 'kiro' }) + expect(a1).toHaveLength(1) + expect(a1[0]!.inputTokens).toBe(125) + expect(a1[0]!.userMessage.length).toBe(500) + + const wsHashA2 = 'b'.repeat(32) + const wsDirA2 = join(tmpDir, 'g2a2', wsHashA2, 'session-modern') + await mkdir(wsDirA2, { recursive: true }) + const execPath = join(wsDirA2, 'execution-modern') + await writeFile(execPath, makeModernExecutionFile({ + executionId: 'exec-g2a2', + sessionId: 'session-g2a2', + userPrompt: 'x'.repeat(2000), + assistantResponse: 'short', + })) + const a2 = await parseSource({ path: execPath, project: 'p', provider: 'kiro' }) + expect(a2).toHaveLength(1) + expect(a2[0]!.inputTokens).toBe(500) + }) + + it('G3 — A1 single-tool toolSequence is present but undefined', async () => { + const wsHash = 'c'.repeat(32) + const wsDir = join(tmpDir, 'g3', wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'single.chat') + await writeFile(chatPath, makeChatFile({ + executionId: 'exec-g3', + workflowId: 'wf-g3', + userPrompt: 'do one thing', + botResponses: ['One tool: writeFile'], + })) + const calls = await parseSource({ path: chatPath, project: 'p', provider: 'kiro' }) + expect(calls).toHaveLength(1) + expect('toolSequence' in calls[0]!).toBe(true) + expect(calls[0]!.toolSequence).toBeUndefined() + expect(Object.keys(calls[0]!).sort()).toEqual([...BASE18_KEYS.slice(0, 15), 'toolSequence', ...BASE18_KEYS.slice(15)]) + }) + + it('G4 — A2 modern both cost paths (whole object)', async () => { + const wsHash = 'd'.repeat(32) + const sessDir = join(tmpDir, 'g4', wsHash, 'session-cost') + await mkdir(sessDir, { recursive: true }) + + const withCredits = join(sessDir, 'with-credits') + await writeFile(withCredits, makeModernExecutionFile({ + executionId: 'exec-g4-with', + sessionId: 'session-g4', + userPrompt: 'priced', + assistantResponse: 'answer', + usageSummary: [{ usedTools: ['readFile'], usage: 2, unit: 'credit' }], + })) + const withCalls = await parseSource({ path: withCredits, project: 'p', provider: 'kiro' }) + expect(withCalls).toStrictEqual([{ + provider: 'kiro', + model: 'claude-sonnet-4-5', + inputTokens: 2, + outputTokens: 2, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0.08, + costBasis: 'measured', + costIsEstimated: false, + tools: ['Read'], + bashCommands: [], + timestamp: '2026-04-27T23:36:40.000Z', + speed: 'standard', + deduplicationKey: 'kiro:session-g4:exec-g4-with', + userMessage: 'priced', + sessionId: 'session-g4', + }]) + + const withoutCredits = join(sessDir, 'without-credits') + await writeFile(withoutCredits, makeModernExecutionFile({ + executionId: 'exec-g4-without', + sessionId: 'session-g4', + userPrompt: 'unpriced', + assistantResponse: 'answer', + })) + const withoutCalls = await parseSource({ path: withoutCredits, project: 'p', provider: 'kiro' }) + expect(withoutCalls).toStrictEqual([{ + provider: 'kiro', + model: 'claude-sonnet-4-5', + inputTokens: 2, + outputTokens: 2, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + tools: ['Read'], + bashCommands: [], + timestamp: '2026-04-27T23:36:40.000Z', + speed: 'standard', + deduplicationKey: 'kiro:session-g4:exec-g4-without', + userMessage: 'unpriced', + sessionId: 'session-g4', + }]) + }) + + it('G4b — credits seam: host multiplication is exact and survives pricing', async () => { + // credits x USD_PER_KIRO_CREDIT (0.04) is plain float arithmetic; pin the + // exact products so a changed rate or a moved multiply is caught. + const wsHash = 'f'.repeat(32) + const sessDir = join(tmpDir, 'g4b', wsHash, 'session-frac') + await mkdir(sessDir, { recursive: true }) + const path = join(sessDir, 'frac') + await writeFile(path, makeModernExecutionFile({ + executionId: 'exec-g4b', + sessionId: 'session-g4b', + userPrompt: 'frac', + assistantResponse: 'answer', + usageSummary: [{ usage: 0.05, unit: 'credit' }, { usage: 0.2, unit: 'credit' }], + })) + const calls = await parseSource({ path, project: 'p', provider: 'kiro' }) + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBe(0.25 * 0.04) + expect(calls[0]!.costBasis).toBe('measured') + expect(calls[0]!.costIsEstimated).toBe(false) + expect(priceProviderCall(calls[0]!).costUSD).toBe(0.25 * 0.04) + + const sessionsRoot = join(tmpDir, 'g4bv2') + const msgs = await makeV2Session(sessionsRoot, { + sessionId: 'sess_g4b', + turns: [{ exec: 'exec-g4b-v2', ts: '2026-07-14T13:39:40.000Z', user: 'frac', say: 'answer', credits: 0.05 }], + }) + const v2calls = await parseSource({ path: msgs, project: 'p', provider: 'kiro' }) + expect(v2calls).toHaveLength(1) + expect(v2calls[0]!.costUSD).toBe(0.05 * 0.04) + expect(priceProviderCall(v2calls[0]!).costUSD).toBe(0.05 * 0.04) + }) + + it('G5 — A3 ws-session timestamp from mtime, no project key, stub-only yields zero', async () => { + const wsSessionsDir = join(tmpDir, 'g5', 'workspace-sessions', 'L3RtcC90ZXN0') + await mkdir(wsSessionsDir, { recursive: true }) + const path = join(wsSessionsDir, 'ws-session-g5.json') + await writeFile(path, JSON.stringify({ + sessionId: 'ws-session-g5', + title: 'Test', + selectedModel: 'claude-opus-4.8', + workspaceDirectory: '/tmp/test', + history: [ + { message: { role: 'user', content: [{ type: 'text', text: 'What is TypeScript?' }] } }, + { message: { role: 'assistant', content: 'TypeScript is a typed superset.' } }, + { message: { role: 'assistant', content: 'On it.' }, executionId: 'exec-ref-g5' }, + ], + })) + const calls = await parseSource({ path, project: 'test', provider: 'kiro' }) + expect(calls).toHaveLength(1) + expect(calls[0]!.sessionId).toBe('ws-session-g5') + expect(calls[0]!.deduplicationKey).toBe('kiro:ws-session:ws-session-g5') + expect(calls[0]!.timestamp).toBe(new Date((await stat(path)).mtimeMs).toISOString()) + expect(Object.keys(calls[0]!).sort()).toEqual(BASE18_KEYS) + // A3 never carries credits either: raw call has no costUSD, pricing estimates. + expect('costUSD' in calls[0]!).toBe(false) + const g5priced = priceProviderCall(calls[0]!) + expect(g5priced.costUSD).toBeGreaterThan(0) + expect(g5priced.costIsEstimated).toBe(true) + + const stubPath = join(wsSessionsDir, 'ws-stub.json') + await writeFile(stubPath, JSON.stringify({ + sessionId: 'ws-stub', + selectedModel: 'auto', + history: [ + { message: { role: 'user', content: [{ type: 'text', text: 'Deploy the stack' }] } }, + { message: { role: 'assistant', content: 'On it.' }, executionId: 'exec-ref' }, + ], + })) + const stubCalls = await parseSource({ path: stubPath, project: 'test', provider: 'kiro' }) + expect(stubCalls).toHaveLength(0) + }) + + it('G6 — A4 CLI turnIndex advances asymmetrically across zero-output turn', async () => { + const cliDir = join(tmpDir, 'g6', 'cli') + await mkdir(cliDir, { recursive: true }) + const sessionId = '66666666-6666-6666-6666-666666666666' + const jsonl = [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'first' }], meta: { timestamp: 1700000000 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'first answer' }] } }), + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm3', content: [{ kind: 'text', data: 'second no output' }], meta: { timestamp: 1700000060 } } }), + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm5', content: [{ kind: 'text', data: 'third' }], meta: { timestamp: 1700000120 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm6', content: [{ kind: 'text', data: 'third answer' }] } }), + ].join('\n') + await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl) + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/tmp/g6-proj', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:01:00Z', + session_state: { + rts_model_state: { model_info: { model_id: 'claude-sonnet-4' } }, + conversation_metadata: { + user_turn_metadatas: [ + { end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [{ value: 1, unit: 'credit' }] }, + { end_timestamp: '2026-01-01T00:01:30Z', metering_usage: [{ value: 2, unit: 'credit' }] }, + ], + }, + }, + })) + const calls = await parseSource({ path: join(cliDir, `${sessionId}.jsonl`), project: 'g6', provider: 'kiro' }) + expect(calls).toStrictEqual([{ + provider: 'kiro', + model: 'claude-sonnet-4', + inputTokens: 2, + outputTokens: 3, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0.04, + costBasis: 'measured', + costIsEstimated: false, + tools: [], + bashCommands: [], + timestamp: '2026-01-01T00:00:30.000Z', + speed: 'standard', + deduplicationKey: `kiro-cli:${sessionId}:0`, + userMessage: 'first', + sessionId, + project: 'g6-proj', + }, { + provider: 'kiro', + model: 'claude-sonnet-4', + inputTokens: 2, + outputTokens: 3, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0.08, + costBasis: 'measured', + costIsEstimated: false, + tools: [], + bashCommands: [], + timestamp: '2026-01-01T00:01:30.000Z', + speed: 'standard', + deduplicationKey: `kiro-cli:${sessionId}:1`, + userMessage: 'third', + sessionId, + project: 'g6-proj', + }]) + }) + + it('G6b — A4 CLI turnIndex advances across a bad-timestamp turn too', async () => { + const cliDir = join(tmpDir, 'g6b', 'cli') + await mkdir(cliDir, { recursive: true }) + const sessionId = '66666666-6666-6666-6666-66666666666b' + const jsonl = [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { content: [{ kind: 'text', data: 'first' }] } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'first answer' }] } }), + JSON.stringify({ version: '1', kind: 'Prompt', data: { content: [{ kind: 'text', data: 'second' }] } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'second answer' }] } }), + JSON.stringify({ version: '1', kind: 'Prompt', data: { content: [{ kind: 'text', data: 'third' }] } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'third answer' }] } }), + ].join('\n') + await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl) + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/tmp/g6b-proj', + created_at: 'also-not-a-date', + updated_at: '2026-01-01T00:01:00Z', + session_state: { + conversation_metadata: { + user_turn_metadatas: [ + { end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [{ value: 1, unit: 'credit' }] }, + { end_timestamp: 'not-a-date', metering_usage: [{ value: 9, unit: 'credit' }] }, + { end_timestamp: '2026-01-01T00:02:30Z', metering_usage: [{ value: 3, unit: 'credit' }] }, + ], + }, + }, + })) + const calls = await parseSource({ path: join(cliDir, `${sessionId}.jsonl`), project: 'g6b', provider: 'kiro' }) + expect(calls.map(c => [c.deduplicationKey, c.timestamp, c.costUSD])).toStrictEqual([ + [`kiro-cli:${sessionId}:0`, '2026-01-01T00:00:30.000Z', 0.04], + [`kiro-cli:${sessionId}:2`, '2026-01-01T00:02:30.000Z', 0.12], + ]) + }) + + it('G7 — A5 v2 dedup-key fallback uses local results.length', async () => { + const sessionsRoot = join(tmpDir, 'g7') + const msgs = await makeV2Session(sessionsRoot, { + sessionId: 'sess_g7', + turns: [ + { exec: 'exec-g7-1', ts: '2026-07-14T13:39:40.000Z', user: 'first' }, + { exec: '', ts: '2026-07-14T13:40:00.000Z', user: 'second no execId' }, + { exec: 'exec-g7-3', ts: '2026-07-14T13:41:00.000Z', user: 'third' }, + ], + }) + const calls = await parseSource({ path: msgs, project: 'g7', provider: 'kiro' }) + expect(calls.map(c => c.deduplicationKey)).toStrictEqual([ + 'kiro-v2:sess_g7:exec-g7-1', + 'kiro-v2:sess_g7:1', + 'kiro-v2:sess_g7:exec-g7-3', + ]) + }) + + it('G8 — A5 v2 whole object (reasoning, tool_result, mcp strip, project, credits)', async () => { + const sessionsRoot = join(tmpDir, 'g8') + const msgs = await makeV2Session(sessionsRoot, { + sessionId: 'sess_g8', + turns: [ + { exec: 'exec-g8', ts: '2026-07-14T13:39:40.000Z', user: 'x'.repeat(8), say: 'done', reasoning: 'x'.repeat(400), tools: ['mcp_someTool'], toolResults: ['r'.repeat(150)], credits: 3 }, + ], + }) + const calls = await parseSource({ path: msgs, project: 'g8-proj', provider: 'kiro' }) + expect(calls).toStrictEqual([{ + provider: 'kiro', + model: 'claude-opus-4-8', + inputTokens: 40, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 100, + webSearchRequests: 0, + costUSD: 0.12, + costBasis: 'measured', + costIsEstimated: false, + tools: ['someTool'], + bashCommands: [], + timestamp: '2026-07-14T13:39:40.000Z', + speed: 'standard', + deduplicationKey: 'kiro-v2:sess_g8:exec-g8', + userMessage: 'xxxxxxxx', + sessionId: 'sess_g8', + project: 'g8-proj', + }]) + const priced = priceProviderCall(calls[0]!) + expect(priced.costUSD).toBeCloseTo(0.12, 8) + }) + + it('G9 — A5 v2 implicit turn carries empty userMessage and zero inputTokens', async () => { + const sessionsRoot = join(tmpDir, 'g9') + const sessDir = join(sessionsRoot, 'hash', 'sess_g9') + await mkdir(sessDir, { recursive: true }) + await writeFile(join(sessDir, 'session.json'), JSON.stringify({ id: 'sess_g9', modelId: 'claude-opus-4.8' })) + const lines = [ + JSON.stringify({ id: 'u', timestamp: '2026-07-14T13:39:00.000Z', payload: { type: 'user', content: 'user prompt', images: [], documents: [] } }), + JSON.stringify({ id: 'a', timestamp: '2026-07-14T13:39:40.000Z', payload: { type: 'assistant', operationType: 'Say', content: 'implicit answer' } }), + ].join('\n') + await writeFile(join(sessDir, 'messages.jsonl'), lines) + const calls = await parseSource({ path: join(sessDir, 'messages.jsonl'), project: 'g9', provider: 'kiro' }) + expect(calls).toStrictEqual([{ + provider: 'kiro', + model: 'claude-opus-4-8', + inputTokens: 0, + outputTokens: 4, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + tools: [], + bashCommands: [], + timestamp: '2026-07-14T13:39:40.000Z', + speed: 'standard', + deduplicationKey: 'kiro-v2:sess_g9:0', + userMessage: '', + sessionId: 'sess_g9', + project: 'g9', + }]) + }) + + it('G10 — cross-format dedup with shared seenKeys', async () => { + const root = join(tmpDir, 'g10') + const agentDir = join(root, 'agent') + const sessionsRoot = join(root, 'sessions') + await mkdir(agentDir, { recursive: true }) + await mkdir(join(sessionsRoot, 'cli'), { recursive: true }) + const wsHash = 'a1b2c3d4'.repeat(4) + const wsDir = join(agentDir, wsHash) + await mkdir(wsDir, { recursive: true }) + await writeFile(join(wsDir, 'legacy.chat'), makeChatFile({ executionId: 'exec-legacy', workflowId: 'wf-legacy', userPrompt: 'legacy question', botResponses: ['legacy answer'] })) + const v1Dir = join(wsDir, 'session-v1') + await mkdir(v1Dir, { recursive: true }) + await writeFile(join(v1Dir, 'execution-v1'), makeModernExecutionFile({ executionId: 'exec-v1', sessionId: 'session-v1', userPrompt: 'v1 question', assistantResponse: 'v1 answer' })) + const wssDir = join(agentDir, 'workspace-sessions', 'L3RtcC9taXhlZA__') + await mkdir(wssDir, { recursive: true }) + await writeFile(join(wssDir, 'ws-real.json'), JSON.stringify({ sessionId: 'ws-real', selectedModel: 'claude-sonnet-4.5', history: [{ message: { role: 'user', content: [{ type: 'text', text: 'ws question' }] } }, { message: { role: 'assistant', content: 'a real standalone workspace-session answer' } }] })) + const cliSessionId = '44444444-4444-4444-4444-444444444444' + await writeFile(join(sessionsRoot, 'cli', `${cliSessionId}.jsonl`), [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'cli question' }], meta: { timestamp: 1700000000 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'cli answer' }] } }), + ].join('\n')) + await writeFile(join(sessionsRoot, 'cli', `${cliSessionId}.json`), JSON.stringify({ + session_id: cliSessionId, cwd: '/tmp/mixed-proj', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:01:00Z', + session_state: { rts_model_state: { model_info: { model_id: 'claude-sonnet-4' } }, conversation_metadata: { user_turn_metadatas: [{ end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [{ value: 0.05, unit: 'credit' }] }] } }, + })) + await makeV2Session(sessionsRoot, { wsHash: 'a1b2c3d4e5f60718', sessionId: 'sess_mixed-001', turns: [{ exec: 'exec-v2-1', ts: '2026-07-14T13:39:40.000Z', user: 'v2 first', say: 'v2 first answer', tools: ['readFile'] }] }) + + const provider = createKiroProvider(agentDir, join(root, 'ws-storage'), join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const seenKeys = new Set() + const run1: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) run1.push(call) + } + const run2: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) run2.push(call) + } + expect(run1).toHaveLength(5) + expect(run2).toStrictEqual([]) + }) + + it('G11 — routing precedence (v2 beats CLI; ws-session beats chat)', async () => { + const sessionsRoot = join(tmpDir, 'g11v2') + const msgs = await makeV2Session(sessionsRoot, { sessionId: 'sess_g11', turns: [{ exec: 'exec-g11', ts: '2026-07-14T13:39:40.000Z', user: 'v2 routing', say: 'hi' }] }) + const v2calls = await parseSource({ path: msgs, project: 'g11', provider: 'kiro' }) + expect(v2calls).toHaveLength(1) + expect(v2calls[0]!.model).toBe('claude-opus-4-8') + expect(v2calls[0]!.deduplicationKey).toBe('kiro-v2:sess_g11:exec-g11') + + const wsSessionsDir = join(tmpDir, 'g11ws', 'workspace-sessions', 'L3RtcC90ZXN0') + await mkdir(wsSessionsDir, { recursive: true }) + const wsPath = join(wsSessionsDir, 'ws-routing.json') + await writeFile(wsPath, JSON.stringify({ + sessionId: 'ws-routing', + selectedModel: 'claude-opus-4.8', + history: [{ message: { role: 'user', content: [{ type: 'text', text: 'ws question' }] } }, { message: { role: 'assistant', content: 'ws answer' } }], + chat: [{ role: 'human', content: 'chat ignored' }], + })) + const wsCalls = await parseSource({ path: wsPath, project: 'g11ws', provider: 'kiro' }) + expect(wsCalls).toHaveLength(1) + expect(wsCalls[0]!.deduplicationKey).toBe('kiro:ws-session:ws-routing') + }) + + it('G12 — degenerate reads yield zero calls and throw nothing', async () => { + const wsHash = 'z'.repeat(32) + const wsDir = join(tmpDir, 'g12', wsHash) + await mkdir(wsDir, { recursive: true }) + await writeFile(join(wsDir, 'empty.chat'), '') + await writeFile(join(wsDir, 'bad.chat'), 'not json') + await writeFile(join(wsDir, 'array.chat'), '[]') + const cliDir = join(tmpDir, 'g12cli') + await mkdir(cliDir, { recursive: true }) + await writeFile(join(cliDir, 'blank.jsonl'), ' \n\n ') + + const scenarios = [ + { path: '/nonexistent/test.chat', label: 'missing file' }, + { path: join(wsDir, 'empty.chat'), label: 'empty file' }, + { path: join(wsDir, 'bad.chat'), label: 'invalid JSON' }, + { path: join(wsDir, 'array.chat'), label: 'JSON array' }, + { path: join(cliDir, 'blank.jsonl'), label: 'blank jsonl' }, + ] + for (const { path } of scenarios) { + const calls = await parseSource({ path, project: 'p', provider: 'kiro' }) + expect(calls).toStrictEqual([]) + } + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 25903e33..824e7568 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -162,6 +162,10 @@ "./providers/antigravity": { "types": "./dist/providers/antigravity/index.d.ts", "import": "./dist/providers/antigravity/index.js" + }, + "./providers/kiro": { + "types": "./dist/providers/kiro/index.d.ts", + "import": "./dist/providers/kiro/index.js" } }, "files": [ diff --git a/packages/core/src/providers/kiro/decode.ts b/packages/core/src/providers/kiro/decode.ts new file mode 100644 index 00000000..24a663ea --- /dev/null +++ b/packages/core/src/providers/kiro/decode.ts @@ -0,0 +1,812 @@ +// @codeburn/core Kiro decoder: pure decode over the five record shapes the host +// hands it. No fs / env / clock — the host owns discovery, durable caching, +// project attribution, and pricing. + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { + KiroChatFile, + KiroCliEntry, + KiroCliSessionMeta, + KiroDecodedCall, + KiroModernExecution, + KiroToolCall, + KiroV2SessionMeta, + KiroWorkspaceSessionDraft, +} from './types.js' + +const CHARS_PER_TOKEN = 4 +function estimateTokensFromChars(chars: number): number { + return Math.ceil(chars / CHARS_PER_TOKEN) +} + +const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000 +const MODERN_CONVERSATION_KEYS = ['messages', 'conversation', 'chat', 'transcript', 'entries', 'events'] + +export const kiroToolNameMap: Record = { + readFile: 'Read', + read_file: 'Read', + read: 'Read', + writeFile: 'Edit', + write_file: 'Edit', + write: 'Edit', + editFile: 'Edit', + edit_file: 'Edit', + createFile: 'Write', + create_file: 'Write', + deleteFile: 'Delete', + listDir: 'LS', + list_dir: 'LS', + openFolders: 'LS', + runCommand: 'Bash', + run_command: 'Bash', + shell: 'Bash', + executeBash: 'Bash', + searchFiles: 'Grep', + search_files: 'Grep', + grep: 'Grep', + grepSearch: 'Grep', + findFiles: 'Glob', + find_files: 'Glob', + glob: 'Glob', + fileSearch: 'Glob', + webSearch: 'WebSearch', + web_search: 'WebSearch', + web_fetch: 'WebFetch', + fsWrite: 'Edit', + strReplace: 'Edit', + listDirectory: 'LS', + code: 'Read', + subagent: 'Agent', +} + +function normalizeModelId(raw: string): string { + return raw.replace(/(\d+)\.(\d+)/g, '$1-$2') +} + +export function extractToolNames(content: string): string[] { + const tools: string[] = [] + const regex = /\s*([^<]+)<\/name>/g + let match + while ((match = regex.exec(content)) !== null) { + const name = match[1]!.trim() + tools.push(kiroToolNameMap[name] ?? name) + } + return tools +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null +} + +export function stringField(record: Record | null, names: string[]): string { + if (!record) return '' + for (const name of names) { + const value = record[name] + if (typeof value === 'string' && value.trim()) return value.trim() + } + return '' +} + +function timeField(record: Record | null, names: string[]): number | string | undefined { + if (!record) return undefined + for (const name of names) { + const value = record[name] + if (typeof value === 'number' || typeof value === 'string') return value + } + return undefined +} + +export function parseKiroTimestamp(value: number | string | undefined): Date | null { + if (value === undefined) return null + + let parsed: number | string = value + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return null + parsed = /^-?\d+(\.\d+)?$/.test(trimmed) ? Number(trimmed) : trimmed + } + + if (typeof parsed === 'number') { + if (!Number.isFinite(parsed)) return null + const ms = parsed < MIN_REASONABLE_TIMESTAMP_MS ? parsed * 1000 : parsed + const date = new Date(ms) + return Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS ? null : date + } + + const date = new Date(parsed) + return Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS ? null : date +} + +function textField(record: Record | null, names: string[]): string { + if (!record) return '' + for (const name of names) { + const text = extractText(record[name]) + if (text) return text + } + return '' +} + +export function extractText(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(extractText).filter(Boolean).join('\n') + const record = asRecord(value) + if (!record) return '' + for (const key of ['content', 'text', 'message', 'value', 'parts', 'entries']) { + const text = extractText(record[key]) + if (text) return text + } + return '' +} + +function messageRole(value: unknown): string { + const record = asRecord(value) + if (!record) return '' + return stringField(record, ['role', 'type', 'author']).toLowerCase() +} + +export function extractStructuredToolNames(value: unknown, text: string, options: { includeDirectName?: boolean } = {}): string[] { + const tools = extractToolNames(text) + const record = asRecord(value) + if (!record) return tools + + if (options.includeDirectName ?? true) { + const directName = stringField(record, ['toolName', 'name']) + if (directName) tools.push(kiroToolNameMap[directName] ?? directName) + } + + for (const key of ['toolCalls', 'tool_calls', 'tools']) { + const entries = record[key] + if (!Array.isArray(entries)) continue + for (const entry of entries) { + const name = stringField(asRecord(entry), ['name', 'toolName', 'tool_name']) + if (name) tools.push(kiroToolNameMap[name] ?? name) + } + } + + return tools +} + +export type KiroDecodeResult = { calls: KiroDecodedCall[]; diagnostics: RecordDiagnostic[] } + +export function decodeKiroChatFile(input: { + record: Record + fallbackChatSessionId: string + context: DecodeContext + seenKeys?: Set +}): KiroDecodeResult { + const { record, fallbackChatSessionId, seenKeys: liveSeen } = input + const seen = liveSeen ?? new Set() + const calls: KiroDecodedCall[] = [] + + const data = record as unknown as KiroChatFile + const { chat, metadata } = data + + let modelId = normalizeModelId(metadata.modelId ?? '') + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + + let pendingUserMessage = '' + const allTools: string[] = [] + const toolSequence: KiroToolCall[][] = [] + + for (const msg of chat) { + if (msg.role === 'human') { + if (msg.content.startsWith('')) continue + pendingUserMessage = msg.content.slice(0, 500) + } + if (msg.role === 'bot') { + const msgTools = extractToolNames(msg.content) + allTools.push(...msgTools) + if (msgTools.length > 0) toolSequence.push(msgTools.map(t => ({ tool: t }))) + } + } + + const botMessages = chat.filter(m => m.role === 'bot' && m.content.length > 0) + const totalOutputChars = botMessages.reduce((sum, m) => sum + m.content.length, 0) + if (totalOutputChars === 0) return { calls, diagnostics: [] } + + const sessionId = stringField(asRecord(metadata), ['workflowId']) || fallbackChatSessionId + const dedupKey = `kiro:${sessionId}:${data.executionId}` + if (seen.has(dedupKey)) return { calls, diagnostics: [] } + + const outputTokens = estimateTokensFromChars(totalOutputChars) + const inputTokens = estimateTokensFromChars(pendingUserMessage.length) + const tsDate = parseKiroTimestamp(metadata.startTime) + if (!tsDate) return { calls, diagnostics: [] } + const timestamp = tsDate.toISOString() + seen.add(dedupKey) + + calls.push({ + provider: 'kiro', + arm: 'chat', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + credits: 0, + tools: [...new Set(allTools)], + bashCommands: [], + toolSequence: toolSequence.length > 1 ? toolSequence : undefined, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + }) + + return { calls, diagnostics: [] } +} + +export function decodeKiroModernExecution(input: { + record: Record + fallbackExecutionId: string + fallbackSessionId: string + context: DecodeContext + seenKeys?: Set +}): KiroDecodeResult { + const { record: data, fallbackExecutionId, fallbackSessionId, seenKeys: liveSeen } = input + const seen = liveSeen ?? new Set() + const calls: KiroDecodedCall[] = [] + + if (Array.isArray(data['executions'])) return { calls, diagnostics: [] } + + const metadata = asRecord(data['metadata']) + const modelObj = asRecord(data['model']) + let modelId = normalizeModelId( + stringField(data, ['modelId', 'modelID', 'modelName', 'model']) || + stringField(modelObj, ['id', 'name']) || + stringField(metadata, ['modelId', 'modelID', 'modelName']), + ) + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + + const executionId = stringField(data, ['executionId', 'id']) || fallbackExecutionId + const sessionId = stringField(data, ['sessionId', 'chatSessionId', 'conversationId', 'workflowId']) || + stringField(metadata, ['workflowId', 'sessionId']) || + fallbackSessionId || + executionId + + let inputChars = 0 + let outputChars = 0 + let pendingUserMessage = '' + const allTools: string[] = [] + let hasOutputActivity = false + const directInput = textField(data, ['prompt', 'input', 'userMessage', 'user_message', 'request']) + const directOutput = textField(data, ['response', 'output', 'assistantMessage', 'assistant_message', 'result']) + const directTools = extractStructuredToolNames(data, directOutput, { includeDirectName: false }) + + if (directInput) { + inputChars += directInput.length + pendingUserMessage = directInput.slice(0, 500) + } + + if (directOutput) { + outputChars += directOutput.length + hasOutputActivity = true + } + + if (directTools.length > 0) { + hasOutputActivity = true + allTools.push(...directTools) + } + + // Check both data.context[key] and data[key] for conversation arrays. + // Kiro IDE stores messages at data.context.messages in current builds. + const contextRec = asRecord(data['context']) + const conversationSources = contextRec ? [contextRec, data] : [data] + + for (const source of conversationSources) { + let found = false + for (const key of MODERN_CONVERSATION_KEYS) { + const messages = (source as Record)[key] + if (!Array.isArray(messages)) continue + + for (const message of messages) { + const text = extractText(message) + const role = messageRole(message) + const tools = extractStructuredToolNames(message, text) + + if (role === 'human' || role === 'user') { + if (!text) continue + inputChars += text.length + pendingUserMessage = text.slice(0, 500) + } else if (role === 'bot' || role === 'assistant' || role === 'ai' || role === 'model') { + if (text) outputChars += text.length + if (text || tools.length > 0) hasOutputActivity = true + allTools.push(...tools) + } else if (role === 'tool' || role === 'system') { + if (text) inputChars += text.length + allTools.push(...tools) + } + } + found = true + break + } + if (found) break + } + + // Extract tools and metered credits from usageSummary (reliable structured + // data in current Kiro builds). usageSummary is an array of per-turn entries + // with optional usedTools and usage (credits, unit "credit") fields — the + // v1 predecessor of v2's usage_summary.promptTurnSummaries. + let executionCredits = 0 + const usageSummary = data['usageSummary'] + if (Array.isArray(usageSummary)) { + for (const entry of usageSummary) { + const rec = asRecord(entry) + if (!rec) continue + const usage = rec['usage'] + if (typeof usage === 'number' && Number.isFinite(usage)) executionCredits += usage + const usedTools = rec['usedTools'] + if (Array.isArray(usedTools)) { + for (const tool of usedTools) { + if (typeof tool === 'string' && tool) { + // Strip mcp_ prefix for cleaner display (e.g. mcp_aws_sentral_mcp_search_accounts -> aws_sentral_mcp_search_accounts) + const cleaned = tool.startsWith('mcp_') ? tool.slice(4) : tool + allTools.push(kiroToolNameMap[cleaned] ?? cleaned) + hasOutputActivity = true + } + } + } + } + } + + if (!hasOutputActivity) return { calls, diagnostics: [] } + + const dedupKey = `kiro:${sessionId}:${executionId}` + if (seen.has(dedupKey)) return { calls, diagnostics: [] } + + const rawStartTime = timeField(data, ['startTime', 'createdAt', 'timestamp']) ?? + timeField(metadata, ['startTime', 'createdAt', 'timestamp']) + const tsDate = parseKiroTimestamp(rawStartTime) + if (!tsDate) return { calls, diagnostics: [] } + + const inputTokens = estimateTokensFromChars(inputChars) + const outputTokens = estimateTokensFromChars(outputChars) + // Prefer real metered credits at the public overage rate; fall back to + // token-estimated pricing when the execution has no usage data — same + // contract as the CLI and v2 parsers. + seen.add(dedupKey) + + calls.push({ + provider: 'kiro', + arm: 'modern', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + credits: executionCredits, + tools: [...new Set(allTools)], + bashCommands: [], + timestamp: tsDate.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + }) + + return { calls, diagnostics: [] } +} + +// --- Kiro IDE workspace-session decode (workspace-sessions//.json) --- +// Newer v1-era Kiro builds store session state here: history[] carries user prompts +// and assistant messages, some of which are stubs referencing per-execution files +// (parsed separately by decodeKiroModernExecution). Split in two so the host can +// keep the mtime stat behind the two content gates rather than statting eagerly. +export type KiroWorkspaceSessionPrepared = + | { kind: 'not-workspace-session' } + | { kind: 'empty' } + | { kind: 'ready'; draft: KiroWorkspaceSessionDraft } + +export function prepareKiroWorkspaceSession(record: Record): KiroWorkspaceSessionPrepared { + const historyArr = record['history'] + if (!Array.isArray(historyArr) || typeof record['sessionId'] !== 'string') return { kind: 'not-workspace-session' } + + const sessionId = record['sessionId'] + const modelRaw = stringField(record, ['selectedModel']) + let modelId = normalizeModelId(modelRaw) + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + + let inputChars = 0 + let outputChars = 0 + let pendingUserMessage = '' + const allTools: string[] = [] + let hasExecutionRefs = false + let hasRealAssistantContent = false + + for (const item of historyArr) { + const rec = asRecord(item) + if (!rec) continue + + // Track if this session references execution files (which are parsed separately) + const execBacked = typeof rec['executionId'] === 'string' + if (execBacked) hasExecutionRefs = true + + const msg = asRecord(rec['message']) + if (!msg) continue + const role = stringField(msg, ['role']) + const text = extractText(msg['content']) + if (role === 'user' && text) { + inputChars += text.length + pendingUserMessage = text.slice(0, 500) + } else if (role === 'assistant' && !execBacked && text && text !== 'On it.') { + // An item carrying an executionId is execution-backed: its content is + // counted from the execution file, so counting it here would double-count. + // 'On it.' is the observed placeholder text Kiro writes for such stubs + // when the executionId rides a separate history item. + outputChars += text.length + hasRealAssistantContent = true + } + } + + // Skip workspace-session entries that are pure execution stubs: + // they reference executionIds (parsed separately as execution files) + // and have no real assistant content beyond "On it." placeholders. + // This avoids double-counting input tokens from both paths. + if (hasExecutionRefs && !hasRealAssistantContent) return { kind: 'empty' } + + // Skip sessions with no meaningful content + if (inputChars === 0 && outputChars === 0) return { kind: 'empty' } + + return { + kind: 'ready', + draft: { sessionId, modelId, inputChars, outputChars, pendingUserMessage, allTools }, + } +} + +export function finishKiroWorkspaceSession( + draft: KiroWorkspaceSessionDraft, + input: { timestamp: string; seenKeys?: Set }, +): KiroDecodeResult { + const { seenKeys: liveSeen } = input + const seen = liveSeen ?? new Set() + const calls: KiroDecodedCall[] = [] + const { sessionId, modelId, inputChars, outputChars, pendingUserMessage, allTools } = draft + + const dedupKey = `kiro:ws-session:${sessionId}` + if (seen.has(dedupKey)) return { calls, diagnostics: [] } + seen.add(dedupKey) + + const inputTokens = estimateTokensFromChars(inputChars) + const outputTokens = estimateTokensFromChars(outputChars) + + calls.push({ + provider: 'kiro', + arm: 'ws-session', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + credits: 0, + tools: [...new Set(allTools)], + bashCommands: [], + timestamp: input.timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + }) + + return { calls, diagnostics: [] } +} + +export function decodeKiroIdeFile(input: { + record: Record + fallbackChatSessionId: string + fallbackExecutionId: string + fallbackSessionId: string + context: DecodeContext + seenKeys?: Set +}): KiroDecodeResult { + const { record, fallbackChatSessionId, fallbackExecutionId, fallbackSessionId, context, seenKeys } = input + const metadata = asRecord(record['metadata']) + if (Array.isArray(record['chat']) && metadata) { + return decodeKiroChatFile({ record, fallbackChatSessionId, context, seenKeys }) + } + return decodeKiroModernExecution({ record, fallbackExecutionId, fallbackSessionId, context, seenKeys }) +} + +export function decodeKiroCliSession(input: { + meta: KiroCliSessionMeta + entries: KiroCliEntry[] + project: string + context: DecodeContext + seenKeys?: Set +}): KiroDecodeResult { + const { meta, entries, project, seenKeys: liveSeen } = input + const seen = liveSeen ?? new Set() + const calls: KiroDecodedCall[] = [] + const sessionId = meta.session_id + + let modelId = meta.session_state?.rts_model_state?.model_info?.model_id ?? 'auto' + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + else modelId = normalizeModelId(modelId) + + const turns = meta.session_state?.conversation_metadata?.user_turn_metadatas ?? [] + + // Walk through JSONL entries grouping by prompt turns + let turnIndex = 0 + let pendingUserMessage = '' + let outputChars = 0 + let inputChars = 0 + const allTools: string[] = [] + let turnStartTimestamp: string | undefined + + function flushTurn() { + if (outputChars === 0) return + const turnMeta = turns[turnIndex] + const dedupKey = `kiro-cli:${sessionId}:${turnIndex}` + if (seen.has(dedupKey)) { turnIndex++; return } + + const timestamp = turnMeta?.end_timestamp ?? turnStartTimestamp ?? meta.created_at + const tsDate = parseKiroTimestamp(timestamp) + if (!tsDate) { turnIndex++; return } + + const inputTokens = estimateTokensFromChars(inputChars) + const outputTokens = estimateTokensFromChars(outputChars) + // metering_usage values are credits (unit: "credit"), not dollars — + // convert at the public overage rate. Gate on credits > 0, not array + // presence: real sessions carry empty metering_usage arrays (turn still + // in flight when the meta was written), which must fall back to + // token-estimated pricing — same contract as the v1-execution and v2 + // parsers. + const turnCredits = turnMeta?.metering_usage + ? turnMeta.metering_usage.reduce((sum, m) => sum + m.value, 0) + : 0 + seen.add(dedupKey) + + calls.push({ + provider: 'kiro', + arm: 'cli', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + credits: turnCredits, + tools: [...new Set(allTools)], + bashCommands: [], + timestamp: tsDate.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + project, + }) + turnIndex++ + } + + let isFirstPrompt = true + for (const entry of entries) { + if (entry.kind === 'Prompt') { + if (!isFirstPrompt) { + flushTurn() + pendingUserMessage = '' + outputChars = 0 + inputChars = 0 + allTools.length = 0 + } + isFirstPrompt = false + const content = entry.data['content'] + if (Array.isArray(content)) { + for (const item of content) { + const rec = asRecord(item) + if (rec && rec['kind'] === 'text' && typeof rec['data'] === 'string') { + pendingUserMessage = (rec['data'] as string).slice(0, 500) + inputChars += (rec['data'] as string).length + } + } + } + const meta2 = asRecord(entry.data['meta']) + if (meta2) { + const ts = meta2['timestamp'] + if (typeof ts === 'number') turnStartTimestamp = new Date(ts * 1000).toISOString() + } + } else if (entry.kind === 'AssistantMessage') { + const content = entry.data['content'] + if (Array.isArray(content)) { + for (const item of content) { + const rec = asRecord(item) + if (!rec) continue + if (rec['kind'] === 'text' && typeof rec['data'] === 'string') { + outputChars += (rec['data'] as string).length + } else if (rec['kind'] === 'toolUse') { + const toolData = asRecord(rec['data']) + if (toolData) { + const name = typeof toolData['name'] === 'string' ? toolData['name'] : '' + if (name) allTools.push(kiroToolNameMap[name] ?? name) + } + } + } + } + } else if (entry.kind === 'ToolResults') { + // Tool results count as input context + const content = entry.data['content'] + if (Array.isArray(content)) { + for (const item of content) { + const text = extractText(item) + if (text) inputChars += text.length + } + } + } + } + // Flush last turn + flushTurn() + + return { calls, diagnostics: [] } +} + +// --- Kiro IDE v2 session decode (~/.kiro/sessions//sess_*/) --- +// v2 is a self-contained, event-sourced store: one directory per session holding +// session.json (metadata incl. the real modelId) + messages.jsonl (append-only +// event log). Unlike v1 it does NOT use separate execution files — assistant +// content is inline. Usage is billed in credits with no token counts, so tokens +// and cost are estimated from transcript text priced at the real model. +export function decodeKiroV2Session(input: { + lines: string + meta: KiroV2SessionMeta + fallbackSessionId: string + project: string + context: DecodeContext + seenKeys?: Set +}): KiroDecodeResult { + const { lines, meta, fallbackSessionId, project, seenKeys: liveSeen } = input + const seen = liveSeen ?? new Set() + const calls: KiroDecodedCall[] = [] + + const sessionId = (typeof meta.id === 'string' && meta.id) || fallbackSessionId + let modelId = normalizeModelId(meta.modelId ?? '') + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + + // In-flight turn state. A turn spans turn_start..turn_end sharing an + // executionId; the preceding `user` event carries its prompt. + let pendingUserMessage = '' + let pendingUserChars = 0 + + let inTurn = false + let execId = '' + let turnStartTs: string | undefined + let outputChars = 0 + let reasoningChars = 0 + let toolResultChars = 0 + let turnCredits = 0 + let turnUserMessage = '' + let turnUserChars = 0 + const tools: string[] = [] + + const resetTurn = () => { + inTurn = false; execId = ''; turnStartTs = undefined + outputChars = 0; reasoningChars = 0; toolResultChars = 0; turnCredits = 0 + turnUserMessage = ''; turnUserChars = 0 + tools.length = 0 + } + + const pushTool = (raw: string) => { + if (!raw) return + // Strip mcp_ prefix for cleaner display, matching the modern-execution parser. + const cleaned = raw.startsWith('mcp_') ? raw.slice(4) : raw + tools.push(kiroToolNameMap[cleaned] ?? cleaned) + } + + const flushTurn = () => { + if (!inTurn) { resetTurn(); return } + const hasActivity = outputChars > 0 || reasoningChars > 0 || tools.length > 0 + if (hasActivity) { + const dedupKey = `kiro-v2:${sessionId}:${execId || String(calls.length)}` + const tsDate = parseKiroTimestamp(turnStartTs) + if (tsDate && !seen.has(dedupKey)) { + seen.add(dedupKey) + // Tool results are fed back to the model as input context, so count them + // toward input tokens — same treatment as the CLI parser's ToolResults. + const inputTokens = estimateTokensFromChars(turnUserChars + toolResultChars) + // outputTokens and reasoningTokens are kept disjoint — downstream + // aggregation (models-report, audit-report, parser) sums the two + // fields, so folding reasoning into outputTokens would double-count. + const reasoningTokens = estimateTokensFromChars(reasoningChars) + const outputTokens = estimateTokensFromChars(outputChars) + // Prefer real metered credits (converted at the public overage rate) + // over token estimation; fall back to token pricing when the turn has + // no usage_summary (e.g. still in progress or null usage). Reasoning + // text is billed as output, so combine it for pricing only (same as + // the codex provider). + calls.push({ + provider: 'kiro', + arm: 'v2', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens, + webSearchRequests: 0, + credits: turnCredits, + tools: [...new Set(tools)], + bashCommands: [], + timestamp: tsDate.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: turnUserMessage, + sessionId, + project, + }) + } + } + resetTurn() + } + + for (const line of lines.split('\n')) { + if (!line.trim()) continue + let evt: Record + try { evt = JSON.parse(line) as Record } catch { continue } + const payload = asRecord(evt['payload']) + if (!payload) continue + const type = stringField(payload, ['type']) + const ts = typeof evt['timestamp'] === 'string' ? evt['timestamp'] as string : undefined + + if (type === 'user') { + // New prompt: close any in-flight turn defensively, then stash the text + // for the upcoming turn_start. + if (inTurn) flushTurn() + const text = typeof payload['content'] === 'string' ? payload['content'] as string : extractText(payload['content']) + pendingUserMessage = text.slice(0, 500) + pendingUserChars = text.length + } else if (type === 'turn_start') { + if (inTurn) flushTurn() + inTurn = true + execId = stringField(payload, ['executionId']) + turnStartTs = ts + turnUserMessage = pendingUserMessage + turnUserChars = pendingUserChars + pendingUserMessage = '' + pendingUserChars = 0 + } else if (type === 'assistant') { + const text = typeof payload['content'] === 'string' ? payload['content'] as string : extractText(payload['content']) + if (stringField(payload, ['operationType']) === 'Reasoning') reasoningChars += text.length + else outputChars += text.length + if (!inTurn && text.length > 0) inTurn = true + if (!turnStartTs && ts) turnStartTs = ts + } else if (type === 'tool_call') { + pushTool(stringField(payload, ['toolName', 'name'])) + } else if (type === 'tool_result') { + // Tool output re-enters the model as context on the next inference call. + // content is a plain string in observed logs; extractText covers nesting. + const text = typeof payload['content'] === 'string' ? payload['content'] as string : extractText(payload['content']) + toolResultChars += text.length + } else if (type === 'usage_summary') { + // usedTools is a reliable per-turn tool list. Credits are the real billed + // usage (promptTurnSummaries[].usage with unit "credit") — harvest them + // for credit-based cost. usage can be null on some turns. + const summaries = payload['promptTurnSummaries'] + if (Array.isArray(summaries)) { + for (const s of summaries) { + const rec = asRecord(s) + const used = rec?.['usedTools'] + if (Array.isArray(used)) for (const u of used) if (typeof u === 'string') pushTool(u) + const usage = rec?.['usage'] + if (typeof usage === 'number' && Number.isFinite(usage)) turnCredits += usage + } + } + } else if (type === 'turn_end') { + flushTurn() + } + } + // Flush a trailing in-progress turn (session still active, no turn_end yet). + flushTurn() + + return { calls, diagnostics: [] } +} diff --git a/packages/core/src/providers/kiro/index.ts b/packages/core/src/providers/kiro/index.ts new file mode 100644 index 00000000..66e53bc9 --- /dev/null +++ b/packages/core/src/providers/kiro/index.ts @@ -0,0 +1,47 @@ +// @codeburn/core Kiro provider. +// +// Two layers: +// - Rich pure decode (`decodeKiroChatFile`, `decodeKiroModernExecution`, +// `decodeKiroIdeFile`, `prepareKiroWorkspaceSession`, +// `finishKiroWorkspaceSession`, `decodeKiroCliSession`, `decodeKiroV2Session`): +// host-facing, NOT part of the stable minimized surface. Pure over supplied +// records; carries content in-memory but no pricing and no fs/clock/env. +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { + decodeKiroChatFile, + decodeKiroModernExecution, + decodeKiroIdeFile, + prepareKiroWorkspaceSession, + finishKiroWorkspaceSession, + decodeKiroCliSession, + decodeKiroV2Session, + kiroToolNameMap, + stringField, + parseKiroTimestamp, + extractText, + extractToolNames, + extractStructuredToolNames, + type KiroDecodeResult, + type KiroWorkspaceSessionPrepared, +} from './decode.js' + +export { + toObservations, + type RichKiroSessionDecode, + type KiroToObservationsContext, +} from './observations.js' + +export type { + KiroDecodedCall, + KiroArm, + KiroToolCall, + KiroChatMessage, + KiroChatFile, + KiroModernExecution, + KiroCliEntry, + KiroCliSessionMeta, + KiroV2SessionMeta, + KiroWorkspaceSessionDraft, +} from './types.js' diff --git a/packages/core/src/providers/kiro/observations.ts b/packages/core/src/providers/kiro/observations.ts new file mode 100644 index 00000000..08842fec --- /dev/null +++ b/packages/core/src/providers/kiro/observations.ts @@ -0,0 +1,88 @@ +// Minimizing transform: rich Kiro decode -> the strict observation envelope. +// Only opaque ids, fingerprints, enums, numbers, timestamps, and canonical tool +// names cross into the output — never the user message, project path, or raw +// tool names. + +import { projectRef, sessionRef } from '../../fingerprint.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { CallObservation, SessionObservation } from '../../observations.js' +import type { KiroDecodedCall } from './types.js' + +/** One Kiro session's rich decode, as the host holds it before minimization. */ +export interface RichKiroSessionDecode { + sessionId: string + /** Absolute project path; fingerprinted, never emitted raw. */ + projectPath: string + /** Rich, cost-free calls in decode order (as the kiro decoders emit them). */ + calls: KiroDecodedCall[] +} + +export interface KiroToObservationsContext { + /** 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. +// This filter is the containment boundary for kiro's arbitrary tool names. +const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ + +function toCallObservation(call: KiroDecodedCall, turnIndex: number): CallObservation { + return { + provider: call.provider, + model: call.model, + tokens: { + input: call.inputTokens, + output: call.outputTokens, + reasoning: call.reasoningTokens, + cacheRead: call.cacheReadInputTokens, + cacheCreate: call.cacheCreationInputTokens, + }, + webSearchRequests: call.webSearchRequests, + speed: call.speed, + costBasis: call.credits > 0 ? 'measured' : 'estimated', + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + } +} + +function toSessionObservation(decode: RichKiroSessionDecode, ctx: KiroToObservationsContext): SessionObservation { + const provider = ctx.provider ?? 'kiro' + const calls: CallObservation[] = decode.calls.map((call, i) => toCallObservation(call, i)) + + const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort() + const startedAt = timestamps[0] ?? '' + const endedAt = timestamps.length > 0 ? timestamps[timestamps.length - 1]! : '' + + const session: SessionObservation = { + sessionRef: sessionRef(ctx.privacyKey, provider, decode.sessionId), + projectRef: projectRef(ctx.privacyKey, decode.projectPath), + providerId: provider, + startedAt, + ...(endedAt ? { endedAt } : {}), + calls, + turnCount: calls.length, + } + return session +} + +/** + * Map a rich Kiro decode (one or many sessions) into the minimized observation + * layer. Returns the `sessions` array plus any per-record `diagnostics`. + * + * Content-smuggling guarantee: no free text (user message, project path, + * arbitrary tool name) is ever copied into the result. Only fingerprints, + * enums, numbers, timestamps, dedup keys, and canonical tool names cross the + * boundary. + */ +export function toObservations( + decode: RichKiroSessionDecode | RichKiroSessionDecode[], + ctx: KiroToObservationsContext, +): { sessions: SessionObservation[]; diagnostics: RecordDiagnostic[] } { + const decodes = Array.isArray(decode) ? decode : [decode] + const sessions = decodes.map(d => toSessionObservation(d, ctx)) + return { sessions, diagnostics: [] } +} diff --git a/packages/core/src/providers/kiro/types.ts b/packages/core/src/providers/kiro/types.ts new file mode 100644 index 00000000..9541928f --- /dev/null +++ b/packages/core/src/providers/kiro/types.ts @@ -0,0 +1,101 @@ +// Raw record + rich-decode types for the Kiro provider. +// +// Kiro is a Category D stateful multi-store provider. The host keeps all I/O, +// discovery, durable caching, project attribution, and pricing; core owns only +// the pure decode of the five record shapes the host hands it: legacy .chat +// files, v1 modern executions, workspace-session files, CLI sessions, and v2 +// IDE sessions. + +export type KiroToolCall = { tool: string } + +export type KiroArm = 'chat' | 'modern' | 'ws-session' | 'cli' | 'v2' + +export type KiroChatMessage = { + role: 'human' | 'bot' | 'tool' + content: string +} + +export type KiroChatFile = { + executionId: string + actionId: string + chat: KiroChatMessage[] + metadata: { + modelId: string + modelProvider: string + workflow: string + workflowId: string + startTime: number + endTime: number + } +} + +export type KiroModernExecution = Record + +export type KiroCliEntry = { + version: string + kind: 'Prompt' | 'AssistantMessage' | 'ToolResults' | 'Clear' + data: Record +} + +export type KiroCliSessionMeta = { + session_id: string + cwd: string + created_at: string + updated_at: string + title?: string + session_state?: { + rts_model_state?: { model_info?: { model_id?: string } } + conversation_metadata?: { + user_turn_metadatas?: Array<{ + end_timestamp?: string + builtin_tool_uses?: number + metering_usage?: Array<{ value: number; unit: string }> + total_request_count?: number + }> + } + } +} + +export type KiroV2SessionMeta = { + id?: string + title?: string + modelId?: string + workspacePaths?: string[] + createdAt?: string + lastModifiedAt?: string +} + +export type KiroWorkspaceSessionDraft = { + sessionId: string + modelId: string + inputChars: number + outputChars: number + pendingUserMessage: string + allTools: string[] +} + +export type KiroDecodedCall = { + provider: 'kiro' + arm: KiroArm + model: string + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + /** Metered Kiro credits for this call; 0 when unmetered. The HOST prices them. */ + credits: number + tools: string[] + bashCommands: string[] + timestamp: string + speed: 'standard' + deduplicationKey: string + userMessage: string + sessionId: string + /** Present ONLY for arms 'cli' and 'v2' (host-supplied attribution). */ + project?: string + /** Present ONLY for arm 'chat'; may be undefined, mirroring the host's shape. */ + toolSequence?: KiroToolCall[][] +} diff --git a/packages/core/tests/architecture-gate.test.ts b/packages/core/tests/architecture-gate.test.ts index 373e8ed8..b4016eb3 100644 --- a/packages/core/tests/architecture-gate.test.ts +++ b/packages/core/tests/architecture-gate.test.ts @@ -78,7 +78,13 @@ const TASK_CATEGORY_NAMES = [ // skill-vs-read classification (from pi.ts); it's host-side logic explanation, // not core classification vocabulary. // Keyed `${name} in ${rel}`; each entry is a justified false positive. -const CATEGORY_LITERAL_ALLOWLIST = new Set(['git in src/fingerprint.ts', 'general in src/providers/pi/decode.ts']) +const CATEGORY_LITERAL_ALLOWLIST = new Set([ + 'git in src/fingerprint.ts', + 'general in src/providers/pi/decode.ts', + // Kiro's modern-execution parser searches record-shape keys for conversation + // arrays; this is a provider data key, not task classification vocabulary. + 'conversation in src/providers/kiro/decode.ts', +]) // Identifiers that would signal classification or correction logic leaking into // core. (Names of the CLI-only classifier/scanner surface.) @@ -178,6 +184,8 @@ const USER_MESSAGE_ALLOWLIST = new Set([ 'src/providers/vscode-cline/types.ts', 'src/providers/opencode-session/decode.ts', 'src/providers/opencode-session/types.ts', + 'src/providers/kiro/decode.ts', + 'src/providers/kiro/types.ts', ]) describe('architecture gate: no classification or free text in @codeburn/core source', () => { diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index daa1765a..31de1d3a 100644 --- a/packages/core/tests/content-smuggling.test.ts +++ b/packages/core/tests/content-smuggling.test.ts @@ -45,6 +45,14 @@ import { decodeAntigravityStatusLine, toObservations as toAntigravityObservations, } from '../src/providers/antigravity/index.js' +import { + decodeKiroChatFile, + decodeKiroModernExecution, + decodeKiroIdeFile, + decodeKiroCliSession, + decodeKiroV2Session, + toObservations as toKiroObservations, +} from '../src/providers/kiro/index.js' import type { DecodeContext } from '../src/contracts.js' import type { ZedThreadRow } from '../src/providers/zed/index.js' import type { @@ -1916,3 +1924,156 @@ describe('content-smuggling guardrail: real cursor decode -> toObservations is s expect(allToolNames).not.toContain(`lang:${SECRETS.commandLine}`) }) }) + +describe('content-smuggling guardrail: real kiro decode -> toObservations is secret-free', () => { + // A hostile Kiro session planting every secret in the free-text fields kiro + // captures: user prompts, tool names, tool_result content, session/execution + // ids, and the CLI cwd. The observation envelope MUST surface none of them. + const kiroContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'kiro', + sourceRef: 'ref', + } + + function decodeAndMinimize() { + // 1. Chat human message carrying secrets -> userMessage (must not escape) + const chatCalls = decodeKiroChatFile({ + record: { + executionId: 'exec-chat', + actionId: 'act', + context: [], + validations: {}, + chat: [ + { role: 'human', content: 'x' }, + { role: 'human', content: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}` }, + { role: 'bot', content: `I will run ${SECRETS.commandLine}` }, + ], + metadata: { + modelId: 'claude-haiku-4-5', + modelProvider: 'qdev', + workflow: 'act', + workflowId: 'wf-chat', + startTime: 1777333000000, + endTime: 1777333010000, + }, + }, + fallbackChatSessionId: 'wf-chat', + context: kiroContext, + }).calls + + // 2. Modern execution prompt and response carrying secrets + const modernCalls = decodeKiroModernExecution({ + record: { + executionId: 'exec-modern', + sessionId: 'sess-modern', + startTime: 1777333000000, + modelId: 'claude-sonnet-4.5', + prompt: `${SECRETS.prompt} ${SECRETS.apiKey}`, + response: `Done. ${SECRETS.commandLine}`, + }, + fallbackExecutionId: 'exec-modern', + fallbackSessionId: 'sess-modern', + context: kiroContext, + }).calls + + // 3. usageSummary usedTools entry carrying a command line (unmapped -> tools) + const usageCalls = decodeKiroModernExecution({ + record: { + executionId: 'exec-usage', + sessionId: 'sess-usage', + startTime: 1777333000000, + modelId: 'claude-sonnet-4.5', + prompt: 'search', + response: 'ok', + usageSummary: [{ usedTools: [SECRETS.commandLine], usage: 1, unit: 'credit' }], + }, + fallbackExecutionId: 'exec-usage', + fallbackSessionId: 'sess-usage', + context: kiroContext, + }).calls + + // 4. v2 tool_result content string -> input tokens (must not escape) + const v2Lines = [ + JSON.stringify({ id: 'u', timestamp: '2026-07-14T13:39:00.000Z', payload: { type: 'user', content: SECRETS.prompt } }), + JSON.stringify({ id: 'ts', timestamp: '2026-07-14T13:39:40.000Z', payload: { type: 'turn_start', executionId: 'exec-v2' } }), + JSON.stringify({ id: 'tc', timestamp: '2026-07-14T13:39:40.000Z', payload: { type: 'tool_call', toolName: SECRETS.commandLine, toolCallId: 'tc1', executionId: 'exec-v2' } }), + JSON.stringify({ id: 'tr', timestamp: '2026-07-14T13:39:40.000Z', payload: { type: 'tool_result', toolCallId: 'tr1', content: SECRETS.fileContent, success: true, executionId: 'exec-v2' } }), + JSON.stringify({ id: 'us', timestamp: '2026-07-14T13:39:40.000Z', payload: { type: 'usage_summary', promptTurnSummaries: [{ unit: 'credit', usage: 1, usedTools: [] }] } }), + JSON.stringify({ id: 'te', timestamp: '2026-07-14T13:39:40.000Z', payload: { type: 'turn_end', executionId: 'exec-v2' } }), + ].join('\n') + const v2Calls = decodeKiroV2Session({ + lines: v2Lines, + meta: { id: 'sess-v2' }, + fallbackSessionId: 'sess-v2', + project: 'kiro-v2', + context: kiroContext, + }).calls + + // 5. CLI session: cwd is host-side project attribution, must not reach envelope + const cliCalls = decodeKiroCliSession({ + meta: { + session_id: 'sess-cli', + cwd: SECRETS.absPath, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:01:00Z', + }, + entries: [ + { version: '1', kind: 'Prompt', data: { content: [{ kind: 'text', data: SECRETS.prompt }] } }, + { version: '1', kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'ok' }] } }, + ], + project: 'kiro-cli', + context: kiroContext, + }).calls + + const calls = [...chatCalls, ...modernCalls, ...usageCalls, ...v2Calls, ...cliCalls] + + const { sessions } = toKiroObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'kiro' }, + ) + + return { + envelope: { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + }, + // Per-vector call counts, so a fixture that silently stops decoding is + // caught instead of making every "contains no secret" assertion vacuous. + vectors: { + chat: chatCalls.length, + modern: modernCalls.length, + usage: usageCalls.length, + v2: v2Calls.length, + cli: cliCalls.length, + }, + total: calls.length, + } + } + + it('every hostile fixture decodes to at least one call (non-vacuousness guard)', () => { + const { envelope, vectors, total } = decodeAndMinimize() + expect(vectors).toEqual({ chat: 1, modern: 1, usage: 1, v2: 1, cli: 1 }) + expect(total).toBe(5) + expect(envelope.sessions[0]!.calls.length).toBe(total) + }) + + it('produces a schema-valid envelope from the hostile session', () => { + const { envelope } = decodeAndMinimize() + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize().envelope) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) + + it('drops non-canonical (argument-carrying) tool names instead of emitting them', () => { + const { envelope } = decodeAndMinimize() + const allToolNames = envelope.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + // The hostile tool names fail the canonical-name regex and must be dropped. + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) +}) diff --git a/packages/core/tests/providers/kiro-decode.test.ts b/packages/core/tests/providers/kiro-decode.test.ts new file mode 100644 index 00000000..40683439 --- /dev/null +++ b/packages/core/tests/providers/kiro-decode.test.ts @@ -0,0 +1,418 @@ +import { describe, it, expect } from 'vitest' + +import { + decodeKiroChatFile, + decodeKiroCliSession, + decodeKiroIdeFile, + decodeKiroModernExecution, + decodeKiroV2Session, + extractStructuredToolNames, + extractText, + extractToolNames, + finishKiroWorkspaceSession, + parseKiroTimestamp, + prepareKiroWorkspaceSession, + toObservations, +} from '../../src/providers/kiro/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import type { DecodeContext } from '../../src/contracts.js' + +const context: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'kiro', sourceRef: 'ref' } + +describe('kiro core decode', () => { + describe('C1 — parseKiroTimestamp', () => { + it('accepts ms numbers, seconds numbers, numeric strings, and ISO strings', () => { + expect(parseKiroTimestamp(1777333000000)!.toISOString()).toBe('2026-04-27T23:36:40.000Z') + expect(parseKiroTimestamp(1777333000)!.toISOString()).toBe('2026-04-27T23:36:40.000Z') + expect(parseKiroTimestamp('1777333000000')!.toISOString()).toBe('2026-04-27T23:36:40.000Z') + expect(parseKiroTimestamp('2026-04-27T23:36:40.000Z')!.toISOString()).toBe('2026-04-27T23:36:40.000Z') + }) + + it('rejects garbage, undefined, empty string, sub-threshold values, Infinity and NaN', () => { + expect(parseKiroTimestamp(undefined)).toBeNull() + expect(parseKiroTimestamp('')).toBeNull() + expect(parseKiroTimestamp('not-a-timestamp')).toBeNull() + expect(parseKiroTimestamp(999_999_999)).toBeNull() + expect(parseKiroTimestamp('999999999')).toBeNull() + expect(parseKiroTimestamp(Number.POSITIVE_INFINITY)).toBeNull() + expect(parseKiroTimestamp(Number.NaN)).toBeNull() + }) + }) + + describe('C2 — extractText', () => { + it('returns strings, joins arrays, and recurses the six keys', () => { + expect(extractText('plain')).toBe('plain') + expect(extractText(['a', 'b'])).toBe('a\nb') + expect(extractText({ content: 'c' })).toBe('c') + expect(extractText({ text: 't' })).toBe('t') + expect(extractText({ message: 'm' })).toBe('m') + expect(extractText({ value: 'v' })).toBe('v') + expect(extractText({ parts: 'p' })).toBe('p') + expect(extractText({ entries: 'e' })).toBe('e') + }) + + it('handles deep nesting without cycles', () => { + expect(extractText({ content: [{ text: [{ value: 'deep' }] }] })).toBe('deep') + }) + + it('returns empty for non-records', () => { + expect(extractText(42)).toBe('') + expect(extractText(null)).toBe('') + }) + }) + + describe('C3 — extractToolNames / extractStructuredToolNames', () => { + it('extracts from tags and maps known names', () => { + expect(extractToolNames('Use runCommand then readFile')).toEqual(['Bash', 'Read']) + }) + + it('passes unmapped names through verbatim', () => { + expect(extractToolNames('custom_tool')).toEqual(['custom_tool']) + }) + + it('extracts direct names and array entries with mapping', () => { + expect(extractStructuredToolNames({ toolName: 'writeFile' }, '')).toEqual(['Edit']) + expect(extractStructuredToolNames({ toolCalls: [{ name: 'runCommand' }] }, '')).toEqual(['Bash']) + expect(extractStructuredToolNames({ tools: [{ tool_name: 'read_file' }] }, '')).toEqual(['Read']) + }) + + it('honors includeDirectName: false', () => { + expect(extractStructuredToolNames({ toolName: 'writeFile' }, '', { includeDirectName: false })).toEqual([]) + }) + }) + + describe('C4 — decodeKiroCliSession turn-index asymmetry (H3)', () => { + function makeMeta(turns: Array<{ end_timestamp?: string; metering_usage?: Array<{ value: number; unit: string }> }>): import('../../src/providers/kiro/types.js').KiroCliSessionMeta { + return { + session_id: 'sess-cli', + cwd: '/tmp/p', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:01:00Z', + session_state: { conversation_metadata: { user_turn_metadatas: turns } }, + } + } + + it('does not advance turnIndex on zero-output turn', () => { + const entries = [ + { version: '1', kind: 'Prompt' as const, data: { content: [{ kind: 'text', data: 'first' }] } }, + { version: '1', kind: 'AssistantMessage' as const, data: { content: [{ kind: 'text', data: 'first answer' }] } }, + { version: '1', kind: 'Prompt' as const, data: { content: [{ kind: 'text', data: 'second no output' }] } }, + { version: '1', kind: 'Prompt' as const, data: { content: [{ kind: 'text', data: 'third' }] } }, + { version: '1', kind: 'AssistantMessage' as const, data: { content: [{ kind: 'text', data: 'third answer' }] } }, + ] + const meta = makeMeta([ + { end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [{ value: 1, unit: 'credit' }] }, + { end_timestamp: '2026-01-01T00:01:30Z', metering_usage: [{ value: 2, unit: 'credit' }] }, + ]) + const { calls } = decodeKiroCliSession({ meta, entries, project: 'p', context }) + expect(calls).toHaveLength(2) + expect(calls[0]!.deduplicationKey).toBe('kiro-cli:sess-cli:0') + expect(calls[0]!.credits).toBe(1) + expect(calls[1]!.deduplicationKey).toBe('kiro-cli:sess-cli:1') + expect(calls[1]!.credits).toBe(2) + }) + + it('advances but does not burn on dedup', () => { + const seen = new Set(['kiro-cli:sess-cli:0']) + const entries = [ + { version: '1', kind: 'Prompt' as const, data: { content: [{ kind: 'text', data: 'first' }] } }, + { version: '1', kind: 'AssistantMessage' as const, data: { content: [{ kind: 'text', data: 'first answer' }] } }, + { version: '1', kind: 'Prompt' as const, data: { content: [{ kind: 'text', data: 'second' }] } }, + { version: '1', kind: 'AssistantMessage' as const, data: { content: [{ kind: 'text', data: 'second answer' }] } }, + ] + const meta = makeMeta([ + { end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [{ value: 1, unit: 'credit' }] }, + { end_timestamp: '2026-01-01T00:01:30Z', metering_usage: [{ value: 2, unit: 'credit' }] }, + ]) + const { calls } = decodeKiroCliSession({ meta, entries, project: 'p', context, seenKeys: seen }) + expect(calls).toHaveLength(1) + expect(calls[0]!.deduplicationKey).toBe('kiro-cli:sess-cli:1') + expect(seen.has('kiro-cli:sess-cli:1')).toBe(true) + }) + + it('advances but does not burn on invalid timestamp', () => { + const entries = [ + { version: '1', kind: 'Prompt' as const, data: { content: [{ kind: 'text', data: 'first' }] } }, + { version: '1', kind: 'AssistantMessage' as const, data: { content: [{ kind: 'text', data: 'first answer' }] } }, + ] + const meta = makeMeta([{ end_timestamp: 'not-a-date', metering_usage: [{ value: 1, unit: 'credit' }] }]) + const seen = new Set() + const { calls } = decodeKiroCliSession({ meta, entries, project: 'p', context, seenKeys: seen }) + expect(calls).toHaveLength(0) + expect(seen.size).toBe(0) + }) + + it('a dropped bad-timestamp turn still consumes its metadata slot', () => { + // Three output-bearing turns; turn 2's metadata timestamp is garbage. The + // bad-timestamp arm advances turnIndex, so turn 3 pairs with slot 2. If + // that arm stopped advancing, turn 3 would re-read slot 1 and be dropped. + const entries = [ + { version: '1', kind: 'Prompt' as const, data: { content: [{ kind: 'text', data: 'first' }] } }, + { version: '1', kind: 'AssistantMessage' as const, data: { content: [{ kind: 'text', data: 'first answer' }] } }, + { version: '1', kind: 'Prompt' as const, data: { content: [{ kind: 'text', data: 'second' }] } }, + { version: '1', kind: 'AssistantMessage' as const, data: { content: [{ kind: 'text', data: 'second answer' }] } }, + { version: '1', kind: 'Prompt' as const, data: { content: [{ kind: 'text', data: 'third' }] } }, + { version: '1', kind: 'AssistantMessage' as const, data: { content: [{ kind: 'text', data: 'third answer' }] } }, + ] + const meta = makeMeta([ + { end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [{ value: 1, unit: 'credit' }] }, + { end_timestamp: 'not-a-date', metering_usage: [{ value: 9, unit: 'credit' }] }, + { end_timestamp: '2026-01-01T00:02:30Z', metering_usage: [{ value: 3, unit: 'credit' }] }, + ]) + const seen = new Set() + const { calls } = decodeKiroCliSession({ meta, entries, project: 'p', context, seenKeys: seen }) + expect(calls).toHaveLength(2) + expect(calls[0]!.deduplicationKey).toBe('kiro-cli:sess-cli:0') + expect(calls[0]!.credits).toBe(1) + expect(calls[1]!.deduplicationKey).toBe('kiro-cli:sess-cli:2') + expect(calls[1]!.credits).toBe(3) + expect(calls[1]!.timestamp).toBe('2026-01-01T00:02:30.000Z') + expect([...seen].sort()).toEqual(['kiro-cli:sess-cli:0', 'kiro-cli:sess-cli:2']) + }) + }) + + describe('C5 — decodeKiroV2Session state machine (H13)', () => { + function makeLines(events: Array<{ id?: string; timestamp?: string; payload: Record }>): string { + return events.map(e => JSON.stringify({ id: e.id ?? 'x', timestamp: e.timestamp ?? '2026-07-14T13:39:40.000Z', payload: e.payload })).join('\n') + } + + it('handles implicit turn (assistant without turn_start)', () => { + const lines = makeLines([ + { payload: { type: 'user', content: 'user prompt' } }, + { payload: { type: 'assistant', operationType: 'Say', content: 'implicit answer' } }, + ]) + const { calls } = decodeKiroV2Session({ lines, meta: { id: 'sess-v2' }, fallbackSessionId: 'fallback', project: 'p', context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.userMessage).toBe('') + expect(calls[0]!.inputTokens).toBe(0) + expect(calls[0]!.outputTokens).toBe(4) + }) + + it('defensively flushes on user event mid-turn', () => { + const lines = makeLines([ + { payload: { type: 'turn_start', executionId: 'exec-1' } }, + { payload: { type: 'assistant', operationType: 'Say', content: 'first' } }, + { payload: { type: 'user', content: 'new prompt' } }, + { payload: { type: 'turn_start', executionId: 'exec-2' } }, + { payload: { type: 'assistant', operationType: 'Say', content: 'second' } }, + { payload: { type: 'turn_end' } }, + ]) + const { calls } = decodeKiroV2Session({ lines, meta: { id: 'sess-v2' }, fallbackSessionId: 'fallback', project: 'p', context }) + expect(calls).toHaveLength(2) + }) + + it('trailing flush captures in-progress turn', () => { + const lines = makeLines([ + { payload: { type: 'turn_start', executionId: 'exec-1' } }, + { payload: { type: 'assistant', operationType: 'Say', content: 'in progress' } }, + ]) + const { calls } = decodeKiroV2Session({ lines, meta: { id: 'sess-v2' }, fallbackSessionId: 'fallback', project: 'p', context }) + expect(calls).toHaveLength(1) + }) + + it('resetTurn does not clear pending user message', () => { + const lines = makeLines([ + { payload: { type: 'user', content: 'pending' } }, + { payload: { type: 'turn_start', executionId: 'exec-1' } }, + { payload: { type: 'assistant', operationType: 'Say', content: 'answer' } }, + { payload: { type: 'turn_end' } }, + ]) + const { calls } = decodeKiroV2Session({ lines, meta: { id: 'sess-v2' }, fallbackSessionId: 'fallback', project: 'p', context }) + expect(calls).toHaveLength(1) + expect(calls[0]!.userMessage).toBe('pending') + }) + }) + + describe('C6 — prepareKiroWorkspaceSession + finishKiroWorkspaceSession', () => { + it('returns not-workspace-session when shape is wrong', () => { + expect(prepareKiroWorkspaceSession({ chat: [] }).kind).toBe('not-workspace-session') + expect(prepareKiroWorkspaceSession({ history: [], sessionId: 'x' }).kind).toBe('empty') + }) + + it('returns empty for stub-only or zero-content sessions', () => { + expect(prepareKiroWorkspaceSession({ + sessionId: 'stub', + history: [{ message: { role: 'user', content: 'hi' } }, { message: { role: 'assistant', content: 'On it.' }, executionId: 'exec-1' }], + }).kind).toBe('empty') + expect(prepareKiroWorkspaceSession({ + sessionId: 'empty', + history: [{ message: { role: 'user', content: '' } }], + }).kind).toBe('empty') + }) + + it('returns ready for real content and finish emits with injected timestamp', () => { + const prepared = prepareKiroWorkspaceSession({ + sessionId: 'ws-real', + selectedModel: 'claude-opus-4.8', + history: [{ message: { role: 'user', content: 'hi' } }, { message: { role: 'assistant', content: 'hello' } }], + }) + expect(prepared.kind).toBe('ready') + if (prepared.kind !== 'ready') throw new Error('unexpected') + const { calls } = finishKiroWorkspaceSession(prepared.draft, { timestamp: '2026-07-14T13:39:40.000Z' }) + expect(calls).toHaveLength(1) + expect(calls[0]!.timestamp).toBe('2026-07-14T13:39:40.000Z') + expect(calls[0]!.deduplicationKey).toBe('kiro:ws-session:ws-real') + }) + + it('finish does not burn dedup key when already seen', () => { + const prepared = prepareKiroWorkspaceSession({ + sessionId: 'ws-dup', + history: [{ message: { role: 'user', content: 'hi' } }, { message: { role: 'assistant', content: 'hello' } }], + }) + if (prepared.kind !== 'ready') throw new Error('unexpected') + const seen = new Set(['kiro:ws-session:ws-dup']) + const { calls } = finishKiroWorkspaceSession(prepared.draft, { timestamp: '2026-07-14T13:39:40.000Z', seenKeys: seen }) + expect(calls).toHaveLength(0) + expect(seen.size).toBe(1) + }) + }) + + describe('C7 — key-burn pins per arm', () => { + it('A1 chat does not burn on skip', () => { + const seen = new Set(['kiro:wf:exec']) + const { calls } = decodeKiroChatFile({ + record: { + executionId: 'exec', + actionId: 'act', + context: [], + validations: {}, + chat: [ + { role: 'human', content: 'x' }, + { role: 'human', content: 'hi' }, + { role: 'bot', content: 'hello' }, + ], + metadata: { modelId: 'claude-haiku-4-5', modelProvider: 'qdev', workflow: 'act', workflowId: 'wf', startTime: 1777333000000, endTime: 1777333010000 }, + } as unknown as import('../../src/providers/kiro/types.js').KiroChatFile, + fallbackChatSessionId: 'wf', + context, + seenKeys: seen, + }) + expect(calls).toHaveLength(0) + expect(seen.size).toBe(1) + }) + + it('A2 modern does not burn on invalid timestamp', () => { + const seen = new Set() + const { calls } = decodeKiroModernExecution({ + record: { executionId: 'exec', sessionId: 'sess', startTime: 'bad', prompt: 'hi', response: 'hello' }, + fallbackExecutionId: 'exec', + fallbackSessionId: 'sess', + context, + seenKeys: seen, + }) + expect(calls).toHaveLength(0) + expect(seen.size).toBe(0) + }) + + it('A3 finish does not burn when already seen', () => { + const seen = new Set(['kiro:ws-session:ws']) + const { calls } = finishKiroWorkspaceSession( + { sessionId: 'ws', modelId: 'kiro-auto', inputChars: 2, outputChars: 2, pendingUserMessage: 'hi', allTools: [] }, + { timestamp: '2026-07-14T13:39:40.000Z', seenKeys: seen }, + ) + expect(calls).toHaveLength(0) + expect(seen.size).toBe(1) + }) + + it('A4 CLI does not burn on invalid timestamp', () => { + const seen = new Set() + const entries = [ + { version: '1', kind: 'Prompt' as const, data: { content: [{ kind: 'text', data: 'hi' }] } }, + { version: '1', kind: 'AssistantMessage' as const, data: { content: [{ kind: 'text', data: 'hello' }] } }, + ] + const meta: import('../../src/providers/kiro/types.js').KiroCliSessionMeta = { + session_id: 'sess', + cwd: '/tmp/p', + created_at: 'bad-date', + updated_at: 'bad-date', + } + const { calls } = decodeKiroCliSession({ meta, entries, project: 'p', context, seenKeys: seen }) + expect(calls).toHaveLength(0) + expect(seen.size).toBe(0) + }) + + it('A5 v2 does not burn on invalid timestamp', () => { + const seen = new Set() + const lines = [ + JSON.stringify({ id: 'u', timestamp: '2026-07-14T13:39:00.000Z', payload: { type: 'user', content: 'hi' } }), + JSON.stringify({ id: 'ts', timestamp: 'bad', payload: { type: 'turn_start', executionId: 'exec' } }), + JSON.stringify({ id: 'a', timestamp: 'bad', payload: { type: 'assistant', operationType: 'Say', content: 'hello' } }), + JSON.stringify({ id: 'te', timestamp: 'bad', payload: { type: 'turn_end' } }), + ].join('\n') + const { calls } = decodeKiroV2Session({ lines, meta: { id: 'sess' }, fallbackSessionId: 'sess', project: 'p', context, seenKeys: seen }) + expect(calls).toHaveLength(0) + expect(seen.size).toBe(0) + }) + }) + + describe('C8 — toObservations minimizes correctly', () => { + it('produces a schema-valid envelope with fingerprinted refs', () => { + const { sessions } = toObservations({ + sessionId: 'sess-obs', + projectPath: '/Users/victim/company/secret', + calls: [{ + provider: 'kiro', + arm: 'modern', + model: 'claude-sonnet-4', + inputTokens: 10, + outputTokens: 5, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + credits: 0, + tools: ['Bash', 'not canonical!'], + bashCommands: [], + timestamp: '2026-07-14T13:39:40.000Z', + speed: 'standard', + deduplicationKey: 'kiro:sess:1', + userMessage: 'SECRET PROMPT', + sessionId: 'sess-obs', + }], + }, { privacyKey: 'test-privacy-key', provider: 'kiro' }) + + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.sessionRef).toMatch(/^[0-9a-f]{16}$/) + expect(sessions[0]!.projectRef).toMatch(/^[0-9a-f]{16}$/) + expect(sessions[0]!.calls[0]!.turnIndex).toBe(0) + expect(sessions[0]!.calls[0]!.toolNames).toEqual(['Bash']) + expect(JSON.stringify(envelope)).not.toContain('SECRET PROMPT') + expect(JSON.stringify(envelope)).not.toContain('/Users/victim/company/secret') + }) + + it('maps costBasis to measured when credits are present', () => { + const { sessions } = toObservations({ + sessionId: 'sess-obs2', + projectPath: '/tmp', + calls: [{ + provider: 'kiro', + arm: 'modern', + model: 'claude-sonnet-4', + inputTokens: 10, + outputTokens: 5, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + credits: 1.5, + tools: [], + bashCommands: [], + timestamp: '2026-07-14T13:39:40.000Z', + speed: 'standard', + deduplicationKey: 'kiro:sess:2', + userMessage: 'hi', + sessionId: 'sess-obs2', + }], + }, { privacyKey: 'test-privacy-key' }) + expect(sessions[0]!.calls[0]!.costBasis).toBe('measured') + }) + }) +}) diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index f7d7b29d..26e5d52a 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -43,6 +43,7 @@ export default defineConfig({ 'src/providers/vscode-cline/index.ts', 'src/providers/opencode-session/index.ts', 'src/providers/antigravity/index.ts', + 'src/providers/kiro/index.ts', ], format: ['esm'], target: 'node20',