Merge pull request #502 from getagentseal/kiro-cli-sessions-rebased

feat(kiro): add Kiro CLI session discovery and parsing (#476)
This commit is contained in:
Resham Joshi 2026-06-18 12:44:29 +02:00 committed by GitHub
commit 59efa7c824
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 325 additions and 3 deletions

View file

@ -27,8 +27,10 @@ const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0
const toolNameMap: Record<string, string> = {
readFile: 'Read',
read_file: 'Read',
read: 'Read',
writeFile: 'Edit',
write_file: 'Edit',
write: 'Edit',
editFile: 'Edit',
edit_file: 'Edit',
createFile: 'Write',
@ -39,10 +41,13 @@ const toolNameMap: Record<string, string> = {
openFolders: 'LS',
runCommand: 'Bash',
run_command: 'Bash',
shell: 'Bash',
searchFiles: 'Grep',
search_files: 'Grep',
grep: 'Grep',
findFiles: 'Glob',
find_files: 'Glob',
glob: 'Glob',
webSearch: 'WebSearch',
web_search: 'WebSearch',
}
@ -343,9 +348,187 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see
return results
}
// --- Kiro CLI session types & parser ---
type KiroCliEntry = {
version: string
kind: 'Prompt' | 'AssistantMessage' | 'ToolResults' | 'Clear'
data: Record<string, unknown>
}
type KiroCliSessionMeta = {
session_id: string
cwd: string
created_at: string
updated_at: string
title?: string
session_state?: {
rts_model_state?: { model_info?: { model_id?: string } }
conversation_metadata?: {
user_turn_metadatas?: Array<{
end_timestamp?: string
builtin_tool_uses?: number
metering_usage?: Array<{ value: number; unit: string }>
total_request_count?: number
}>
}
}
}
function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seenKeys: Set<string>): ParsedProviderCall[] {
const results: ParsedProviderCall[] = []
const sessionId = meta.session_id
const project = basename(meta.cwd || '')
let modelId = meta.session_state?.rts_model_state?.model_info?.model_id ?? 'auto'
if (modelId === 'auto' || !modelId) modelId = 'kiro-auto'
else modelId = normalizeModelId(modelId)
const turns = meta.session_state?.conversation_metadata?.user_turn_metadatas ?? []
// Walk through JSONL entries grouping by prompt turns
let turnIndex = 0
let pendingUserMessage = ''
let outputChars = 0
let inputChars = 0
const allTools: string[] = []
let turnStartTimestamp: string | undefined
function flushTurn() {
if (outputChars === 0) return
const turnMeta = turns[turnIndex]
const dedupKey = `kiro-cli:${sessionId}:${turnIndex}`
if (seenKeys.has(dedupKey)) { turnIndex++; return }
const timestamp = turnMeta?.end_timestamp ?? turnStartTimestamp ?? meta.created_at
const tsDate = parseKiroTimestamp(timestamp)
if (!tsDate) { turnIndex++; return }
const 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)
seenKeys.add(dedupKey)
results.push({
provider: 'kiro',
model: modelId,
inputTokens,
outputTokens,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD,
costIsEstimated: !turnMeta?.metering_usage,
tools: [...new Set(allTools)],
bashCommands: [],
timestamp: tsDate.toISOString(),
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: pendingUserMessage,
sessionId,
project,
})
turnIndex++
}
let isFirstPrompt = true
for (const entry of entries) {
if (entry.kind === 'Prompt') {
if (!isFirstPrompt) {
flushTurn()
pendingUserMessage = ''
outputChars = 0
inputChars = 0
allTools.length = 0
}
isFirstPrompt = false
const content = entry.data['content']
if (Array.isArray(content)) {
for (const item of content) {
const rec = asRecord(item)
if (rec && rec['kind'] === 'text' && typeof rec['data'] === 'string') {
pendingUserMessage = (rec['data'] as string).slice(0, 500)
inputChars += (rec['data'] as string).length
}
}
}
const meta2 = asRecord(entry.data['meta'])
if (meta2) {
const ts = meta2['timestamp']
if (typeof ts === 'number') turnStartTimestamp = new Date(ts * 1000).toISOString()
}
} else if (entry.kind === 'AssistantMessage') {
const content = entry.data['content']
if (Array.isArray(content)) {
for (const item of content) {
const rec = asRecord(item)
if (!rec) continue
if (rec['kind'] === 'text' && typeof rec['data'] === 'string') {
outputChars += (rec['data'] as string).length
} else if (rec['kind'] === 'toolUse') {
const toolData = asRecord(rec['data'])
if (toolData) {
const name = typeof toolData['name'] === 'string' ? toolData['name'] : ''
if (name) allTools.push(toolNameMap[name] ?? name)
}
}
}
}
} else if (entry.kind === 'ToolResults') {
// Tool results count as input context
const content = entry.data['content']
if (Array.isArray(content)) {
for (const item of content) {
const text = extractText(item)
if (text) inputChars += text.length
}
}
}
}
// Flush last turn
flushTurn()
return results
}
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
// CLI session: path points to a .jsonl file
if (source.path.endsWith('.jsonl')) {
const jsonlContent = await readSessionFile(source.path)
if (jsonlContent === null) return
const entries: KiroCliEntry[] = []
for (const line of jsonlContent.split('\n')) {
if (!line.trim()) continue
try { entries.push(JSON.parse(line) as KiroCliEntry) } catch { /* skip malformed lines */ }
}
if (entries.length === 0) return
// Load companion .json for metadata
const metaPath = source.path.replace(/\.jsonl$/, '.json')
let meta: KiroCliSessionMeta
try {
const raw = await readFile(metaPath, 'utf-8')
meta = JSON.parse(raw) as KiroCliSessionMeta
} catch {
// Minimal fallback
meta = { session_id: basename(source.path, '.jsonl'), cwd: '', created_at: '', updated_at: '' }
}
for (const call of parseCliSession(meta, entries, seenKeys)) {
yield call
}
return
}
// IDE session: original path
const content = await readSessionFile(source.path)
if (content === null) return
@ -423,9 +606,28 @@ async function resolveWorkspaceProject(agentDir: string, workspaceStorageDir: st
return workspaceHash
}
async function discoverSessions(agentDir: string, workspaceStorageDir: string): Promise<SessionSource[]> {
async function discoverSessions(agentDir: string, workspaceStorageDir: string, cliSessionsDir: string): Promise<SessionSource[]> {
const sources: SessionSource[] = []
// --- Kiro CLI sessions (~/.kiro/sessions/cli/) ---
try {
const cliEntries = await readdir(cliSessionsDir, { withFileTypes: true })
for (const entry of cliEntries) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
const jsonlPath = join(cliSessionsDir, entry.name)
// Derive project from companion .json
const metaPath = jsonlPath.replace(/\.jsonl$/, '.json')
let project = 'kiro-cli'
try {
const raw = await readFile(metaPath, 'utf-8')
const meta = JSON.parse(raw) as { cwd?: string }
if (meta.cwd) project = basename(meta.cwd)
} catch {}
sources.push({ path: jsonlPath, project, provider: 'kiro' })
}
} catch {}
// --- Kiro IDE sessions ---
let workspaceDirs: string[]
try {
const entries = await readdir(agentDir, { withFileTypes: true })
@ -468,9 +670,11 @@ async function discoverSessions(agentDir: string, workspaceStorageDir: string):
return sources
}
export function createKiroProvider(agentDirOverride?: string, workspaceStorageDirOverride?: string): Provider {
export function createKiroProvider(agentDirOverride?: string, workspaceStorageDirOverride?: string, cliSessionsDirOverride?: string): Provider {
const agentDir = 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'))
return {
name: 'kiro',
@ -489,7 +693,7 @@ export function createKiroProvider(agentDirOverride?: string, workspaceStorageDi
},
async discoverSessions(): Promise<SessionSource[]> {
return discoverSessions(agentDir, wsDir)
return discoverSessions(agentDir, wsDir, cliDir)
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {

View file

@ -604,3 +604,121 @@ describe('kiro provider - metadata', () => {
expect(kiro.modelDisplayName('claude-haiku-4-5-20260101')).toBe('Haiku 4.5')
})
})
describe('kiro provider - CLI session discovery', () => {
let cliDir: string
beforeEach(async () => {
cliDir = await mkdtemp(join(tmpdir(), 'kiro-cli-test-'))
})
afterEach(async () => {
await rm(cliDir, { recursive: true, force: true })
})
it('discovers .jsonl files from CLI sessions directory', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111'
await writeFile(join(cliDir, `${sessionId}.jsonl`), '')
await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({
session_id: sessionId,
cwd: '/home/user/my-project',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:01:00Z',
}))
const provider = createKiroProvider('/nonexistent', '/nonexistent', cliDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.project).toBe('my-project')
expect(sessions[0]!.path).toContain('.jsonl')
expect(sessions[0]!.provider).toBe('kiro')
})
it('parses CLI session JSONL into calls', async () => {
const sessionId = '22222222-2222-2222-2222-222222222222'
const jsonl = [
JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hello world' }], meta: { timestamp: 1700000000 } } }),
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'Hello! How can I help you today?' }, { kind: 'toolUse', data: { toolUseId: 't1', name: 'read', input: {} } }] } }),
JSON.stringify({ version: '1', kind: 'ToolResults', data: { message_id: 'm3', content: [{ kind: 'text', data: 'file contents here' }], results: { t1: { output: 'ok' } } } }),
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm4', content: [{ kind: 'text', data: 'I read the file for you.' }] } }),
].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: 'auto' } },
conversation_metadata: {
user_turn_metadatas: [{
end_timestamp: '2026-01-01T00:00:30Z',
metering_usage: [{ value: 0.05, unit: 'credit' }, { value: 0.08, unit: 'credit' }],
}],
},
},
}))
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)
const call = calls[0]!
expect(call.provider).toBe('kiro')
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.deduplicationKey).toBe(`kiro-cli:${sessionId}:0`)
expect(call.timestamp).toBe('2026-01-01T00:00:30.000Z')
expect(call.project).toBe('test-project')
})
it('parses multiple turns from a CLI session', async () => {
const sessionId = '33333333-3333-3333-3333-333333333333'
const jsonl = [
JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'first question' }], meta: { timestamp: 1700000000 } } }),
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'first answer' }] } }),
JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm3', content: [{ kind: 'text', data: 'second question' }], meta: { timestamp: 1700000060 } } }),
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm4', content: [{ kind: 'text', data: 'second answer' }] } }),
].join('\n')
await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl)
await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({
session_id: sessionId,
cwd: '/tmp/multi',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:02: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.04, unit: 'credit' }] },
{ end_timestamp: '2026-01-01T00:01:30Z', metering_usage: [{ value: 0.06, unit: 'credit' }] },
],
},
},
}))
const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'multi', provider: 'kiro' }
const calls: ParsedProviderCall[] = []
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(2)
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)
})
it('skips non-jsonl files in CLI directory', async () => {
await writeFile(join(cliDir, 'something.json'), '{}')
await writeFile(join(cliDir, 'something.lock'), '')
const provider = createKiroProvider('/nonexistent', '/nonexistent', cliDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(0)
})
})