Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 6e24f82

Browse files
committed
Fix Kiro post-February storage discovery (getagentseal#339)
Adds extensionless session index and nested execution file discovery while preserving legacy .chat support. Modern execution JSON is parsed for identifiers, timestamps, model IDs, conversation text, structured tools, and token estimates. Also fixes dedup key poisoning on invalid timestamps in the legacy parser.
1 parent 9eaf8c4 commit 6e24f82

4 files changed

Lines changed: 537 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@
7373
because the cache shortcut only merged cost/calls. Per-provider periods now
7474
always do a full parse. Also floors `maxCost` at 0.01 to avoid NaN bar
7575
widths in ActivitySection and ModelsSection. (#362)
76+
- **Kiro post-February 2026 storage discovery.** The Kiro provider now keeps
77+
legacy `.chat` support while also discovering extensionless session index
78+
files and nested execution files. Modern execution JSON is parsed for
79+
identifiers, timestamps, model IDs, conversation text, structured tools, and
80+
estimated token usage. Thanks @ozymandiashh. Closes #329. (#339)
7681

7782
### Fixed (macOS menubar)
7883
- **Per-provider refresh latency.** Switching provider tabs took ~24s on heavy

docs/providers/kiro.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Kiro IDE chat history.
44

55
- **Source:** `src/providers/kiro.ts`
66
- **Loading:** eager (`src/providers/index.ts:7`)
7-
- **Test:** `tests/providers/kiro.test.ts` (328 lines)
7+
- **Test:** `tests/providers/kiro.test.ts`
88

99
## Where it reads from
1010

@@ -16,29 +16,38 @@ VS Code-style globalStorage at `kiro.kiroagent`:
1616
| Windows | `%APPDATA%/Kiro/User/globalStorage/kiro.kiroagent` |
1717
| Linux | `~/.config/Kiro/User/globalStorage/kiro.kiroagent` |
1818

19-
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`.
19+
Sessions are under hash-named workspace subdirectories. Discovery keeps backward compatibility with legacy `.chat` files and also scans the post-February 2026 extensionless format:
20+
21+
- `<workspace-hash>/<execution-id>.chat` legacy session files
22+
- `<workspace-hash>/<session-hash>` extensionless session index files
23+
- `<workspace-hash>/<session-hash>/<execution-hash>` extensionless execution files inside session directories
2024

2125
## Storage format
2226

23-
JSON `.chat` files (`kiro.ts:153`).
27+
Kiro has two known JSON formats:
28+
29+
- Legacy `.chat` files with `{ chat, metadata, executionId }`
30+
- 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
31+
32+
Session index files with `{ executions: [...] }` are discovered but skipped during parsing because they do not contain conversation content.
2433

2534
## Caching
2635

2736
None.
2837

2938
## Deduplication
3039

31-
Per `executionId` (`kiro.ts:104`).
40+
Modern files deduplicate per session/execution pair. Legacy `.chat` files deduplicate per workflow/execution pair.
3241

3342
## Quirks
3443

35-
- **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`).
36-
- **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.
37-
- **Tool name extraction is regex-driven.** Kiro embeds tool calls inside the message text as `<tool_use><name>...</name>` (`kiro.ts:69-78`). Brittle but unavoidable until Kiro emits structured tool data.
38-
- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`, `kiro.ts:9`, `:108-109`).
44+
- **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.
45+
- **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.
46+
- **Tool name extraction accepts text and structured calls.** Kiro can embed tool calls inside message text as `<tool_use><name>...</name>` or expose structured `toolCalls` / `tool_calls` / `tools` entries.
47+
- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`).
3948

4049
## When fixing a bug here
4150

4251
1. If the bug is "wrong workspace", check the base64 fallback path. Some users name their workspaces with characters that are not valid base64.
43-
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`.
44-
3. If the bug is "tools missing", look at the regex at `kiro.ts:69-78`. Kiro changes its envelope occasionally.
52+
2. If the bug is "missing model in pricing", add the model to the normalization map and verify against `tests/providers/kiro.test.ts`.
53+
3. If the bug is "tools missing", check both text-envelope extraction and structured tool-call extraction. Kiro changes its envelope occasionally.

src/providers/kiro.ts

Lines changed: 234 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { readdir, readFile, stat } from 'fs/promises'
2-
import { basename, join } from 'path'
1+
import type { Dirent } from 'fs'
2+
import { readdir, readFile } from 'fs/promises'
3+
import { basename, dirname, extname, join } from 'path'
34
import { homedir } from 'os'
45

56
import { readSessionFile } from '../fs-utils.js'
@@ -8,6 +9,8 @@ import type { ToolCall } from '../types.js'
89
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
910

1011
const CHARS_PER_TOKEN = 4
12+
const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000
13+
const MODERN_CONVERSATION_KEYS = ['messages', 'conversation', 'chat', 'transcript', 'entries', 'events']
1114

1215
const modelDisplayNames: Record<string, string> = {
1316
'claude-sonnet-4-6': 'Sonnet 4.6',
@@ -63,6 +66,8 @@ type KiroChatFile = {
6366
}
6467
}
6568

69+
type KiroModernExecution = Record<string, unknown>
70+
6671
function normalizeModelId(raw: string): string {
6772
return raw.replace(/(\d+)\.(\d+)/g, '$1-$2')
6873
}
@@ -78,6 +83,98 @@ function extractToolNames(content: string): string[] {
7883
return tools
7984
}
8085

86+
function asRecord(value: unknown): Record<string, unknown> | null {
87+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
88+
}
89+
90+
function stringField(record: Record<string, unknown> | null, names: string[]): string {
91+
if (!record) return ''
92+
for (const name of names) {
93+
const value = record[name]
94+
if (typeof value === 'string' && value.trim()) return value.trim()
95+
}
96+
return ''
97+
}
98+
99+
function timeField(record: Record<string, unknown> | null, names: string[]): number | string | undefined {
100+
if (!record) return undefined
101+
for (const name of names) {
102+
const value = record[name]
103+
if (typeof value === 'number' || typeof value === 'string') return value
104+
}
105+
return undefined
106+
}
107+
108+
function parseKiroTimestamp(value: number | string | undefined): Date | null {
109+
if (value === undefined) return null
110+
111+
let parsed: number | string = value
112+
if (typeof value === 'string') {
113+
const trimmed = value.trim()
114+
if (!trimmed) return null
115+
parsed = /^-?\d+(\.\d+)?$/.test(trimmed) ? Number(trimmed) : trimmed
116+
}
117+
118+
if (typeof parsed === 'number') {
119+
if (!Number.isFinite(parsed)) return null
120+
const ms = parsed < MIN_REASONABLE_TIMESTAMP_MS ? parsed * 1000 : parsed
121+
const date = new Date(ms)
122+
return Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS ? null : date
123+
}
124+
125+
const date = new Date(parsed)
126+
return Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS ? null : date
127+
}
128+
129+
function textField(record: Record<string, unknown> | null, names: string[]): string {
130+
if (!record) return ''
131+
for (const name of names) {
132+
const text = extractText(record[name])
133+
if (text) return text
134+
}
135+
return ''
136+
}
137+
138+
function extractText(value: unknown): string {
139+
if (typeof value === 'string') return value
140+
if (Array.isArray(value)) return value.map(extractText).filter(Boolean).join('\n')
141+
const record = asRecord(value)
142+
if (!record) return ''
143+
for (const key of ['content', 'text', 'message', 'value', 'parts']) {
144+
const text = extractText(record[key])
145+
if (text) return text
146+
}
147+
return ''
148+
}
149+
150+
function messageRole(value: unknown): string {
151+
const record = asRecord(value)
152+
if (!record) return ''
153+
return stringField(record, ['role', 'type', 'author']).toLowerCase()
154+
}
155+
156+
function extractStructuredToolNames(value: unknown, text: string, options: { includeDirectName?: boolean } = {}): string[] {
157+
const tools = extractToolNames(text)
158+
const record = asRecord(value)
159+
if (!record) return tools
160+
161+
if (options.includeDirectName ?? true) {
162+
const directName = stringField(record, ['toolName', 'name'])
163+
if (directName) tools.push(toolNameMap[directName] ?? directName)
164+
}
165+
166+
for (const key of ['toolCalls', 'tool_calls', 'tools']) {
167+
const entries = record[key]
168+
if (!Array.isArray(entries)) continue
169+
for (const entry of entries) {
170+
const name = stringField(asRecord(entry), ['name', 'toolName', 'tool_name'])
171+
if (name) tools.push(toolNameMap[name] ?? name)
172+
}
173+
}
174+
175+
return tools
176+
}
177+
81178
function parseChatFile(data: KiroChatFile, sessionId: string, project: string, seenKeys: Set<string>): ParsedProviderCall[] {
82179
const results: ParsedProviderCall[] = []
83180
const { chat, metadata } = data
@@ -107,14 +204,14 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s
107204

108205
const dedupKey = `kiro:${sessionId}:${data.executionId}`
109206
if (seenKeys.has(dedupKey)) return results
110-
seenKeys.add(dedupKey)
111207

112208
const outputTokens = Math.ceil(totalOutputChars / CHARS_PER_TOKEN)
113209
const inputTokens = Math.ceil(pendingUserMessage.length / CHARS_PER_TOKEN)
114210
const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0)
115-
const tsDate = metadata.startTime ? new Date(metadata.startTime) : null
116-
if (!tsDate || isNaN(tsDate.getTime()) || tsDate.getTime() < 1_000_000_000_000) return results
211+
const tsDate = parseKiroTimestamp(metadata.startTime)
212+
if (!tsDate) return results
117213
const timestamp = tsDate.toISOString()
214+
seenKeys.add(dedupKey)
118215

119216
results.push({
120217
provider: 'kiro',
@@ -140,23 +237,132 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s
140237
return results
141238
}
142239

240+
function parseModernExecution(data: KiroModernExecution, sourcePath: string, seenKeys: Set<string>): ParsedProviderCall[] {
241+
const results: ParsedProviderCall[] = []
242+
if (Array.isArray(data['executions'])) return results
243+
244+
const metadata = asRecord(data['metadata'])
245+
const modelObj = asRecord(data['model'])
246+
let modelId = normalizeModelId(
247+
stringField(data, ['modelId', 'modelID', 'modelName', 'model']) ||
248+
stringField(modelObj, ['id', 'name']) ||
249+
stringField(metadata, ['modelId', 'modelID', 'modelName']),
250+
)
251+
if (modelId === 'auto' || !modelId) modelId = 'kiro-auto'
252+
253+
const executionId = stringField(data, ['executionId', 'id']) || basename(sourcePath)
254+
const sessionId = stringField(data, ['sessionId', 'conversationId', 'workflowId']) ||
255+
stringField(metadata, ['workflowId', 'sessionId']) ||
256+
basename(dirname(sourcePath)) ||
257+
executionId
258+
259+
let inputChars = 0
260+
let outputChars = 0
261+
let pendingUserMessage = ''
262+
const allTools: string[] = []
263+
let hasOutputActivity = false
264+
const directInput = textField(data, ['prompt', 'input', 'userMessage', 'user_message', 'request'])
265+
const directOutput = textField(data, ['response', 'output', 'assistantMessage', 'assistant_message', 'result'])
266+
const directTools = extractStructuredToolNames(data, directOutput, { includeDirectName: false })
267+
268+
if (directInput) {
269+
inputChars += directInput.length
270+
pendingUserMessage = directInput.slice(0, 500)
271+
}
272+
273+
if (directOutput) {
274+
outputChars += directOutput.length
275+
hasOutputActivity = true
276+
}
277+
278+
if (directTools.length > 0) {
279+
hasOutputActivity = true
280+
allTools.push(...directTools)
281+
}
282+
283+
for (const key of MODERN_CONVERSATION_KEYS) {
284+
const messages = data[key]
285+
if (!Array.isArray(messages)) continue
286+
287+
for (const message of messages) {
288+
const text = extractText(message)
289+
const role = messageRole(message)
290+
const tools = extractStructuredToolNames(message, text)
291+
292+
if (role === 'human' || role === 'user') {
293+
if (!text) continue
294+
inputChars += text.length
295+
pendingUserMessage = text.slice(0, 500)
296+
} else if (role === 'bot' || role === 'assistant' || role === 'ai' || role === 'model') {
297+
if (text) outputChars += text.length
298+
if (text || tools.length > 0) hasOutputActivity = true
299+
allTools.push(...tools)
300+
} else if (role === 'tool' || role === 'system') {
301+
if (text) inputChars += text.length
302+
allTools.push(...tools)
303+
}
304+
}
305+
break
306+
}
307+
308+
if (!hasOutputActivity) return results
309+
310+
const dedupKey = `kiro:${sessionId}:${executionId}`
311+
if (seenKeys.has(dedupKey)) return results
312+
313+
const rawStartTime = timeField(data, ['startTime', 'createdAt', 'timestamp']) ??
314+
timeField(metadata, ['startTime', 'createdAt', 'timestamp'])
315+
const tsDate = parseKiroTimestamp(rawStartTime)
316+
if (!tsDate) return results
317+
318+
const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN)
319+
const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN)
320+
const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0)
321+
seenKeys.add(dedupKey)
322+
323+
results.push({
324+
provider: 'kiro',
325+
model: modelId,
326+
inputTokens,
327+
outputTokens,
328+
cacheCreationInputTokens: 0,
329+
cacheReadInputTokens: 0,
330+
cachedInputTokens: 0,
331+
reasoningTokens: 0,
332+
webSearchRequests: 0,
333+
costUSD,
334+
tools: [...new Set(allTools)],
335+
bashCommands: [],
336+
timestamp: tsDate.toISOString(),
337+
speed: 'standard',
338+
deduplicationKey: dedupKey,
339+
userMessage: pendingUserMessage,
340+
sessionId,
341+
})
342+
343+
return results
344+
}
345+
143346
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
144347
return {
145348
async *parse(): AsyncGenerator<ParsedProviderCall> {
146349
const content = await readSessionFile(source.path)
147350
if (content === null) return
148351

149-
let data: KiroChatFile
352+
let data: unknown
150353
try {
151354
data = JSON.parse(content)
152355
} catch {
153356
return
154357
}
155358

156-
if (!data.chat || !data.metadata) return
359+
const record = asRecord(data)
360+
if (!record) return
157361

158-
const sessionId = data.metadata.workflowId ?? basename(source.path, '.chat')
159-
const calls = parseChatFile(data, sessionId, source.project, seenKeys)
362+
const metadata = asRecord(record['metadata'])
363+
const calls = Array.isArray(record['chat']) && metadata
364+
? parseChatFile(record as unknown as KiroChatFile, stringField(metadata, ['workflowId']) || basename(source.path, '.chat'), source.project, seenKeys)
365+
: parseModernExecution(record, source.path, seenKeys)
160366
for (const call of calls) {
161367
yield call
162368
}
@@ -232,19 +438,30 @@ async function discoverSessions(agentDir: string, workspaceStorageDir: string):
232438
const wsPath = join(agentDir, wsHash)
233439
const project = await resolveWorkspaceProject(agentDir, workspaceStorageDir, wsHash)
234440

235-
let files: string[]
441+
let entries: Dirent[]
236442
try {
237-
const entries = await readdir(wsPath)
238-
files = entries.filter(f => f.endsWith('.chat'))
443+
entries = await readdir(wsPath, { withFileTypes: true })
239444
} catch {
240445
continue
241446
}
242447

243-
for (const file of files) {
244-
const filePath = join(wsPath, file)
245-
const s = await stat(filePath).catch(() => null)
246-
if (!s?.isFile()) continue
247-
sources.push({ path: filePath, project, provider: 'kiro' })
448+
for (const entry of entries) {
449+
if (entry.name.startsWith('.')) continue
450+
const entryPath = join(wsPath, entry.name)
451+
if (entry.isFile() && (entry.name.endsWith('.chat') || extname(entry.name) === '')) {
452+
sources.push({ path: entryPath, project, provider: 'kiro' })
453+
continue
454+
}
455+
456+
if (!entry.isDirectory()) continue
457+
458+
const childEntries = await readdir(entryPath, { withFileTypes: true }).catch(() => [])
459+
for (const child of childEntries) {
460+
if (child.name.startsWith('.')) continue
461+
if (!child.isFile()) continue
462+
if (extname(child.name) !== '') continue
463+
sources.push({ path: join(entryPath, child.name), project, provider: 'kiro' })
464+
}
248465
}
249466
}
250467

0 commit comments

Comments
 (0)