mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
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:
commit
59efa7c824
2 changed files with 325 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue