diff --git a/docs/providers/kiro.md b/docs/providers/kiro.md index 6fe2819c..0252e901 100644 --- a/docs/providers/kiro.md +++ b/docs/providers/kiro.md @@ -14,20 +14,30 @@ VS Code-style globalStorage at `kiro.kiroagent`: |---|---| | macOS | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent` | | Windows | `%APPDATA%/Kiro/User/globalStorage/kiro.kiroagent` | -| Linux | `~/.config/Kiro/User/globalStorage/kiro.kiroagent` | +| Linux | `~/.config/Kiro/User/globalStorage/kiro.kiroagent` and `~/.kiro-server/data/User/globalStorage/kiro.kiroagent` (remote dev boxes / Cloud Desktop) | -Sessions are under hash-named workspace subdirectories. Discovery keeps backward compatibility with legacy `.chat` files and also scans the post-February 2026 extensionless format: +On Linux both the local and remote-server globalStorage paths are scanned when present. + +Two more stores live outside globalStorage, under `~/.kiro/sessions` (honors `KIRO_HOME`): + +- **CLI store:** `~/.kiro/sessions/cli/.jsonl` (+ companion `.json`) +- **IDE v2 store:** `~/.kiro/sessions//sess_/` (see below) + +Sessions in globalStorage are under hash-named workspace subdirectories. Discovery keeps backward compatibility with legacy `.chat` files and also scans the post-February 2026 extensionless format: - `/.chat` legacy session files - `/` extensionless session index files - `//` extensionless execution files inside session directories +- `workspace-sessions//.json` v1 session records, a sibling of the workspace-hash directories at the `kiro.kiroagent` root (conversation spine; execution-backed stubs are skipped to avoid double-counting the execution files) ## Storage format -Kiro has two known JSON formats: +Kiro has several known formats across generations: -- Legacy `.chat` files with `{ chat, metadata, executionId }` -- Modern extensionless execution files with identifiers/timestamps at the top level plus conversation fields such as `messages`, `conversation`, `chat`, `transcript`, `entries`, `events`, or direct prompt/response fields +- **Legacy `.chat`** files with `{ chat, metadata, executionId }` +- **Modern extensionless execution files** with identifiers/timestamps at the top level plus conversation fields such as `messages`, `conversation`, `chat`, `transcript`, `entries`, `events`, `context.messages`, or direct prompt/response fields. In v1 these are the *content* half; the paired `workspace-sessions/.json` record is the *spine* (linked by `executionId` ⇄ `chatSessionId`). +- **CLI** JSONL: one `{ version, kind, data }` event per line + a companion `.json` with `session_state` metering (`metering_usage` gives real per-turn cost in credits). +- **IDE v2** (`sess_/`): a self-contained, event-sourced session directory — `session.json` (metadata incl. the real `modelId`, `title`, `workspacePaths`) + `messages.jsonl` (append-only `{ id, timestamp, payload:{ type, ... } }` events) + `publish.cursor` + `snapshots/`. Assistant content is inline (no separate execution files). Usage is billed in **credits** via `usage_summary.promptTurnSummaries` (no token counts): cost comes from credits at the public overage rate ($0.04/credit, `USD_PER_KIRO_CREDIT`), while token counts are estimated from transcript text. Session index files with `{ executions: [...] }` are discovered but skipped during parsing because they do not contain conversation content. @@ -37,7 +47,9 @@ None. ## Deduplication -Modern files deduplicate per session/execution pair. Legacy `.chat` files deduplicate per workflow/execution pair. +Modern files deduplicate per session/execution pair. Legacy `.chat` files deduplicate per workflow/execution pair. CLI turns key on `kiro-cli::`. v2 turns key on `kiro-v2::`. + +The stores are disjoint (v2 sessions use `sess_`-prefixed IDs in a separate directory), so no session is counted twice today. Migration from v1 workspace-sessions to v2 is currently detect-only; if it begins moving records, add a guard preferring v2 when the same logical session appears in both. ## Quirks @@ -45,6 +57,8 @@ Modern files deduplicate per session/execution pair. Legacy `.chat` files dedupl - **Model ID normalization.** Kiro stores models like `claude-1.2`; the parser rewrites the dot to a hyphen so they match `claude-1-2` in the pricing snapshot. Add new versions here when Kiro ships them. - **Tool name extraction accepts text and structured calls.** Kiro can embed tool calls inside message text as `...` or expose structured `toolCalls` / `tool_calls` / `tools` entries. - Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`). +- **Credits are the cost source; tokens stay estimated.** Kiro bills in credits ($20/mo for 1,000; overage $0.04/credit). CLI (`metering_usage`), v1 executions (`usageSummary[].usage`), and v2 (`usage_summary.promptTurnSummaries[].usage`) turns record real credits, converted to USD at `USD_PER_KIRO_CREDIT = 0.04` (the public overage rate — the same never-understate approach as Codebuff). Turns without credit data fall back to token-estimated cost (`costIsEstimated: true`); legacy `.chat` and workspace-session records carry no usage data, so they are always token-estimated. Note: an earlier CLI implementation summed credit values directly as dollars, overstating cost 25×. Token *counts* remain char-estimated everywhere (input undercounts: only visible transcript text is seen, not the full resent context; v2's `session_metadata.contextUsage.usagePercentage` × context window is a better input proxy if ever needed). v2 does keep the real `modelId`, so unlike the v1 execution-file path it is not mislabeled `kiro-auto`. +- **Cost is frozen at parse time.** Kiro is on the `costUSD` pass-through allowlist in `providerCallToCachedCall` (alongside mistral-vibe, devin, hermes, …), so its credit-based cost survives the session cache instead of being re-priced from estimated tokens — token re-pricing understated/overstated real kiro spend by up to 16× per model. The tradeoff, shared with all allowlisted providers: `codeburn price-override` and `model-alias` do not affect kiro dollar amounts (token *counts* are unaffected). Historical caches from before this change re-parse via the `CACHE_VERSION` bump to 5. ## When fixing a bug here diff --git a/src/daily-cache.ts b/src/daily-cache.ts index c697cfbd..638a75c6 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -5,18 +5,23 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' -// Bumped to 10: cursor accounting changed (real composer context tokens on +// Bumped to 11: kiro cost accounting changed (metered credits pass through +// the session cache instead of being re-priced from estimated tokens), so +// days finalized at v10 carry token-estimated kiro costs that were off by up +// to 16× per model. Raising MIN_SUPPORTED_VERSION forces the one-time full +// re-hydration that backfills history under credit-based pricing. +// +// v10: cursor accounting changed (real composer context tokens on // conversation-anchored records, Cursor-published composer pricing), so days // finalized at v9 carry the old double-counted agentKv estimates and -// sonnet-proxy composer costs. Raising MIN_SUPPORTED_VERSION forces the -// one-time full re-hydration that backfills history under the new accounting. +// sonnet-proxy composer costs. // // v9: providers added since the v8 rollup (Grok, Hermes, ZCode) parse usage // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 10 -const MIN_SUPPORTED_VERSION = 10 +export const DAILY_CACHE_VERSION = 11 +const MIN_SUPPORTED_VERSION = 11 const DAILY_CACHE_FILENAME = 'daily-cache.json' export type DailyEntry = { diff --git a/src/parser.ts b/src/parser.ts index 2f5de981..64379cb9 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1754,7 +1754,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { webSearchRequests: call.webSearchRequests, cacheCreationOneHourTokens: 0, }, - costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes') ? call.costUSD : undefined, + costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro') ? call.costUSD : undefined, speed: call.speed, timestamp: call.timestamp, tools: call.tools, diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts index df63ca2f..bc91e1e9 100644 --- a/src/providers/kiro.ts +++ b/src/providers/kiro.ts @@ -10,6 +10,11 @@ import type { ToolCall } from '../types.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' const CHARS_PER_TOKEN = 4 +// Kiro bills in credits: individual plans are $20/mo for 1,000 credits and +// overage is billed at $0.04 per additional credit. We price credits at the +// 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'] @@ -239,6 +244,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s reasoningTokens: 0, webSearchRequests: 0, costUSD, + costIsEstimated: true, tools: [...new Set(allTools)], bashCommands: [], toolSequence: toolSequence.length > 1 ? toolSequence : undefined, @@ -330,13 +336,18 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see if (found) break } - // Extract tools from usageSummary (reliable structured tool list in current Kiro builds). - // usageSummary is an array of per-turn entries with optional usedTools field. + // 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) { @@ -363,7 +374,12 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) - const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) + // 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. + const costUSD = executionCredits > 0 + ? executionCredits * USD_PER_KIRO_CREDIT + : calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) seenKeys.add(dedupKey) results.push({ @@ -377,6 +393,7 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see reasoningTokens: 0, webSearchRequests: 0, costUSD, + costIsEstimated: executionCredits === 0, tools: [...new Set(allTools)], bashCommands: [], timestamp: tsDate.toISOString(), @@ -445,12 +462,20 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen const tsDate = parseKiroTimestamp(timestamp) if (!tsDate) { turnIndex++; return } - const costUSD = turnMeta?.metering_usage - ? turnMeta.metering_usage.reduce((sum, m) => sum + m.value, 0) - : 0 - const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) + // 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 + const costUSD = turnCredits > 0 + ? turnCredits * USD_PER_KIRO_CREDIT + : calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) seenKeys.add(dedupKey) results.push({ @@ -464,7 +489,7 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen reasoningTokens: 0, webSearchRequests: 0, costUSD, - costIsEstimated: !turnMeta?.metering_usage, + costIsEstimated: turnCredits === 0, tools: [...new Set(allTools)], bashCommands: [], timestamp: tsDate.toISOString(), @@ -537,9 +562,289 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen 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 = Math.ceil(inputChars / CHARS_PER_TOKEN) + const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) + const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) + + results.push({ + provider: 'kiro', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + 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 = Math.ceil((turnUserChars + toolResultChars) / CHARS_PER_TOKEN) + // 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 = Math.ceil(reasoningChars / CHARS_PER_TOKEN) + const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) + // 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). + const costUSD = turnCredits > 0 + ? turnCredits * USD_PER_KIRO_CREDIT + : calculateCost(modelId, inputTokens, outputTokens + reasoningTokens, 0, 0, 0) + results.push({ + provider: 'kiro', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens, + webSearchRequests: 0, + costUSD, + 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 +} + function createParser(source: SessionSource, seenKeys: Set): SessionParser { return { async *parse(): AsyncGenerator { + // 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 + return + } + // CLI session: path points to a .jsonl file if (source.path.endsWith('.jsonl')) { const jsonlContent = await readSessionFile(source.path) @@ -585,92 +890,8 @@ 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. - const historyArr = record['history'] - if (Array.isArray(historyArr) && typeof record['sessionId'] === 'string') { - const sessionId = record['sessionId'] as string - 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 - - // Skip sessions with no meaningful content - if (inputChars === 0 && outputChars === 0) return - - // 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 - } - - const dedupKey = `kiro:ws-session:${sessionId}` - if (seenKeys.has(dedupKey)) return - seenKeys.add(dedupKey) - - const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) - const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) - const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) - - yield { - provider: 'kiro', - model: modelId, - inputTokens, - outputTokens, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens: 0, - webSearchRequests: 0, - costUSD, - costIsEstimated: true, - tools: [...new Set(allTools)], - bashCommands: [], - timestamp, - speed: 'standard', - deduplicationKey: dedupKey, - userMessage: pendingUserMessage, - sessionId, - } + if (Array.isArray(record['history']) && typeof record['sessionId'] === 'string') { + for (const call of await parseWorkspaceSession(record, source, seenKeys)) yield call return } @@ -837,11 +1058,66 @@ async function discoverSessions(agentDir: string, workspaceStorageDir: string, c return sources } -export function createKiroProvider(agentDirOverride?: string, workspaceStorageDirOverride?: string, cliSessionsDirOverride?: string): Provider { +// Discover v2 IDE sessions under ~/.kiro/sessions//sess_*/. +// Each session is a self-contained directory (session.json + messages.jsonl); +// the emitted source points at messages.jsonl and routes to parseV2Session. +async function discoverV2Sessions(sessionsRoot: string): Promise { + const sources: SessionSource[] = [] + let hashDirs: Dirent[] + try { + hashDirs = await readdir(sessionsRoot, { withFileTypes: true }) + } catch { + return sources + } + + for (const hd of hashDirs) { + // Skip the CLI store (scanned separately) and any stray files. + if (!hd.isDirectory() || hd.name === 'cli') continue + const hashPath = join(sessionsRoot, hd.name) + + let sessDirs: Dirent[] + try { + sessDirs = await readdir(hashPath, { withFileTypes: true }) + } catch { + continue + } + + for (const sd of sessDirs) { + if (!sd.isDirectory() || !sd.name.startsWith('sess_')) continue + const sessDir = join(hashPath, sd.name) + const messagesPath = join(sessDir, 'messages.jsonl') + if (!existsSync(messagesPath)) continue + + // Project from session.json workspacePaths; fall back to a generic label. + let project = 'kiro-ide' + try { + const raw = await readFile(join(sessDir, 'session.json'), 'utf-8') + const sj = JSON.parse(raw) as { workspacePaths?: string[] } + const wp = sj.workspacePaths?.[0] + if (wp) project = basename(wp) + } catch {} + if (project === basename(homedir())) project = 'kiro-ide' + + sources.push({ path: messagesPath, project, provider: 'kiro' }) + } + } + + return sources +} + +export function createKiroProvider(agentDirOverride?: string, workspaceStorageDirOverride?: string, cliSessionsDirOverride?: string, v2SessionsRootOverride?: string): Provider { const agentDirs = getKiroAgentDir(agentDirOverride) const wsDir = getKiroWorkspaceStorageDir(workspaceStorageDirOverride) // When overrides are provided (tests), don't scan real CLI sessions unless explicitly given const cliDir = cliSessionsDirOverride ?? (agentDirOverride ? join(agentDirOverride, '..', 'cli-sessions') : join(process.env['KIRO_HOME'] || join(homedir(), '.kiro'), 'sessions', 'cli')) + // v2 IDE sessions live under ~/.kiro/sessions//sess_*/, a sibling of the + // CLI store (.../sessions/cli). Derive the root from cliDir ONLY when cliDir was + // itself explicit (default path or cliSessionsDirOverride). When only + // agentDirOverride is set (tests), the derived cliDir parent would point at an + // arbitrary directory (e.g. the system tmpdir) — scan nothing in that case. + const v2Root = v2SessionsRootOverride ?? + (cliSessionsDirOverride ? dirname(cliSessionsDirOverride) : + agentDirOverride ? undefined : dirname(cliDir)) return { name: 'kiro', @@ -866,6 +1142,8 @@ export function createKiroProvider(agentDirOverride?: string, workspaceStorageDi const sources = await discoverSessions(agentDir, wsDir, cliDir) allSources.push(...sources) } + // v2 IDE sessions: scan the resolved v2 root (undefined = skip, see above). + if (v2Root) allSources.push(...await discoverV2Sessions(v2Root)) // CLI sessions are only scanned once (first agentDir pass includes them); // deduplicate by path in case multiple agentDirs share the same CLI dir. const seen = new Set() diff --git a/src/session-cache.ts b/src/session-cache.ts index 7d02e9cd..7f7b79ed 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -82,7 +82,11 @@ export type SessionCache = { // ── Constants ────────────────────────────────────────────────────────── -export const CACHE_VERSION = 4 +// v5: kiro joined the costUSD pass-through allowlist (credit-based pricing). +// Cached kiro entries from v4 carry costUSD: undefined and would keep being +// re-priced from estimated tokens forever, since historical session files +// never change. Bump forces a one-time re-parse so metered credit costs land. +export const CACHE_VERSION = 5 const CACHE_FILE = 'session-cache.json' const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000 @@ -292,6 +296,15 @@ export async function saveCache(cache: SessionCache): Promise { } // ── File Fingerprinting ──────────────────────────────────────────────── +// +// Fingerprints cover the source's transcript file only. Providers that keep +// metadata in a companion file (kiro CLI: credits in `.json` next to the +// `.jsonl`; kiro v2: modelId in `session.json` next to `messages.jsonl`) have +// a blind spot: a parse that races the companion write caches the turn with +// fallback values, and if the transcript never changes again (a session's +// final turn) the entry never invalidates. Mid-session turns self-heal since +// append-only transcripts keep changing. Fixing this properly means +// multi-file fingerprints per source. export async function fingerprintFile(filePath: string): Promise { try { diff --git a/tests/provider-turn-grouping.test.ts b/tests/provider-turn-grouping.test.ts index d9dd0d65..bee7585d 100644 --- a/tests/provider-turn-grouping.test.ts +++ b/tests/provider-turn-grouping.test.ts @@ -155,4 +155,45 @@ describe('provider turn grouping', () => { expect(session.totalOutputTokens).toBe(90) expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0) }) + + it('preserves Kiro credit-based cost through cache conversion instead of re-pricing from tokens', async () => { + const kiroHome = join(home, '.kiro') + const cliDir = join(kiroHome, 'sessions', 'cli') + await mkdir(cliDir, { recursive: true }) + process.env['KIRO_HOME'] = kiroHome + + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + await writeFile(join(cliDir, `${sessionId}.jsonl`), [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hi' }], meta: { timestamp: 1778925600 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'short reply' }] } }), + ].join('\n') + '\n') + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/Users/test/project-a', + created_at: '2026-05-16T10:00:00Z', + updated_at: '2026-05-16T10:01:00Z', + session_state: { + rts_model_state: { model_info: { model_id: 'claude-sonnet-4.6' } }, + conversation_metadata: { + user_turn_metadatas: [{ + end_timestamp: '2026-05-16T10:00:30Z', + // 2.5 credits × $0.04/credit = $0.10 — far from any token estimate + // of this tiny transcript, so passing means the metered cost was + // preserved through providerCallToCachedCall/cachedCallToApiCall. + metering_usage: [{ value: 2.5, unit: 'credit' }], + }], + }, + }, + })) + + try { + const parseAllSessions = await loadParser() + const projects = await parseAllSessions(dayRange(), 'kiro') + const session = projects[0]!.sessions[0]! + + expect(session.totalCostUSD).toBeCloseTo(2.5 * 0.04, 8) + } finally { + delete process.env['KIRO_HOME'] + } + }) }) diff --git a/tests/providers/kiro.test.ts b/tests/providers/kiro.test.ts index 59286702..a12ae4b4 100644 --- a/tests/providers/kiro.test.ts +++ b/tests/providers/kiro.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' import { join } from 'path' -import { tmpdir } from 'os' +import { tmpdir, homedir } from 'os' import { kiro, createKiroProvider } from '../../src/providers/kiro.js' import type { ParsedProviderCall } from '../../src/providers/types.js' @@ -617,14 +617,18 @@ describe('kiro provider - metadata', () => { }) describe('kiro provider - CLI session discovery', () => { + let cliRoot: string let cliDir: string beforeEach(async () => { - cliDir = await mkdtemp(join(tmpdir(), 'kiro-cli-test-')) + // cliDir sits at /cli so v2 discovery derives (not the system tmpdir). + cliRoot = await mkdtemp(join(tmpdir(), 'kiro-cli-test-')) + cliDir = join(cliRoot, 'cli') + await mkdir(cliDir, { recursive: true }) }) afterEach(async () => { - await rm(cliDir, { recursive: true, force: true }) + await rm(cliRoot, { recursive: true, force: true }) }) it('discovers .jsonl files from CLI sessions directory', async () => { @@ -681,7 +685,7 @@ describe('kiro provider - CLI session discovery', () => { expect(call.model).toBe('kiro-auto') expect(call.tools).toContain('Read') expect(call.userMessage).toBe('hello world') - expect(call.costUSD).toBeCloseTo(0.13, 2) + expect(call.costUSD).toBeCloseTo(0.13 * 0.04, 4) // 0.13 credits × $0.04/credit expect(call.deduplicationKey).toBe(`kiro-cli:${sessionId}:0`) expect(call.timestamp).toBe('2026-01-01T00:00:30.000Z') expect(call.project).toBe('test-project') @@ -721,7 +725,69 @@ describe('kiro provider - CLI session discovery', () => { expect(calls[0]!.userMessage).toBe('first question') expect(calls[0]!.model).toBe('claude-sonnet-4') expect(calls[1]!.userMessage).toBe('second question') - expect(calls[1]!.costUSD).toBeCloseTo(0.06, 2) + expect(calls[1]!.costUSD).toBeCloseTo(0.06 * 0.04, 4) // 0.06 credits × $0.04/credit + }) + + it('falls back to token-estimated cost for CLI turns without metering_usage', async () => { + const sessionId = '44444444-4444-4444-4444-444444444444' + const jsonl = [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hi' }], meta: { timestamp: 1700000000 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'x'.repeat(4000) }] } }), + ].join('\n') + + await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl) + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/tmp/test-project', + 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.6' } }, + // no conversation_metadata / metering_usage (e.g. turn still in flight) + }, + })) + + const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'test-project', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + // Token-priced at the session's real model, not $0 — same fallback + // contract as the v1-execution and v2 parsers. + expect(calls[0]!.costIsEstimated).toBe(true) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('treats an empty metering_usage array as no metering (token fallback, not frozen $0)', async () => { + const sessionId = '55555555-5555-5555-5555-555555555555' + const jsonl = [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hi' }], meta: { timestamp: 1700000000 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'x'.repeat(4000) }] } }), + ].join('\n') + + await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl) + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/tmp/test-project', + 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.6' } }, + conversation_metadata: { + // Observed in real sessions: the turn metadata exists but metering + // hasn't landed yet — an empty array, which is truthy. + user_turn_metadatas: [{ end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [] }], + }, + }, + })) + + const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'test-project', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costIsEstimated).toBe(true) + expect(calls[0]!.costUSD).toBeGreaterThan(0) }) it('skips non-jsonl files in CLI directory', async () => { @@ -766,7 +832,7 @@ describe('kiro provider - context.messages with entries', () => { await mkdir(join(tmpDir, wsHash, subDir), { recursive: true }) await writeFile(join(tmpDir, wsHash, subDir, 'exec-ctx-001'), file) - const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') const sessions = await provider.discoverSessions() expect(sessions.length).toBeGreaterThan(0) @@ -808,7 +874,7 @@ describe('kiro provider - context.messages with entries', () => { await mkdir(join(tmpDir, wsHash, subDir), { recursive: true }) await writeFile(join(tmpDir, wsHash, subDir, 'exec-tools-001'), file) - const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') const sessions = await provider.discoverSessions() const calls: ParsedProviderCall[] = [] for (const source of sessions) { @@ -822,6 +888,45 @@ describe('kiro provider - context.messages with entries', () => { expect(call.tools).toContain('aws_sentral_mcp_search_accounts') expect(call.tools).toContain('Bash') expect(call.tools).toContain('Read') + // usageSummary credits (0.5 + 1.0) price the execution at $0.04/credit + expect(call.costUSD).toBeCloseTo(1.5 * 0.04, 8) + expect(call.costIsEstimated).toBe(false) + }) + + it('falls back to token-estimated cost when a modern execution has no usageSummary credits', async () => { + const file = JSON.stringify({ + executionId: 'exec-nocredits-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + chatSessionId: 'session-nocredits-001', + modelId: 'claude-sonnet-4.6', + context: { + messages: [ + { role: 'human', entries: ['hello'] }, + { role: 'bot', entries: ['x'.repeat(4000)] }, + ], + }, + }) + + const wsHash = 'c'.repeat(32) + const subDir = 'e'.repeat(32) + await mkdir(join(tmpDir, wsHash, subDir), { recursive: true }) + await writeFile(join(tmpDir, wsHash, subDir, 'exec-nocredits-001'), file) + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') + const sessions = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sessions) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.costIsEstimated).toBe(true) + expect(call.costUSD).toBeGreaterThan(0) // token-estimated at the real model }) it('skips execution index files with executions array', async () => { @@ -837,7 +942,7 @@ describe('kiro provider - context.messages with entries', () => { await mkdir(join(tmpDir, wsHash), { recursive: true }) await writeFile(join(tmpDir, wsHash, 'f'.repeat(32)), indexFile) - const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') const sessions = await provider.discoverSessions() const calls: ParsedProviderCall[] = [] for (const source of sessions) { @@ -881,7 +986,7 @@ describe('kiro provider - workspace-sessions format', () => { // Also need sessions.json (should be skipped) await writeFile(join(wsSessionsDir, 'sessions.json'), '[]') - const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') const sessions = await provider.discoverSessions() const wsSessions = sessions.filter(s => s.path.includes('workspace-sessions')) @@ -922,7 +1027,7 @@ describe('kiro provider - workspace-sessions format', () => { await writeFile(join(wsSessionsDir, 'ws-session-stub.json'), sessionFile) - const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') const sessions = await provider.discoverSessions() const calls: ParsedProviderCall[] = [] for (const source of sessions) { @@ -940,9 +1045,403 @@ describe('kiro provider - workspace-sessions format', () => { await mkdir(wsSessionsDir, { recursive: true }) await writeFile(join(wsSessionsDir, 'sessions.json'), '[]') - const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') const sessions = await provider.discoverSessions() const wsSessions = sessions.filter(s => s.path.includes('workspace-sessions')) expect(wsSessions).toHaveLength(0) }) }) + +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') +} + +describe('kiro provider - v2 sess_ format', () => { + let sessionsRoot: string + + beforeEach(async () => { + sessionsRoot = await mkdtemp(join(tmpdir(), 'kiro-v2-')) + }) + + afterEach(async () => { + await rm(sessionsRoot, { recursive: true, force: true }) + }) + + it('discovers and parses a v2 session with the real model and project', async () => { + await makeV2Session(sessionsRoot, { + turns: [ + { exec: 'exec-1', ts: '2026-07-14T13:39:40.000Z', user: 'What is TypeScript?', say: 'TypeScript is a typed superset of JavaScript.', tools: ['readFile'] }, + { exec: 'exec-2', ts: '2026-07-14T13:41:00.000Z', user: 'Run the build', say: 'Building now.', tools: ['executeBash'] }, + ], + }) + + // cliDir override sits at /cli, so v2 root = dirname(cliDir) = sessionsRoot. + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const v2 = sources.filter(s => /\/sess_[^/]+\/messages\.jsonl$/.test(s.path)) + expect(v2).toHaveLength(1) + expect(v2[0]!.project).toBe('test-proj') + + const calls: ParsedProviderCall[] = [] + for (const source of v2) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + } + + expect(calls).toHaveLength(2) + const first = calls[0]! + expect(first.provider).toBe('kiro') + expect(first.model).toBe('claude-opus-4-8') + expect(first.sessionId).toBe('sess_test-001') + expect(first.inputTokens).toBeGreaterThan(0) + expect(first.outputTokens).toBeGreaterThan(0) + expect(first.tools).toEqual(['Read']) + expect(first.userMessage).toBe('What is TypeScript?') + expect(first.timestamp).toBe('2026-07-14T13:39:40.000Z') + expect(first.deduplicationKey).toBe('kiro-v2:sess_test-001:exec-1') + // Fixture turns carry usage_summary credits (default 1 credit), so cost is + // metered: 1 credit × $0.04/credit, not token-estimated. + expect(first.costIsEstimated).toBe(false) + expect(first.costUSD).toBeCloseTo(0.04, 4) + expect(calls[1]!.tools).toEqual(['Bash']) + }) + + it('prices turns from metered credits at $0.04/credit, falling back to token estimation without credits', async () => { + await makeV2Session(sessionsRoot, { + turns: [ + { exec: 'exec-c', ts: '2026-07-14T13:39:40.000Z', user: 'hi', say: 'metered answer', credits: 5.25 }, + // credits: 0 -> fixture emits usage_summary with usage 0 -> token fallback + { exec: 'exec-e', ts: '2026-07-14T13:41:00.000Z', user: 'hi again', say: 'x'.repeat(4000), credits: 0 }, + ], + }) + + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + } + + expect(calls).toHaveLength(2) + // Metered: 5.25 credits × $0.04 = $0.21, marked as real cost + expect(calls[0]!.costUSD).toBeCloseTo(0.21, 4) + expect(calls[0]!.costIsEstimated).toBe(false) + // No credits: token-estimated at the session's real model, marked estimated + expect(calls[1]!.costIsEstimated).toBe(true) + expect(calls[1]!.costUSD).toBeGreaterThan(0) + }) + + it('counts tool_result content as input context, scoped to its own turn', async () => { + await makeV2Session(sessionsRoot, { + turns: [ + // Turn 1: 8-char prompt + 400 chars of tool results -> ceil(408/4) = 102 + { exec: 'exec-1', ts: '2026-07-14T13:39:40.000Z', user: 'x'.repeat(8), say: 'done', tools: ['readFile'], toolResults: ['r'.repeat(150), 's'.repeat(250)] }, + // Turn 2: 8-char prompt, no tool results -> ceil(8/4) = 2 (no leak from turn 1) + { exec: 'exec-2', ts: '2026-07-14T13:41:00.000Z', user: 'y'.repeat(8), say: 'ok' }, + ], + }) + + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + } + + expect(calls).toHaveLength(2) + expect(calls[0]!.inputTokens).toBe(102) + expect(calls[1]!.inputTokens).toBe(2) + }) + + it('keeps reasoningTokens disjoint from outputTokens (no double-count in aggregation)', async () => { + await makeV2Session(sessionsRoot, { + turns: [ + { exec: 'exec-r', ts: '2026-07-14T13:39:40.000Z', user: 'hi', say: 'short', reasoning: 'x'.repeat(400) }, + ], + }) + + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.reasoningTokens).toBe(100) // 400 chars / 4 + // output holds only assistant text ('short' = 5 chars -> 2 tokens); + // downstream aggregation sums outputTokens + reasoningTokens, so reasoning + // must NOT be folded into outputTokens here. + expect(call.outputTokens).toBe(2) + }) + + it('deduplicates v2 turns across parser runs', async () => { + const msgs = await makeV2Session(sessionsRoot, { + turns: [{ exec: 'exec-1', ts: '2026-07-14T13:39:40.000Z', user: 'hello', say: 'hi there' }], + }) + const source = { path: msgs, project: 'test-proj', provider: 'kiro' } + const seen = new Set() + + const run1: ParsedProviderCall[] = [] + for await (const c of kiro.createSessionParser(source, seen).parse()) run1.push(c) + const run2: ParsedProviderCall[] = [] + for await (const c of kiro.createSessionParser(source, seen).parse()) run2.push(c) + + expect(run1).toHaveLength(1) + expect(run2).toHaveLength(0) + }) + + it('routes a sess_/messages.jsonl path to the v2 parser (not the CLI parser)', async () => { + const msgs = await makeV2Session(sessionsRoot, { + turns: [{ exec: 'exec-1', ts: '2026-07-14T13:39:40.000Z', user: 'hello', say: 'hi', tools: ['fsWrite'] }], + }) + const source = { path: msgs, project: 'test-proj', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const c of kiro.createSessionParser(source, new Set()).parse()) calls.push(c) + + expect(calls).toHaveLength(1) + // A CLI-parser misroute would yield model 'kiro-auto' with no meta; v2 gives the real model. + expect(calls[0]!.model).toBe('claude-opus-4-8') + expect(calls[0]!.tools).toEqual(['Edit']) + }) + + it('maps a bare-homedir workspace to a generic project label', async () => { + await makeV2Session(sessionsRoot, { + workspacePaths: [homedir()], + turns: [{ exec: 'exec-1', ts: '2026-07-14T13:39:40.000Z', user: 'hello', say: 'hi' }], + }) + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const v2 = sources.filter(s => /\/sess_[^/]+\/messages\.jsonl$/.test(s.path)) + expect(v2).toHaveLength(1) + expect(v2[0]!.project).toBe('kiro-ide') + }) + + it('ignores a sess_ directory with no messages.jsonl', async () => { + const sessDir = join(sessionsRoot, '4748323122002acb', 'sess_empty') + await mkdir(sessDir, { recursive: true }) + await writeFile(join(sessDir, 'session.json'), JSON.stringify({ id: 'sess_empty', modelId: 'auto' })) + + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const v2 = sources.filter(s => /\/sess_[^/]+\/messages\.jsonl$/.test(s.path)) + expect(v2).toHaveLength(0) + }) + + it('does not descend into the cli directory when discovering v2 sessions', async () => { + // A sess_-shaped directory inside the CLI store must not be picked up as v2. + const trap = join(sessionsRoot, 'cli', 'sess_trap') + await mkdir(trap, { recursive: true }) + await writeFile(join(trap, 'messages.jsonl'), makeV2Messages([ + { exec: 'exec-trap', ts: '2026-07-14T13:39:40.000Z', user: 'trap', say: 'should not appear' }, + ])) + + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const v2 = sources.filter(s => /\/sess_[^/]+\/messages\.jsonl$/.test(s.path)) + expect(v2).toHaveLength(0) + }) +}) + +describe('kiro provider - mixed-format coexistence (legacy + v1 + workspace-sessions + CLI + v2)', () => { + // Simulates a machine that upgraded through Kiro versions: every historical + // storage format has files on disk simultaneously. Verifies each source routes + // to the right parser, the aggregate call count is exact (no double counting), + // and dedup keys stay in their own namespaces. + let root: string + let agentDir: string + let sessionsRoot: string + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'kiro-mixed-')) + agentDir = join(root, 'agent') + sessionsRoot = join(root, 'sessions') + await mkdir(agentDir, { recursive: true }) + await mkdir(join(sessionsRoot, 'cli'), { recursive: true }) + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + async function parseAll(): Promise { + const provider = createKiroProvider(agentDir, join(root, 'ws-storage'), join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const seenKeys = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call) + } + return calls + } + + async function buildFullTree() { + const wsHash = 'a1b2c3d4'.repeat(4) + + // 1. Legacy .chat file + 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'], + })) + + // 2. v1 modern execution file (nested session dir) + const sessDir = join(wsDir, 'session-v1') + await mkdir(sessDir, { recursive: true }) + await writeFile(join(sessDir, 'execution-v1'), makeModernExecutionFile({ + executionId: 'exec-v1', sessionId: 'session-v1', + userPrompt: 'v1 question', assistantResponse: 'v1 answer', + })) + + // 3a. Workspace-session with real assistant content (counted) + 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' } }, + ], + })) + + // 3b. Workspace-session stub referencing the v1 execution (must be skipped) + await writeFile(join(wssDir, 'ws-stub.json'), JSON.stringify({ + sessionId: 'ws-stub', selectedModel: 'auto', + history: [ + { message: { role: 'user', content: [{ type: 'text', text: 'v1 question' }] } }, + { message: { role: 'assistant', content: 'On it.' }, executionId: 'exec-v1' }, + ], + })) + + // 4. CLI session + 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' }] }] }, + }, + })) + + // 5. v2 session (2 turns) + 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'] }, + { exec: 'exec-v2-2', ts: '2026-07-14T13:41:00.000Z', user: 'v2 second', say: 'v2 second answer' }, + ], + }) + } + + it('routes every format to the right parser and counts each conversation exactly once', async () => { + await buildFullTree() + const calls = await parseAll() + + // Exact aggregate: 1 legacy + 1 v1 exec + 1 ws-session (stub skipped) + 1 CLI + 2 v2 turns + expect(calls).toHaveLength(6) + + const byPrefix = (p: string) => calls.filter(c => c.deduplicationKey!.startsWith(p)) + expect(byPrefix('kiro-v2:')).toHaveLength(2) + expect(byPrefix('kiro-cli:')).toHaveLength(1) + expect(byPrefix('kiro:ws-session:')).toHaveLength(1) + // Legacy + v1 executions share the plain kiro: namespace + expect(byPrefix('kiro:').filter(c => !c.deduplicationKey!.startsWith('kiro:ws-session:'))).toHaveLength(2) + + // The ws-session stub for exec-v1 must not have been emitted + expect(calls.some(c => c.deduplicationKey === 'kiro:ws-session:ws-stub')).toBe(false) + // Each conversation's content is attributed once + expect(calls.filter(c => c.userMessage === 'v1 question')).toHaveLength(1) + + // Dedup keys are globally unique + const keys = calls.map(c => c.deduplicationKey) + expect(new Set(keys).size).toBe(keys.length) + }) + + it('emits nothing on a second pass with shared seenKeys (cross-run dedup across all formats)', async () => { + await buildFullTree() + 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(6) + expect(run2).toHaveLength(0) + }) + + it('discovery paths are unique across all stores', async () => { + await buildFullTree() + const provider = createKiroProvider(agentDir, join(root, 'ws-storage'), join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const paths = sources.map(s => s.path) + expect(new Set(paths).size).toBe(paths.length) + }) +})