From 9bb295fc7ee3cb4951b9134ea57dabd5c0be616d Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sun, 17 May 2026 16:27:54 +0300 Subject: [PATCH] Support Kiro extensionless executions --- CHANGELOG.md | 7 ++ docs/providers/kiro.md | 27 +++-- src/providers/kiro.ts | 221 ++++++++++++++++++++++++++++++++--- tests/providers/kiro.test.ts | 184 ++++++++++++++++++++++++++++- 4 files changed, 415 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b745428..40cb3c41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,13 @@ `Shell`, `ReadFile`, and `WriteFile`, and maps hidden managed Kimi Code model aliases to priced Kimi K2 entries. +### Fixed (CLI) +- **Kiro post-February 2026 storage discovery.** The Kiro provider now keeps + legacy `.chat` support while also discovering extensionless session index + files and nested execution files. Modern execution JSON is parsed for + identifiers, timestamps, model IDs, conversation text, structured tools, and + estimated token usage. Closes #329. + ## 0.9.9 - 2026-05-15 ### Added (CLI) diff --git a/docs/providers/kiro.md b/docs/providers/kiro.md index 0c450fb6..0cc2fd95 100644 --- a/docs/providers/kiro.md +++ b/docs/providers/kiro.md @@ -16,11 +16,20 @@ VS Code-style globalStorage at `kiro.kiroagent`: | Windows | `%APPDATA%/Kiro/User/globalStorage/kiro.kiroagent` | | Linux | `~/.config/Kiro/User/globalStorage/kiro.kiroagent` | -Sessions are `.chat` files under hash-named subdirectories. Discovery is in `kiro.ts:215-247`; the path-resolution helpers it uses start at `kiro.ts:164`. +Sessions 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 ## Storage format -JSON `.chat` files (`kiro.ts:153`). +Kiro has two known JSON formats: + +- 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`, or direct prompt/response fields + +Session index files with `{ executions: [...] }` are discovered but skipped during parsing because they do not contain conversation content. ## Caching @@ -28,17 +37,17 @@ None. ## Deduplication -Per `executionId` (`kiro.ts:104`). +Per session/execution pair. ## Quirks -- **Workspace hash resolution** is non-trivial. The parser tries `workspace.json` first; if that fails, it base64-decodes the directory name to recover the workspace path (`kiro.ts:198-213`). -- **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 (`kiro.ts:65-67`). Add new versions here when Kiro ships them. -- **Tool name extraction is regex-driven.** Kiro embeds tool calls inside the message text as `...` (`kiro.ts:69-78`). Brittle but unavoidable until Kiro emits structured tool data. -- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`, `kiro.ts:9`, `:108-109`). +- **Workspace hash resolution** is non-trivial. The parser tries `workspace.json` first; if that fails, it base64-decodes the directory name to recover the workspace path. +- **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`). ## When fixing a bug here 1. If the bug is "wrong workspace", check the base64 fallback path. Some users name their workspaces with characters that are not valid base64. -2. If the bug is "missing model in pricing", add the model to the normalization map at `kiro.ts:65-67` and verify against `tests/providers/kiro.test.ts`. -3. If the bug is "tools missing", look at the regex at `kiro.ts:69-78`. Kiro changes its envelope occasionally. +2. If the bug is "missing model in pricing", add the model to the normalization map and verify against `tests/providers/kiro.test.ts`. +3. If the bug is "tools missing", check both text-envelope extraction and structured tool-call extraction. Kiro changes its envelope occasionally. diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts index 118bd06b..81e5aabb 100644 --- a/src/providers/kiro.ts +++ b/src/providers/kiro.ts @@ -1,5 +1,6 @@ -import { readdir, readFile, stat } from 'fs/promises' -import { basename, join } from 'path' +import type { Dirent } from 'fs' +import { readdir, readFile } from 'fs/promises' +import { basename, dirname, extname, join } from 'path' import { homedir } from 'os' import { readSessionFile } from '../fs-utils.js' @@ -62,6 +63,8 @@ type KiroChatFile = { } } +type KiroModernExecution = Record + function normalizeModelId(raw: string): string { return raw.replace(/(\d+)\.(\d+)/g, '$1-$2') } @@ -77,6 +80,77 @@ function extractToolNames(content: string): string[] { 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 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']) { + 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 @@ -135,23 +209,131 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s 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', '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) + } + + for (const key of ['messages', 'conversation', 'chat', 'transcript', 'entries', 'events']) { + const messages = data[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) + } + } + } + + if (!hasOutputActivity) return results + + const dedupKey = `kiro:${sessionId}:${executionId}` + if (seenKeys.has(dedupKey)) return results + seenKeys.add(dedupKey) + + const rawStartTime = timeField(data, ['startTime', 'createdAt', 'timestamp']) ?? + timeField(metadata, ['startTime', 'createdAt', 'timestamp']) + const tsDate = rawStartTime ? new Date(rawStartTime) : null + if (!tsDate || isNaN(tsDate.getTime()) || tsDate.getTime() < 1_000_000_000_000) return results + + 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, + tools: [...new Set(allTools)], + bashCommands: [], + timestamp: tsDate.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + }) + + return results +} + function createParser(source: SessionSource, seenKeys: Set): SessionParser { return { async *parse(): AsyncGenerator { const content = await readSessionFile(source.path) if (content === null) return - let data: KiroChatFile + let data: unknown try { data = JSON.parse(content) } catch { return } - if (!data.chat || !data.metadata) return + const record = asRecord(data) + if (!record) return - const sessionId = data.metadata.workflowId ?? basename(source.path, '.chat') - const calls = parseChatFile(data, sessionId, source.project, seenKeys) + 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 } @@ -227,19 +409,30 @@ async function discoverSessions(agentDir: string, workspaceStorageDir: string): const wsPath = join(agentDir, wsHash) const project = await resolveWorkspaceProject(agentDir, workspaceStorageDir, wsHash) - let files: string[] + let entries: Dirent[] try { - const entries = await readdir(wsPath) - files = entries.filter(f => f.endsWith('.chat')) + entries = await readdir(wsPath, { withFileTypes: true }) } catch { continue } - for (const file of files) { - const filePath = join(wsPath, file) - const s = await stat(filePath).catch(() => null) - if (!s?.isFile()) continue - sources.push({ path: filePath, project, provider: 'kiro' }) + for (const entry of entries) { + if (entry.name.startsWith('.')) continue + const entryPath = join(wsPath, entry.name) + if (entry.isFile() && (entry.name.endsWith('.chat') || extname(entry.name) === '')) { + sources.push({ path: entryPath, project, provider: 'kiro' }) + continue + } + + if (!entry.isDirectory()) continue + + const childEntries = await readdir(entryPath, { withFileTypes: true }).catch(() => []) + for (const child of childEntries) { + if (child.name.startsWith('.')) continue + if (!child.isFile()) continue + if (extname(child.name) !== '') continue + sources.push({ path: join(entryPath, child.name), project, provider: 'kiro' }) + } } } diff --git a/tests/providers/kiro.test.ts b/tests/providers/kiro.test.ts index a157d4e9..9cda0597 100644 --- a/tests/providers/kiro.test.ts +++ b/tests/providers/kiro.test.ts @@ -49,6 +49,33 @@ function makeChatFile(opts: { }) } +function makeModernExecutionFile(opts: { + executionId?: string + sessionId?: string + modelId?: string + startTime?: number + userPrompt?: string + assistantResponse?: string +}) { + return JSON.stringify({ + executionId: opts.executionId ?? 'exec-modern-001', + sessionId: opts.sessionId ?? 'session-modern-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime: opts.startTime ?? 1777333000000, + endTime: (opts.startTime ?? 1777333000000) + 10000, + 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' }], + }, + ], + }) +} + describe('kiro provider - chat file parsing', () => { beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'kiro-test-')) @@ -227,6 +254,137 @@ describe('kiro provider - chat file parsing', () => { expect(calls).toHaveLength(1) expect(calls[0]!.sessionId).toBe('my-workflow-id') }) + + it('parses a post-February extensionless execution file', async () => { + const wsHash = 'i'.repeat(32) + const sessionHash = 'session-modern' + const wsDir = join(tmpDir, wsHash, sessionHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-modern') + await writeFile(executionPath, makeModernExecutionFile({ + executionId: 'exec-modern', + sessionId: 'session-modern', + modelId: 'claude-sonnet-4.5', + userPrompt: 'summarize this workspace', + assistantResponse: 'I reviewed it. runCommand', + })) + + const source = { path: executionPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('kiro') + expect(call.model).toBe('claude-sonnet-4-5') + expect(call.sessionId).toBe('session-modern') + expect(call.userMessage).toBe('summarize this workspace') + expect(call.inputTokens).toBeGreaterThan(0) + expect(call.outputTokens).toBeGreaterThan(0) + expect(call.tools).toEqual(['Bash', 'Read']) + expect(call.costUSD).toBeGreaterThan(0) + }) + + it('skips session index files without conversation content', async () => { + const wsHash = 'j'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const indexPath = join(wsDir, 'session-index') + await writeFile(indexPath, JSON.stringify({ + executions: [{ + executionId: 'exec-indexed', + type: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + endTime: 1777333010000, + }], + })) + + const source = { path: indexPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(0) + }) + + it('parses direct prompt and response fields from modern execution files', async () => { + const wsHash = 'k'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-direct') + await writeFile(executionPath, JSON.stringify({ + executionId: 'exec-direct', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + model: { id: 'auto' }, + prompt: 'make a small change', + response: 'Changed it. writeFile', + })) + + const source = { path: executionPath, project: 'test', 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]!.model).toBe('kiro-auto') + expect(calls[0]!.userMessage).toBe('make a small change') + expect(calls[0]!.tools).toEqual(['Edit']) + }) + + it('keeps modern executions with structured assistant tool calls and no assistant text', async () => { + const wsHash = 'l'.repeat(32) + const wsDir = join(tmpDir, wsHash, 'session-tools') + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-tools') + await writeFile(executionPath, JSON.stringify({ + executionId: 'exec-tools', + sessionId: 'session-tools', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + modelId: 'claude-sonnet-4.5', + messages: [ + { role: 'user', content: 'run the test suite' }, + { role: 'assistant', toolCalls: [{ name: 'runCommand' }] }, + ], + })) + + const source = { path: executionPath, project: 'test', 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]!.tools).toEqual(['Bash']) + expect(calls[0]!.inputTokens).toBeGreaterThan(0) + expect(calls[0]!.outputTokens).toBe(0) + }) + + it('keeps direct modern executions with root tool calls and no response text', async () => { + const wsHash = 'm'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-root-tools') + await writeFile(executionPath, JSON.stringify({ + executionId: 'exec-root-tools', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + model: { id: 'auto' }, + name: 'workflow-name', + prompt: 'edit a file', + toolCalls: [{ name: 'writeFile' }], + })) + + const source = { path: executionPath, project: 'test', 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]!.tools).toEqual(['Edit']) + expect(calls[0]!.tools).not.toContain('workflow-name') + expect(calls[0]!.outputTokens).toBe(0) + }) }) describe('kiro provider - discoverSessions', () => { @@ -253,6 +411,30 @@ describe('kiro provider - discoverSessions', () => { expect(sessions.every(s => s.path.endsWith('.chat'))).toBe(true) }) + it('discovers extensionless session index files and nested execution files', async () => { + const wsHash = 'd'.repeat(32) + const wsDir = join(tmpDir, wsHash) + const sessionDir = join(wsDir, 'session-dir') + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(wsDir, 'session-index'), JSON.stringify({ executions: [] })) + await writeFile(join(wsDir, 'legacy.chat'), makeChatFile({})) + await writeFile(join(wsDir, 'ignored.json'), '{}') + await writeFile(join(wsDir, '.DS_Store'), 'ignored') + await writeFile(join(sessionDir, 'execution-1'), makeModernExecutionFile({})) + await writeFile(join(sessionDir, '.hidden'), 'ignored') + await writeFile(join(sessionDir, 'ignored.txt'), 'hello') + + const provider = createKiroProvider(tmpDir, '/nonexistent/ws') + const sessions = await provider.discoverSessions() + const paths = sessions.map(s => s.path).sort() + + expect(paths).toEqual([ + join(sessionDir, 'execution-1'), + join(wsDir, 'legacy.chat'), + join(wsDir, 'session-index'), + ].sort()) + }) + it('reads project name from workspace.json', async () => { const wsHash = 'b'.repeat(32) const agentWsDir = join(tmpDir, wsHash) @@ -287,7 +469,7 @@ describe('kiro provider - discoverSessions', () => { expect(sessions).toHaveLength(0) }) - it('skips files without .chat extension', async () => { + it('skips files with unsupported extensions', async () => { const wsHash = 'c'.repeat(32) const wsDir = join(tmpDir, wsHash) await mkdir(wsDir, { recursive: true })