@threadcrumbs/common 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,734 +1,734 @@
1
- import fsp from 'node:fs/promises'
2
- import path from 'node:path'
3
- import { columnsOf, tablesOf, withReadOnly } from './sqlite.mjs'
4
- import {
5
- DETAIL_LIMITS,
6
- RECENT_MS,
7
- activityDetail,
8
- addFileChange,
9
- askOf,
10
- clip,
11
- clipBlock,
12
- emptyActivity,
13
- emptySample,
14
- endCall,
15
- fileList,
16
- joinedText,
17
- keepNewest,
18
- keepText,
19
- number,
20
- openTool,
21
- planItems,
22
- planSummary,
23
- relativePath,
24
- sampleList,
25
- samplePoint,
26
- settleCalls,
27
- startCall,
28
- text,
29
- textList,
30
- titleFromPrompt,
31
- toolFile,
32
- toolInput,
33
- toolNow,
34
- toolTarget,
35
- } from './telemetry-fields.mjs'
36
-
37
- const ACTIVE_WINDOW_MS = 30 * 60 * 1000
38
- const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
39
- const OPEN_TOOL = new Set(['pending', 'streaming', 'running'])
40
- const ABORTED = /abort/i
41
- const STOPPED = /user rejected permission|permission.{0,20}denied|denied.{0,20}permission|aborted|cancelled/i
42
- const QUESTION_TOOL = 'question'
43
- const SUBAGENT_TOOLS = new Set(['task', 'subagent'])
44
- const TODO_TOOL = 'todowrite'
45
- const CATALOG_KEY = 'models-dev:v2:catalog'
46
- const TURN_MESSAGES = ['user', 'assistant', 'shell', 'synthetic', 'compaction', 'idle']
47
- const USAGE_COLUMNS = ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write']
48
-
49
- function parse(raw) {
50
- try {
51
- const value = typeof raw === 'string' ? JSON.parse(raw) : raw
52
- return value && typeof value === 'object' ? value : {}
53
- } catch {
54
- return {}
55
- }
56
- }
57
-
58
- const list = (value) => (Array.isArray(value) ? value : Object.values(value || {}))
59
-
60
- function addTokens(total, t) {
61
- if (!t) return total
62
- const next = total || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
63
- next.input += number(t.input)
64
- next.output += number(t.output) + number(t.reasoning)
65
- next.cacheRead += number(t.cache?.read)
66
- next.cacheWrite += number(t.cache?.write)
67
- return next
68
- }
69
-
70
- const windowOf = (t) => number(t?.input) + number(t?.output) + number(t?.reasoning) + number(t?.cache?.read) + number(t?.cache?.write)
71
-
72
- function usageFromColumns(row) {
73
- if (row.tokens_input == null && row.cost == null) return null
74
- return {
75
- tokens: {
76
- input: number(row.tokens_input),
77
- output: number(row.tokens_output) + number(row.tokens_reasoning),
78
- cacheRead: number(row.tokens_cache_read),
79
- cacheWrite: number(row.tokens_cache_write),
80
- },
81
- cost: row.cost == null ? null : number(row.cost),
82
- }
83
- }
84
-
85
- const pickColumns = (cols, names) => names.map((c) => (cols.has(c) ? c : `NULL AS ${c}`)).join(', ')
86
-
87
- export function opencodeStore({ id, name, dbPath, watchDirs, limitsFile, continuing = ['tool-calls'], liveSummary = false, now = Date.now }) {
88
- const facts = new Map()
89
- const options = { id, continuing: new Set(continuing), liveSummary }
90
- let limits = { file: '', mtime: -1, byModel: new Map() }
91
-
92
- async function fileLimits() {
93
- const file = await limitsFile()
94
- const stat = file ? await fsp.stat(file).catch(() => null) : null
95
- if (!stat) return new Map()
96
- if (limits.file === file && limits.mtime === stat.mtimeMs) return limits.byModel
97
- const byModel = new Map()
98
- for (const [providerId, provider] of Object.entries(parse(await fsp.readFile(file, 'utf8').catch(() => '{}')))) {
99
- for (const [modelId, model] of Object.entries(provider?.models || {})) {
100
- const limit = number(model?.limit?.context)
101
- if (limit) byModel.set(`${providerId}/${modelId}`, limit)
102
- }
103
- }
104
- limits = { file, mtime: stat.mtimeMs, byModel }
105
- return byModel
106
- }
107
-
108
- async function scanSessions() {
109
- const file = await dbPath()
110
- if (!file) return []
111
- const context = { ...options, facts, seen: new Set(), byModel: new Map(await fileLimits()), at: now() }
112
- const sessions = await withReadOnly(
113
- file,
114
- (db) => {
115
- const tables = tablesOf(db)
116
- const byId = new Map()
117
- const keep = (s) => {
118
- const existing = byId.get(s.id)
119
- if (!existing || s.lastActivityAt >= existing.lastActivityAt) byId.set(s.id, s)
120
- }
121
- if (['session', 'message', 'part'].every((t) => tables.has(t))) legacySessions(db, tables, context).forEach(keep)
122
- if (tables.has('session_v2') && tables.has('session_message')) {
123
- if (tables.has('kv')) catalogLimits(db, context.byModel)
124
- currentSessions(db, tables, context).forEach(keep)
125
- }
126
- return [...byId.values()]
127
- },
128
- [],
129
- )
130
- for (const key of facts.keys()) if (!context.seen.has(key)) facts.delete(key)
131
- return sessions
132
- }
133
-
134
- async function sessionDetail(key, { filesOnly = false } = {}) {
135
- const prefix = `${id}:`
136
- const sessionId = String(key).startsWith(prefix) ? String(key).slice(prefix.length) : ''
137
- if (filesOnly) return sessionId ? { id: `${id}:${sessionId}`, sessionId, files: facts.get(`v1:${sessionId}`)?.value.files ?? null } : null
138
- const file = sessionId ? await dbPath() : ''
139
- if (!file) return null
140
- const context = { ...options, facts, seen: new Set(), byModel: new Map(), at: now() }
141
- return withReadOnly(
142
- file,
143
- (db) => {
144
- const tables = tablesOf(db)
145
- const legacy = ['session', 'message', 'part'].every((t) => tables.has(t)) ? sessionRow(db, 'session', sessionId) : null
146
- const current = tables.has('session_v2') && tables.has('session_message') ? sessionRow(db, 'session_v2', sessionId) : null
147
- if (!legacy && !current) return null
148
- const useCurrent = current && (!legacy || number(current.time_updated) >= number(legacy.time_updated))
149
- const detail = useCurrent ? currentDetail(db, tables, current, context) : legacyDetail(db, tables, legacy, context)
150
- return { id: `${id}:${sessionId}`, sessionId, source: file, ...detail }
151
- },
152
- null,
153
- )
154
- }
155
-
156
- return {
157
- id,
158
- name,
159
- detect: async () => Boolean(await dbPath()),
160
- watchRoots: () => watchDirs,
161
- scanSessions,
162
- sessionDetail,
163
- }
164
- }
165
-
166
- const ROW_COLUMNS = ['title', 'parent_id', 'model', 'time_archived', 'time_idle', 'time_busy', 'idle_outcome', ...USAGE_COLUMNS]
167
-
168
- function sessionRows(db, table, where, value) {
169
- const cols = columnsOf(db, table)
170
- if (!['id', 'directory', 'time_created', 'time_updated'].every((c) => cols.has(c))) return []
171
- if (where === 'parent_id' && !cols.has('parent_id')) return []
172
- return db.prepare(`SELECT id, directory, time_created, time_updated, ${pickColumns(cols, ROW_COLUMNS)} FROM ${table} WHERE ${where} = ?`).all(value)
173
- }
174
-
175
- function sessionRow(db, table, sessionId) {
176
- const row = sessionRows(db, table, 'id', sessionId)[0]
177
- return row && !row.parent_id && row.time_archived == null ? row : null
178
- }
179
-
180
- function addModelUsage(models, model, tokens, cost) {
181
- if (!model) return
182
- const entry = models.get(model) || { model, tokens: null, costUsd: null }
183
- entry.tokens = addTokens(entry.tokens, tokens)
184
- if (cost != null) entry.costUsd = (entry.costUsd || 0) + number(cost)
185
- models.set(model, entry)
186
- }
187
-
188
- function modelList(models) {
189
- return [...models.values()].map((m) => ({ model: m.model, tokens: m.tokens || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, costUsd: m.costUsd }))
190
- }
191
-
192
- function childList(rows, turnOf) {
193
- return rows
194
- .filter((r) => r.time_archived == null)
195
- .map((r) => {
196
- const turn = turnOf(r)
197
- return {
198
- id: r.id,
199
- name: childName(r, turn),
200
- type: '',
201
- status: turn.open ? 'working' : turn.failed ? 'error' : 'done',
202
- model: turn.model,
203
- tokens: turn.tokens ? { ...turn.tokens } : null,
204
- tool: turn.open && turn.tool ? toolNow(turn.tool) : null,
205
- startedAt: number(r.time_created),
206
- lastActivityAt: number(r.time_updated),
207
- }
208
- })
209
- .sort((a, b) => a.startedAt - b.startedAt)
210
- .slice(-DETAIL_LIMITS.subagents)
211
- }
212
-
213
- function legacyDetail(db, tables, row, context) {
214
- const directory = text(row.directory)
215
- const activity = emptyActivity()
216
- const history = []
217
- const prompts = []
218
- const errors = []
219
- const files = new Map()
220
- const models = new Map()
221
- let contextParts = null
222
- const typed = new Set(
223
- db
224
- .prepare(`SELECT message_id FROM part WHERE session_id = ? AND data LIKE '%"type":"text"%' AND data NOT LIKE '%"synthetic":true%'`)
225
- .all(row.id)
226
- .map((r) => r.message_id),
227
- )
228
- const modelOf = new Map()
229
- const messageAt = new Map()
230
- const messageUsage = []
231
- for (const message of db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, rowid').all(row.id)) {
232
- const data = parse(message.data)
233
- const at = number(data.time?.created) || number(message.time_created)
234
- if (data.role === 'user') {
235
- if (typed.has(message.id)) {
236
- const prompt = firstText(db, message.id)
237
- if (prompt) keepNewest(prompts, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
238
- }
239
- for (const diff of Array.isArray(data.summary?.diffs) ? data.summary.diffs : []) {
240
- if (text(diff?.file)) addFileChange(files, text(diff.file), number(diff.additions), number(diff.deletions))
241
- }
242
- continue
243
- }
244
- if (data.role !== 'assistant') continue
245
- const model = text(data.modelID)
246
- modelOf.set(message.id, model)
247
- messageAt.set(message.id, at)
248
- messageUsage.push([model, data.tokens, data.cost])
249
- if (number(data.tokens?.output) > 0) {
250
- const output = number(data.tokens.output) + number(data.tokens.reasoning)
251
- keepNewest(history, { at: number(data.time?.completed) || at, context: windowOf(data.tokens), output }, DETAIL_LIMITS.history)
252
- contextParts = { input: number(data.tokens.input), cacheRead: number(data.tokens.cache?.read), cacheWrite: number(data.tokens.cache?.write), output: number(data.tokens.output) + number(data.tokens.reasoning) }
253
- }
254
- const error = text(data.error?.name)
255
- if (error && !ABORTED.test(error)) {
256
- keepNewest(errors, { at, message: clip(text(data.error?.data?.message) || error, DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
257
- }
258
- }
259
-
260
- const steps = db.prepare(`SELECT message_id, data FROM part WHERE session_id = ? AND data LIKE '%"type":"step-finish"%' ORDER BY rowid`).all(row.id)
261
- if (steps.length) {
262
- for (const step of steps) {
263
- const data = parse(step.data)
264
- addModelUsage(models, modelOf.get(step.message_id), data.tokens, data.cost)
265
- }
266
- } else {
267
- for (const [model, tokens, cost] of messageUsage) addModelUsage(models, model, tokens, cost)
268
- }
269
-
270
- for (const part of db.prepare(`SELECT id, data FROM part WHERE session_id = ? AND data LIKE '%"type":"tool"%' ORDER BY rowid`).all(row.id)) {
271
- const data = parse(part.data)
272
- if (data.type !== 'tool') continue
273
- const state = data.state || {}
274
- const callId = text(data.callID) || part.id
275
- const call = { id: callId, name: text(data.tool), target: toolTarget(state.input, directory), input: toolInput(state.input), ...toolFile(state.input, directory) }
276
- if (!startCall(activity, { ...call, startedAt: number(state.time?.start) })) continue
277
- if (state.status === 'completed' || state.status === 'error') {
278
- const failed = state.status === 'error' && !STOPPED.test(`${text(state.error)}\n${text(state.output)}`)
279
- endCall(activity, callId, number(state.time?.end), failed, text(state.error) || text(state.output))
280
- }
281
- }
282
- const thoughts = []
283
- const replies = []
284
- const said = db.prepare(`SELECT message_id, data FROM part WHERE session_id = ? AND (data LIKE '%"type":"text"%' OR data LIKE '%"type":"reasoning"%') ORDER BY rowid`)
285
- for (const part of said.all(row.id)) {
286
- if (!messageAt.has(part.message_id)) continue
287
- const data = parse(part.data)
288
- if (data.synthetic || data.ignored) continue
289
- const at = number(data.time?.end) || number(data.time?.start) || messageAt.get(part.message_id)
290
- if (data.type === 'reasoning') keepText(thoughts, 'thoughts', { id: part.message_id, at, text: text(data.text) })
291
- else if (data.type === 'text') keepText(replies, 'replies', { id: part.message_id, at, text: text(data.text) })
292
- }
293
- const turnOf = (r) => cached(context, `v1:${r.id}`, number(r.time_updated), () => legacyTurn(db, r, context))
294
- if (!turnOf(row).open) settleCalls(activity)
295
-
296
- const todoCols = tables.has('todo') ? columnsOf(db, 'todo') : new Set()
297
- const plan = ['session_id', 'content', 'status'].every((c) => todoCols.has(c))
298
- ? db.prepare(`SELECT content, status FROM todo WHERE session_id = ? ORDER BY ${todoCols.has('position') ? 'position' : 'rowid'}`).all(row.id)
299
- : []
300
-
301
- return {
302
- models: modelList(models),
303
- history,
304
- compactions: null,
305
- contextParts,
306
- ...activityDetail(activity),
307
- plan: planItems(plan.map((t) => ({ content: text(t.content), status: text(t.status) }))),
308
- subagents: childList(sessionRows(db, 'session', 'parent_id', row.id), turnOf),
309
- files: files.size || !context.liveSummary ? fileList(files, directory) : null,
310
- prompts,
311
- thoughts: textList(thoughts),
312
- replies: textList(replies),
313
- contextBreakdown: null,
314
- errors,
315
- }
316
- }
317
-
318
- function currentDetail(db, tables, row, context) {
319
- const directory = text(row.directory)
320
- const activity = emptyActivity()
321
- const history = []
322
- const prompts = []
323
- const errors = []
324
- const models = new Map()
325
- let contextParts = null
326
- const replied = []
327
- for (const message of db.prepare('SELECT id, type, time_created, data FROM session_message WHERE session_id = ? ORDER BY seq').all(row.id)) {
328
- const data = parse(message.data)
329
- const at = number(message.time_created)
330
- if (message.type === 'user') {
331
- const prompt = text(data.text).trim()
332
- if (prompt) keepNewest(prompts, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
333
- } else if (message.type === 'assistant') {
334
- replied.push({ id: message.id, at, content: list(data.content) })
335
- addModelUsage(models, text(data.model?.id), data.tokens, data.cost)
336
- if (number(data.tokens?.output) > 0) {
337
- keepNewest(history, { at, context: windowOf(data.tokens), output: number(data.tokens.output) + number(data.tokens.reasoning) }, DETAIL_LIMITS.history)
338
- contextParts = { input: number(data.tokens.input), cacheRead: number(data.tokens.cache?.read), cacheWrite: number(data.tokens.cache?.write), output: number(data.tokens.output) + number(data.tokens.reasoning) }
339
- }
340
- } else if (message.type === 'idle' && text(data.outcome) === 'failed') {
341
- keepNewest(errors, { at, message: clip(text(data.error?.message) || 'The turn failed', DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
342
- }
343
- }
344
-
345
- const parts = tables.has('session_part') && ['session_id', 'message_id', 'type', 'data'].every((c) => columnsOf(db, 'session_part').has(c))
346
- const rows = new Map()
347
- if (parts) {
348
- for (const part of db.prepare(`SELECT id, message_id, type, data FROM session_part WHERE session_id = ? AND type IN ('tool', 'reasoning', 'text') ORDER BY rowid`).all(row.id)) {
349
- const data = parse(part.data)
350
- if (!rows.has(part.message_id)) rows.set(part.message_id, [])
351
- rows.get(part.message_id).push({ ...data, type: text(data.type) || part.type, rowId: part.id })
352
- }
353
- }
354
- let plan = []
355
- const thoughts = []
356
- const replies = []
357
- for (const message of replied) {
358
- const content = rows.get(message.id) || message.content
359
- content.forEach((data, i) => {
360
- if (data?.type !== 'tool') return
361
- const state = data.state || {}
362
- const callId = text(data.callID) || text(data.id) || data.rowId || `${message.id}:${i}`
363
- const call = { id: callId, name: text(data.name), target: toolTarget(state.input, directory), input: toolInput(state.input), ...toolFile(state.input, directory) }
364
- if (!startCall(activity, { ...call, startedAt: number(data.time?.ran) || number(data.time?.created) || message.at })) return
365
- if (state.status === 'completed' || state.status === 'error') {
366
- endCall(activity, callId, number(state.time?.end) || number(data.time?.completed), state.status === 'error', text(state.error) || text(state.output))
367
- }
368
- if (data.name === TODO_TOOL && state.status === 'completed' && Array.isArray(state.input?.todos)) {
369
- plan = state.input.todos.map((t) => ({ content: text(t?.content), status: text(t?.status) }))
370
- }
371
- })
372
- keepText(thoughts, 'thoughts', { at: message.at, text: joinedText(content, 'reasoning', 'text') })
373
- keepText(replies, 'replies', { at: message.at, text: joinedText(content, 'text') })
374
- }
375
- const turnOf = (r) => cached(context, `v2:${r.id}`, number(r.time_updated), () => currentTurn(db, r, parts, context))
376
- if (!turnOf(row).open) settleCalls(activity)
377
-
378
- return {
379
- models: modelList(models),
380
- history,
381
- compactions: null,
382
- contextParts,
383
- ...activityDetail(activity),
384
- plan: planItems(plan),
385
- subagents: childList(sessionRows(db, 'session_v2', 'parent_id', row.id), turnOf),
386
- files: null,
387
- prompts,
388
- thoughts: textList(thoughts),
389
- replies: textList(replies),
390
- contextBreakdown: null,
391
- errors,
392
- }
393
- }
394
-
395
- function catalogLimits(db, byModel) {
396
- const row = db.prepare('SELECT value FROM kv WHERE key = ?').get(CATALOG_KEY)
397
- if (!row) return
398
- const body = parse(parse(row.value).body)
399
- for (const provider of list(body.providers ?? body)) {
400
- for (const model of list(provider?.models)) {
401
- const limit = number(model?.limit?.context)
402
- if (provider?.id && model?.id && limit) byModel.set(`${provider.id}/${model.id}`, limit)
403
- }
404
- }
405
- }
406
-
407
- function cached(context, key, updated, compute) {
408
- context.seen.add(key)
409
- const hit = context.facts.get(key)
410
- let turn = hit?.value
411
- if (!hit || hit.updated !== updated) {
412
- turn = compute()
413
- context.facts.set(key, { updated, value: turn })
414
- }
415
- const open = turn.busy && context.at - updated < ACTIVE_WINDOW_MS
416
- return { ...turn, open, tool: open ? turn.tool : null }
417
- }
418
-
419
- function statusOf({ open, failed, errands, tool, lastActivityAt, read, at }) {
420
- if (open) {
421
- if (tool?.name === QUESTION_TOOL) return ['asking', 'question']
422
- if (errands && (!tool || SUBAGENT_TOOLS.has(tool.name))) return ['delegating', 'subagents']
423
- return ['working', 'turn-open']
424
- }
425
- if (errands) return ['delegating', 'subagents']
426
- if (failed) return ['error', 'turn-failed']
427
- if (read) return read.unread ? ['done', 'unread'] : ['idle', 'read']
428
- return at - lastActivityAt < ACTIVE_WINDOW_MS ? ['done', 'recent'] : ['idle', 'quiet']
429
- }
430
-
431
- const childName = (row, turn) => text(row.title).trim() || (turn.firstPrompt ? titleFromPrompt(turn.firstPrompt) : '') || 'Subagent'
432
-
433
- function workingChildren(rows, parent, turnOf, context) {
434
- return rows
435
- .filter((r) => r.parent_id === parent.id && context.at - number(r.time_updated) < SUBAGENT_WINDOW_MS && turnOf(r).open)
436
- .sort((a, b) => number(a.time_created) - number(b.time_created))
437
- .map((r) => ({ id: r.id, name: childName(r, turnOf(r)) }))
438
- }
439
-
440
- function session(context, row, turn, extra) {
441
- const directory = text(row.directory)
442
- const sessionModel = parse(row.model)
443
- const lastActivityAt = number(row.time_updated)
444
- const viewed = number(row.time_viewed)
445
- const read = viewed > 0 ? { unread: number(row.time_idle) > viewed } : null
446
- const errands = extra.working.length
447
- const [status, statusReason] = statusOf({ ...turn, errands, lastActivityAt, read, at: context.at })
448
- return {
449
- id: `${context.id}:${row.id}`,
450
- harness: context.id,
451
- title: text(row.title).trim() || (turn.firstPrompt ? titleFromPrompt(turn.firstPrompt) : '') || 'Untitled session',
452
- project: path.basename(directory.replace(/\\/g, '/')) || 'unknown',
453
- projectPath: directory,
454
- worktree: '',
455
- cwd: directory,
456
- gitBranch: '',
457
- model: turn.model || text(sessionModel.id),
458
- effort: turn.effort || text(sessionModel.variant),
459
- status,
460
- live: turn.open,
461
- startedAt: number(row.time_created),
462
- lastActivityAt,
463
- tool: turn.open && turn.tool ? toolNow(turn.tool) : null,
464
- tokens: turn.tokens ? { ...turn.tokens } : null,
465
- context: turn.context ? { used: turn.context, limit: context.byModel.get(`${turn.provider}/${turn.model}`) || 0 } : null,
466
- contextHistory: sampleList(turn.contextSample, turn.context),
467
- costUsd: turn.cost,
468
- lines: extra.lines,
469
- plan: planSummary(extra.plan),
470
- subagents: errands,
471
- workingSubagents: extra.working,
472
- prompts: turn.prompts,
473
- turnStartedAt: turn.open ? turn.turnStartedAt || null : null,
474
- statusReason,
475
- ask: askOf(status, { calls: turn.open && turn.tool ? [turn.tool] : [], errorText: turn.errorText }),
476
- usageLimits: null,
477
- }
478
- }
479
-
480
- function legacySessions(db, tables, context) {
481
- const cols = columnsOf(db, 'session')
482
- if (!['id', 'directory', 'time_created', 'time_updated'].every((c) => cols.has(c))) return []
483
- const rows = db
484
- .prepare(
485
- `SELECT id, directory, time_created, time_updated, ${pickColumns(cols, ['title', 'parent_id', 'model', 'time_archived', 'summary_additions', 'summary_deletions', ...USAGE_COLUMNS])} FROM session WHERE time_updated > ?`,
486
- )
487
- .all(context.at - RECENT_MS)
488
- const todoCols = tables.has('todo') ? columnsOf(db, 'todo') : new Set()
489
- const todos = ['session_id', 'content', 'status'].every((c) => todoCols.has(c))
490
- const turnOf = (row) => cached(context, `v1:${row.id}`, number(row.time_updated), () => legacyTurn(db, row, context))
491
- const out = []
492
- for (const row of rows) {
493
- if (row.parent_id || row.time_archived != null) continue
494
- const turn = turnOf(row)
495
- const working = workingChildren(rows, row, turnOf, context)
496
- const lines = context.liveSummary
497
- ? row.summary_additions == null && row.summary_deletions == null
498
- ? null
499
- : { added: number(row.summary_additions), removed: number(row.summary_deletions) }
500
- : turn.lines
501
- const plan = todos
502
- ? db
503
- .prepare(`SELECT content, status FROM todo WHERE session_id = ? ORDER BY ${todoCols.has('position') ? 'position' : 'rowid'}`)
504
- .all(row.id)
505
- .map((t) => ({ content: text(t.content), status: text(t.status) }))
506
- : null
507
- out.push(session(context, row, turn, { working, lines, plan }))
508
- }
509
- return out
510
- }
511
-
512
- function legacyTurn(db, row, context) {
513
- const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, contextSample: emptySample(), lines: null, tool: null, turnStartedAt: 0 }
514
- const typed = new Set(
515
- db
516
- .prepare(`SELECT message_id FROM part WHERE session_id = ? AND data LIKE '%"type":"text"%' AND data NOT LIKE '%"synthetic":true%'`)
517
- .all(row.id)
518
- .map((r) => r.message_id),
519
- )
520
- let last = null
521
- let lastAssistant = ''
522
- const changed = new Set()
523
- for (const message of db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, rowid').all(row.id)) {
524
- const data = parse(message.data)
525
- last = data
526
- if (data.role === 'user') {
527
- turn.turnStartedAt = number(data.time?.created) || number(message.time_created)
528
- if (typed.has(message.id)) {
529
- turn.prompts++
530
- if (!turn.firstPrompt) turn.firstPrompt = firstText(db, message.id)
531
- }
532
- if (Array.isArray(data.summary?.diffs)) {
533
- turn.lines ||= { added: 0, removed: 0 }
534
- for (const diff of data.summary.diffs) {
535
- turn.lines.added += number(diff?.additions)
536
- turn.lines.removed += number(diff?.deletions)
537
- if (text(diff?.file)) changed.add(relativePath(text(diff.file), text(row.directory)))
538
- }
539
- }
540
- continue
541
- }
542
- if (data.role !== 'assistant') continue
543
- lastAssistant = message.id
544
- if (data.modelID) turn.model = text(data.modelID)
545
- if (data.providerID) turn.provider = text(data.providerID)
546
- if (data.variant) turn.effort = text(data.variant)
547
- if (data.cost != null) turn.cost = (turn.cost || 0) + number(data.cost)
548
- if (number(data.tokens?.output) > 0) {
549
- turn.context = windowOf(data.tokens)
550
- samplePoint(turn.contextSample, turn.context)
551
- }
552
- }
553
- turn.files = changed.size || !context.liveSummary ? [...changed].map((file) => ({ path: file })) : null
554
- const usage = usageFromColumns(row)
555
- if (usage) {
556
- turn.tokens = usage.tokens
557
- if (usage.cost != null) turn.cost = usage.cost
558
- } else {
559
- for (const part of db.prepare(`SELECT data FROM part WHERE session_id = ? AND data LIKE '%"type":"step-finish"%'`).all(row.id)) {
560
- turn.tokens = addTokens(turn.tokens, parse(part.data).tokens)
561
- }
562
- }
563
- const error = text(last?.error?.name)
564
- const finish = text(last?.finish)
565
- const ended = Boolean(last?.time?.completed) || Boolean(finish)
566
- turn.busy = last?.role === 'user' || (last?.role === 'assistant' && !error && (!ended || context.continuing.has(finish)))
567
- const failedTool = last?.role === 'assistant' && !error && ended ? toolFailure(db, lastAssistant) : ''
568
- turn.failed = last?.role === 'assistant' && (error ? !ABORTED.test(error) : Boolean(failedTool))
569
- turn.errorText = turn.failed ? text(last?.error?.data?.message) || error || failedTool : ''
570
- if (turn.busy) turn.tool = runningTool(db, lastAssistant, text(row.directory))
571
- return turn
572
- }
573
-
574
- function firstText(db, messageId) {
575
- for (const part of db.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY rowid LIMIT 8').all(messageId)) {
576
- const data = parse(part.data)
577
- if (data.type === 'text' && !data.synthetic && text(data.text).trim()) return text(data.text).trim()
578
- }
579
- return ''
580
- }
581
-
582
- function toolFailure(db, messageId) {
583
- for (const part of db.prepare(`SELECT data FROM part WHERE message_id = ? AND data LIKE '%"status":"error"%'`).all(messageId)) {
584
- const state = parse(part.data).state || {}
585
- if (!STOPPED.test(`${text(state.error)}\n${text(state.output)}`)) return text(state.error) || text(state.output) || 'A tool failed'
586
- }
587
- return ''
588
- }
589
-
590
- function runningTool(db, messageId, cwd) {
591
- if (!messageId) return null
592
- let tool = null
593
- for (const part of db.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY rowid').all(messageId)) {
594
- const data = parse(part.data)
595
- if (data.type !== 'tool' || !OPEN_TOOL.has(data.state?.status)) continue
596
- tool = openTool(text(data.tool), number(data.state?.time?.start), data.state?.input, cwd)
597
- }
598
- return tool
599
- }
600
-
601
- function currentSessions(db, tables, context) {
602
- const cols = columnsOf(db, 'session_v2')
603
- if (!['id', 'directory', 'time_created', 'time_updated'].every((c) => cols.has(c))) return []
604
- const rows = db
605
- .prepare(
606
- `SELECT id, directory, time_created, time_updated, ${pickColumns(cols, ['title', 'parent_id', 'model', 'time_archived', 'time_idle', 'time_viewed', 'time_busy', 'idle_outcome', ...USAGE_COLUMNS])} FROM session_v2 WHERE time_updated > ?`,
607
- )
608
- .all(context.at - RECENT_MS)
609
- const parts = tables.has('session_part') && ['session_id', 'message_id', 'type', 'data'].every((c) => columnsOf(db, 'session_part').has(c))
610
- const turnOf = (row) => cached(context, `v2:${row.id}`, number(row.time_updated), () => currentTurn(db, row, parts, context))
611
- const out = []
612
- for (const row of rows) {
613
- if (row.parent_id || row.time_archived != null) continue
614
- const turn = turnOf(row)
615
- out.push(session(context, row, turn, { working: workingChildren(rows, row, turnOf, context), lines: null, plan: turn.plan }))
616
- }
617
- return out
618
- }
619
-
620
- function currentSample(db, sessionId, before) {
621
- let { contextSample: sample, contextSeq: seq = -1, contextCount: count = 0 } = before || {}
622
- const kept = sample && db.prepare(`SELECT COUNT(*) AS n, MAX(seq) AS top FROM session_message WHERE session_id = ? AND type = 'assistant' AND seq <= ?`).get(sessionId, seq)
623
- if (!kept || number(kept.n) !== count || (count && number(kept.top) !== seq)) {
624
- sample = emptySample()
625
- seq = -1
626
- count = 0
627
- }
628
- const newer = db.prepare(`SELECT seq, data FROM session_message WHERE session_id = ? AND type = 'assistant' AND seq > ? ORDER BY seq`).all(sessionId, seq)
629
- for (const [i, message] of newer.entries()) {
630
- const data = parse(message.data)
631
- if (i === newer.length - 1 && !data.time?.completed) break
632
- if (number(data.tokens?.output) > 0) samplePoint(sample, windowOf(data.tokens))
633
- seq = number(message.seq)
634
- count++
635
- }
636
- return { contextSample: sample, contextSeq: seq, contextCount: count }
637
- }
638
-
639
- function currentTurn(db, row, parts, context) {
640
- const before = context.facts.get(`v2:${row.id}`)?.value
641
- const sampled = currentSample(db, row.id, before)
642
- const kept = sampled.contextSample === before?.contextSample ? before : null
643
- const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, tool: null, plan: null, turnStartedAt: 0, ...sampled }
644
- turn.prompts = number(db.prepare(`SELECT COUNT(*) AS n FROM session_message WHERE session_id = ? AND type = 'user'`).get(row.id)?.n)
645
- const lastUser = db.prepare(`SELECT time_created FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq DESC LIMIT 1`).get(row.id)
646
- turn.turnStartedAt = number(row.time_busy) || number(lastUser?.time_created)
647
- turn.firstPrompt = text(parse(db.prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq ASC LIMIT 1`).get(row.id)?.data).text).trim()
648
- const assistant = db.prepare(`SELECT id, seq, data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq DESC LIMIT 1`).get(row.id)
649
- const latest = parse(assistant?.data)
650
- turn.model = text(latest.model?.id)
651
- turn.provider = text(latest.model?.providerID)
652
- turn.effort = text(latest.model?.variant)
653
- const withTokens = db
654
- .prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' AND data LIKE '%"tokens"%' ORDER BY seq DESC LIMIT 1`)
655
- .get(row.id)
656
- turn.context = windowOf(parse(withTokens?.data).tokens)
657
- const usage = usageFromColumns(row)
658
- if (usage) {
659
- turn.tokens = usage.tokens
660
- turn.cost = usage.cost
661
- }
662
- const last = db
663
- .prepare(`SELECT type, data FROM session_message WHERE session_id = ? AND type IN (${TURN_MESSAGES.map(() => '?').join(', ')}) ORDER BY seq DESC LIMIT 1`)
664
- .get(row.id, ...TURN_MESSAGES)
665
- const busy = row.time_busy != null ? number(row.time_busy) > number(row.time_idle) : Boolean(last) && last.type !== 'idle'
666
- turn.busy = busy
667
- const outcome = text(row.idle_outcome) || (last?.type === 'idle' ? text(parse(last.data).outcome) : '')
668
- turn.failed = !busy && outcome === 'failed'
669
- turn.errorText = turn.failed && last?.type === 'idle' ? text(parse(last.data).error?.message) : ''
670
- const tools = currentTools(db, row.id, assistant, parts, text(row.directory), kept)
671
- turn.planSeq = tools.planSeq
672
- turn.olderTodos = tools.olderTodos
673
- turn.planRow = tools.planRow
674
- turn.rowTodos = tools.rowTodos
675
- if (busy) turn.tool = tools.running
676
- turn.plan = tools.todos
677
- return turn
678
- }
679
-
680
- const completedTodos = (parts) => {
681
- const found = parts.find((p) => p?.name === TODO_TOOL && p.state?.status === 'completed' && Array.isArray(p.state?.input?.todos))
682
- return found ? found.state.input.todos.map((t) => ({ content: text(t?.content), status: text(t?.status) })) : null
683
- }
684
-
685
- function rowTodos(db, sessionId, assistant, before) {
686
- const newest = text(assistant?.id)
687
- const from = before?.planRow ?? -1
688
- let older = before?.rowTodos ?? null
689
- const rows = db
690
- .prepare(`SELECT data FROM session_part WHERE session_id = ? AND type = 'tool' AND rowid > ? AND message_id != ? AND data LIKE ? ORDER BY rowid DESC`)
691
- .all(sessionId, from, newest, `%"${TODO_TOOL}"%`)
692
- for (const row of rows) {
693
- const found = completedTodos([parse(row.data)])
694
- if (found) {
695
- older = found
696
- break
697
- }
698
- }
699
- const top = db.prepare('SELECT MAX(rowid) AS row FROM session_part WHERE session_id = ? AND message_id != ?').get(sessionId, newest)
700
- return { rowTodos: older, planRow: top?.row == null ? from : number(top.row) }
701
- }
702
-
703
- function inlineTodos(db, sessionId, assistant, before) {
704
- const newest = number(assistant?.seq)
705
- let older = before?.olderTodos ?? null
706
- const rows = db
707
- .prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' AND seq > ? AND seq < ? AND data LIKE ? ORDER BY seq DESC`)
708
- .all(sessionId, before?.planSeq ?? -1, newest, `%"${TODO_TOOL}"%`)
709
- for (const row of rows) {
710
- const found = completedTodos(list(parse(row.data).content).filter((c) => c?.type === 'tool').reverse())
711
- if (found) {
712
- older = found
713
- break
714
- }
715
- }
716
- return { olderTodos: older, planSeq: assistant ? newest - 1 : before?.planSeq ?? -1 }
717
- }
718
-
719
- function currentTools(db, sessionId, assistant, parts, cwd, before = null) {
720
- const result = { running: null, todos: null }
721
- let latest = []
722
- if (parts && assistant) {
723
- latest = db.prepare(`SELECT data FROM session_part WHERE session_id = ? AND message_id = ? AND type = 'tool' ORDER BY rowid`).all(sessionId, assistant.id).map((p) => parse(p.data))
724
- }
725
- if (!latest.length && assistant) latest = list(parse(assistant.data).content).filter((c) => c?.type === 'tool')
726
- for (const part of latest) {
727
- if (OPEN_TOOL.has(part.state?.status)) result.running = openTool(text(part.name), number(part.time?.ran) || number(part.time?.created), part.state?.input, cwd)
728
- }
729
- const rows = parts ? rowTodos(db, sessionId, assistant, before) : { rowTodos: null, planRow: -1 }
730
- const inline = inlineTodos(db, sessionId, assistant, before)
731
- Object.assign(result, rows, inline)
732
- result.todos = completedTodos([...latest].reverse()) ?? rows.rowTodos ?? inline.olderTodos
733
- return result
734
- }
1
+ import fsp from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { columnsOf, tablesOf, withReadOnly } from './sqlite.mjs'
4
+ import {
5
+ DETAIL_LIMITS,
6
+ RECENT_MS,
7
+ activityDetail,
8
+ addFileChange,
9
+ askOf,
10
+ clip,
11
+ clipBlock,
12
+ emptyActivity,
13
+ emptySample,
14
+ endCall,
15
+ fileList,
16
+ joinedText,
17
+ keepNewest,
18
+ keepText,
19
+ number,
20
+ openTool,
21
+ planItems,
22
+ planSummary,
23
+ relativePath,
24
+ sampleList,
25
+ samplePoint,
26
+ settleCalls,
27
+ startCall,
28
+ text,
29
+ textList,
30
+ titleFromPrompt,
31
+ toolFile,
32
+ toolInput,
33
+ toolNow,
34
+ toolTarget,
35
+ } from './telemetry-fields.mjs'
36
+
37
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
38
+ const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
39
+ const OPEN_TOOL = new Set(['pending', 'streaming', 'running'])
40
+ const ABORTED = /abort/i
41
+ const STOPPED = /user rejected permission|permission.{0,20}denied|denied.{0,20}permission|aborted|cancelled/i
42
+ const QUESTION_TOOL = 'question'
43
+ const SUBAGENT_TOOLS = new Set(['task', 'subagent'])
44
+ const TODO_TOOL = 'todowrite'
45
+ const CATALOG_KEY = 'models-dev:v2:catalog'
46
+ const TURN_MESSAGES = ['user', 'assistant', 'shell', 'synthetic', 'compaction', 'idle']
47
+ const USAGE_COLUMNS = ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write']
48
+
49
+ function parse(raw) {
50
+ try {
51
+ const value = typeof raw === 'string' ? JSON.parse(raw) : raw
52
+ return value && typeof value === 'object' ? value : {}
53
+ } catch {
54
+ return {}
55
+ }
56
+ }
57
+
58
+ const list = (value) => (Array.isArray(value) ? value : Object.values(value || {}))
59
+
60
+ function addTokens(total, t) {
61
+ if (!t) return total
62
+ const next = total || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
63
+ next.input += number(t.input)
64
+ next.output += number(t.output) + number(t.reasoning)
65
+ next.cacheRead += number(t.cache?.read)
66
+ next.cacheWrite += number(t.cache?.write)
67
+ return next
68
+ }
69
+
70
+ const windowOf = (t) => number(t?.input) + number(t?.output) + number(t?.reasoning) + number(t?.cache?.read) + number(t?.cache?.write)
71
+
72
+ function usageFromColumns(row) {
73
+ if (row.tokens_input == null && row.cost == null) return null
74
+ return {
75
+ tokens: {
76
+ input: number(row.tokens_input),
77
+ output: number(row.tokens_output) + number(row.tokens_reasoning),
78
+ cacheRead: number(row.tokens_cache_read),
79
+ cacheWrite: number(row.tokens_cache_write),
80
+ },
81
+ cost: row.cost == null ? null : number(row.cost),
82
+ }
83
+ }
84
+
85
+ const pickColumns = (cols, names) => names.map((c) => (cols.has(c) ? c : `NULL AS ${c}`)).join(', ')
86
+
87
+ export function opencodeStore({ id, name, dbPath, watchDirs, limitsFile, continuing = ['tool-calls'], liveSummary = false, now = Date.now }) {
88
+ const facts = new Map()
89
+ const options = { id, continuing: new Set(continuing), liveSummary }
90
+ let limits = { file: '', mtime: -1, byModel: new Map() }
91
+
92
+ async function fileLimits() {
93
+ const file = await limitsFile()
94
+ const stat = file ? await fsp.stat(file).catch(() => null) : null
95
+ if (!stat) return new Map()
96
+ if (limits.file === file && limits.mtime === stat.mtimeMs) return limits.byModel
97
+ const byModel = new Map()
98
+ for (const [providerId, provider] of Object.entries(parse(await fsp.readFile(file, 'utf8').catch(() => '{}')))) {
99
+ for (const [modelId, model] of Object.entries(provider?.models || {})) {
100
+ const limit = number(model?.limit?.context)
101
+ if (limit) byModel.set(`${providerId}/${modelId}`, limit)
102
+ }
103
+ }
104
+ limits = { file, mtime: stat.mtimeMs, byModel }
105
+ return byModel
106
+ }
107
+
108
+ async function scanSessions() {
109
+ const file = await dbPath()
110
+ if (!file) return []
111
+ const context = { ...options, facts, seen: new Set(), byModel: new Map(await fileLimits()), at: now() }
112
+ const sessions = await withReadOnly(
113
+ file,
114
+ (db) => {
115
+ const tables = tablesOf(db)
116
+ const byId = new Map()
117
+ const keep = (s) => {
118
+ const existing = byId.get(s.id)
119
+ if (!existing || s.lastActivityAt >= existing.lastActivityAt) byId.set(s.id, s)
120
+ }
121
+ if (['session', 'message', 'part'].every((t) => tables.has(t))) legacySessions(db, tables, context).forEach(keep)
122
+ if (tables.has('session_v2') && tables.has('session_message')) {
123
+ if (tables.has('kv')) catalogLimits(db, context.byModel)
124
+ currentSessions(db, tables, context).forEach(keep)
125
+ }
126
+ return [...byId.values()]
127
+ },
128
+ [],
129
+ )
130
+ for (const key of facts.keys()) if (!context.seen.has(key)) facts.delete(key)
131
+ return sessions
132
+ }
133
+
134
+ async function sessionDetail(key, { filesOnly = false } = {}) {
135
+ const prefix = `${id}:`
136
+ const sessionId = String(key).startsWith(prefix) ? String(key).slice(prefix.length) : ''
137
+ if (filesOnly) return sessionId ? { id: `${id}:${sessionId}`, sessionId, files: facts.get(`v1:${sessionId}`)?.value.files ?? null } : null
138
+ const file = sessionId ? await dbPath() : ''
139
+ if (!file) return null
140
+ const context = { ...options, facts, seen: new Set(), byModel: new Map(), at: now() }
141
+ return withReadOnly(
142
+ file,
143
+ (db) => {
144
+ const tables = tablesOf(db)
145
+ const legacy = ['session', 'message', 'part'].every((t) => tables.has(t)) ? sessionRow(db, 'session', sessionId) : null
146
+ const current = tables.has('session_v2') && tables.has('session_message') ? sessionRow(db, 'session_v2', sessionId) : null
147
+ if (!legacy && !current) return null
148
+ const useCurrent = current && (!legacy || number(current.time_updated) >= number(legacy.time_updated))
149
+ const detail = useCurrent ? currentDetail(db, tables, current, context) : legacyDetail(db, tables, legacy, context)
150
+ return { id: `${id}:${sessionId}`, sessionId, source: file, ...detail }
151
+ },
152
+ null,
153
+ )
154
+ }
155
+
156
+ return {
157
+ id,
158
+ name,
159
+ detect: async () => Boolean(await dbPath()),
160
+ watchRoots: () => watchDirs,
161
+ scanSessions,
162
+ sessionDetail,
163
+ }
164
+ }
165
+
166
+ const ROW_COLUMNS = ['title', 'parent_id', 'model', 'time_archived', 'time_idle', 'time_busy', 'idle_outcome', ...USAGE_COLUMNS]
167
+
168
+ function sessionRows(db, table, where, value) {
169
+ const cols = columnsOf(db, table)
170
+ if (!['id', 'directory', 'time_created', 'time_updated'].every((c) => cols.has(c))) return []
171
+ if (where === 'parent_id' && !cols.has('parent_id')) return []
172
+ return db.prepare(`SELECT id, directory, time_created, time_updated, ${pickColumns(cols, ROW_COLUMNS)} FROM ${table} WHERE ${where} = ?`).all(value)
173
+ }
174
+
175
+ function sessionRow(db, table, sessionId) {
176
+ const row = sessionRows(db, table, 'id', sessionId)[0]
177
+ return row && !row.parent_id && row.time_archived == null ? row : null
178
+ }
179
+
180
+ function addModelUsage(models, model, tokens, cost) {
181
+ if (!model) return
182
+ const entry = models.get(model) || { model, tokens: null, costUsd: null }
183
+ entry.tokens = addTokens(entry.tokens, tokens)
184
+ if (cost != null) entry.costUsd = (entry.costUsd || 0) + number(cost)
185
+ models.set(model, entry)
186
+ }
187
+
188
+ function modelList(models) {
189
+ return [...models.values()].map((m) => ({ model: m.model, tokens: m.tokens || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, costUsd: m.costUsd }))
190
+ }
191
+
192
+ function childList(rows, turnOf) {
193
+ return rows
194
+ .filter((r) => r.time_archived == null)
195
+ .map((r) => {
196
+ const turn = turnOf(r)
197
+ return {
198
+ id: r.id,
199
+ name: childName(r, turn),
200
+ type: '',
201
+ status: turn.open ? 'working' : turn.failed ? 'error' : 'done',
202
+ model: turn.model,
203
+ tokens: turn.tokens ? { ...turn.tokens } : null,
204
+ tool: turn.open && turn.tool ? toolNow(turn.tool) : null,
205
+ startedAt: number(r.time_created),
206
+ lastActivityAt: number(r.time_updated),
207
+ }
208
+ })
209
+ .sort((a, b) => a.startedAt - b.startedAt)
210
+ .slice(-DETAIL_LIMITS.subagents)
211
+ }
212
+
213
+ function legacyDetail(db, tables, row, context) {
214
+ const directory = text(row.directory)
215
+ const activity = emptyActivity()
216
+ const history = []
217
+ const prompts = []
218
+ const errors = []
219
+ const files = new Map()
220
+ const models = new Map()
221
+ let contextParts = null
222
+ const typed = new Set(
223
+ db
224
+ .prepare(`SELECT message_id FROM part WHERE session_id = ? AND data LIKE '%"type":"text"%' AND data NOT LIKE '%"synthetic":true%'`)
225
+ .all(row.id)
226
+ .map((r) => r.message_id),
227
+ )
228
+ const modelOf = new Map()
229
+ const messageAt = new Map()
230
+ const messageUsage = []
231
+ for (const message of db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, rowid').all(row.id)) {
232
+ const data = parse(message.data)
233
+ const at = number(data.time?.created) || number(message.time_created)
234
+ if (data.role === 'user') {
235
+ if (typed.has(message.id)) {
236
+ const prompt = firstText(db, message.id)
237
+ if (prompt) keepNewest(prompts, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
238
+ }
239
+ for (const diff of Array.isArray(data.summary?.diffs) ? data.summary.diffs : []) {
240
+ if (text(diff?.file)) addFileChange(files, text(diff.file), number(diff.additions), number(diff.deletions))
241
+ }
242
+ continue
243
+ }
244
+ if (data.role !== 'assistant') continue
245
+ const model = text(data.modelID)
246
+ modelOf.set(message.id, model)
247
+ messageAt.set(message.id, at)
248
+ messageUsage.push([model, data.tokens, data.cost])
249
+ if (number(data.tokens?.output) > 0) {
250
+ const output = number(data.tokens.output) + number(data.tokens.reasoning)
251
+ keepNewest(history, { at: number(data.time?.completed) || at, context: windowOf(data.tokens), output }, DETAIL_LIMITS.history)
252
+ contextParts = { input: number(data.tokens.input), cacheRead: number(data.tokens.cache?.read), cacheWrite: number(data.tokens.cache?.write), output: number(data.tokens.output) + number(data.tokens.reasoning) }
253
+ }
254
+ const error = text(data.error?.name)
255
+ if (error && !ABORTED.test(error)) {
256
+ keepNewest(errors, { at, message: clip(text(data.error?.data?.message) || error, DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
257
+ }
258
+ }
259
+
260
+ const steps = db.prepare(`SELECT message_id, data FROM part WHERE session_id = ? AND data LIKE '%"type":"step-finish"%' ORDER BY rowid`).all(row.id)
261
+ if (steps.length) {
262
+ for (const step of steps) {
263
+ const data = parse(step.data)
264
+ addModelUsage(models, modelOf.get(step.message_id), data.tokens, data.cost)
265
+ }
266
+ } else {
267
+ for (const [model, tokens, cost] of messageUsage) addModelUsage(models, model, tokens, cost)
268
+ }
269
+
270
+ for (const part of db.prepare(`SELECT id, data FROM part WHERE session_id = ? AND data LIKE '%"type":"tool"%' ORDER BY rowid`).all(row.id)) {
271
+ const data = parse(part.data)
272
+ if (data.type !== 'tool') continue
273
+ const state = data.state || {}
274
+ const callId = text(data.callID) || part.id
275
+ const call = { id: callId, name: text(data.tool), target: toolTarget(state.input, directory), input: toolInput(state.input), ...toolFile(state.input, directory) }
276
+ if (!startCall(activity, { ...call, startedAt: number(state.time?.start) })) continue
277
+ if (state.status === 'completed' || state.status === 'error') {
278
+ const failed = state.status === 'error' && !STOPPED.test(`${text(state.error)}\n${text(state.output)}`)
279
+ endCall(activity, callId, number(state.time?.end), failed, text(state.error) || text(state.output))
280
+ }
281
+ }
282
+ const thoughts = []
283
+ const replies = []
284
+ const said = db.prepare(`SELECT message_id, data FROM part WHERE session_id = ? AND (data LIKE '%"type":"text"%' OR data LIKE '%"type":"reasoning"%') ORDER BY rowid`)
285
+ for (const part of said.all(row.id)) {
286
+ if (!messageAt.has(part.message_id)) continue
287
+ const data = parse(part.data)
288
+ if (data.synthetic || data.ignored) continue
289
+ const at = number(data.time?.end) || number(data.time?.start) || messageAt.get(part.message_id)
290
+ if (data.type === 'reasoning') keepText(thoughts, 'thoughts', { id: part.message_id, at, text: text(data.text) })
291
+ else if (data.type === 'text') keepText(replies, 'replies', { id: part.message_id, at, text: text(data.text) })
292
+ }
293
+ const turnOf = (r) => cached(context, `v1:${r.id}`, number(r.time_updated), () => legacyTurn(db, r, context))
294
+ if (!turnOf(row).open) settleCalls(activity)
295
+
296
+ const todoCols = tables.has('todo') ? columnsOf(db, 'todo') : new Set()
297
+ const plan = ['session_id', 'content', 'status'].every((c) => todoCols.has(c))
298
+ ? db.prepare(`SELECT content, status FROM todo WHERE session_id = ? ORDER BY ${todoCols.has('position') ? 'position' : 'rowid'}`).all(row.id)
299
+ : []
300
+
301
+ return {
302
+ models: modelList(models),
303
+ history,
304
+ compactions: null,
305
+ contextParts,
306
+ ...activityDetail(activity),
307
+ plan: planItems(plan.map((t) => ({ content: text(t.content), status: text(t.status) }))),
308
+ subagents: childList(sessionRows(db, 'session', 'parent_id', row.id), turnOf),
309
+ files: files.size || !context.liveSummary ? fileList(files, directory) : null,
310
+ prompts,
311
+ thoughts: textList(thoughts),
312
+ replies: textList(replies),
313
+ contextBreakdown: null,
314
+ errors,
315
+ }
316
+ }
317
+
318
+ function currentDetail(db, tables, row, context) {
319
+ const directory = text(row.directory)
320
+ const activity = emptyActivity()
321
+ const history = []
322
+ const prompts = []
323
+ const errors = []
324
+ const models = new Map()
325
+ let contextParts = null
326
+ const replied = []
327
+ for (const message of db.prepare('SELECT id, type, time_created, data FROM session_message WHERE session_id = ? ORDER BY seq').all(row.id)) {
328
+ const data = parse(message.data)
329
+ const at = number(message.time_created)
330
+ if (message.type === 'user') {
331
+ const prompt = text(data.text).trim()
332
+ if (prompt) keepNewest(prompts, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
333
+ } else if (message.type === 'assistant') {
334
+ replied.push({ id: message.id, at, content: list(data.content) })
335
+ addModelUsage(models, text(data.model?.id), data.tokens, data.cost)
336
+ if (number(data.tokens?.output) > 0) {
337
+ keepNewest(history, { at, context: windowOf(data.tokens), output: number(data.tokens.output) + number(data.tokens.reasoning) }, DETAIL_LIMITS.history)
338
+ contextParts = { input: number(data.tokens.input), cacheRead: number(data.tokens.cache?.read), cacheWrite: number(data.tokens.cache?.write), output: number(data.tokens.output) + number(data.tokens.reasoning) }
339
+ }
340
+ } else if (message.type === 'idle' && text(data.outcome) === 'failed') {
341
+ keepNewest(errors, { at, message: clip(text(data.error?.message) || 'The turn failed', DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
342
+ }
343
+ }
344
+
345
+ const parts = tables.has('session_part') && ['session_id', 'message_id', 'type', 'data'].every((c) => columnsOf(db, 'session_part').has(c))
346
+ const rows = new Map()
347
+ if (parts) {
348
+ for (const part of db.prepare(`SELECT id, message_id, type, data FROM session_part WHERE session_id = ? AND type IN ('tool', 'reasoning', 'text') ORDER BY rowid`).all(row.id)) {
349
+ const data = parse(part.data)
350
+ if (!rows.has(part.message_id)) rows.set(part.message_id, [])
351
+ rows.get(part.message_id).push({ ...data, type: text(data.type) || part.type, rowId: part.id })
352
+ }
353
+ }
354
+ let plan = []
355
+ const thoughts = []
356
+ const replies = []
357
+ for (const message of replied) {
358
+ const content = rows.get(message.id) || message.content
359
+ content.forEach((data, i) => {
360
+ if (data?.type !== 'tool') return
361
+ const state = data.state || {}
362
+ const callId = text(data.callID) || text(data.id) || data.rowId || `${message.id}:${i}`
363
+ const call = { id: callId, name: text(data.name), target: toolTarget(state.input, directory), input: toolInput(state.input), ...toolFile(state.input, directory) }
364
+ if (!startCall(activity, { ...call, startedAt: number(data.time?.ran) || number(data.time?.created) || message.at })) return
365
+ if (state.status === 'completed' || state.status === 'error') {
366
+ endCall(activity, callId, number(state.time?.end) || number(data.time?.completed), state.status === 'error', text(state.error) || text(state.output))
367
+ }
368
+ if (data.name === TODO_TOOL && state.status === 'completed' && Array.isArray(state.input?.todos)) {
369
+ plan = state.input.todos.map((t) => ({ content: text(t?.content), status: text(t?.status) }))
370
+ }
371
+ })
372
+ keepText(thoughts, 'thoughts', { at: message.at, text: joinedText(content, 'reasoning', 'text') })
373
+ keepText(replies, 'replies', { at: message.at, text: joinedText(content, 'text') })
374
+ }
375
+ const turnOf = (r) => cached(context, `v2:${r.id}`, number(r.time_updated), () => currentTurn(db, r, parts, context))
376
+ if (!turnOf(row).open) settleCalls(activity)
377
+
378
+ return {
379
+ models: modelList(models),
380
+ history,
381
+ compactions: null,
382
+ contextParts,
383
+ ...activityDetail(activity),
384
+ plan: planItems(plan),
385
+ subagents: childList(sessionRows(db, 'session_v2', 'parent_id', row.id), turnOf),
386
+ files: null,
387
+ prompts,
388
+ thoughts: textList(thoughts),
389
+ replies: textList(replies),
390
+ contextBreakdown: null,
391
+ errors,
392
+ }
393
+ }
394
+
395
+ function catalogLimits(db, byModel) {
396
+ const row = db.prepare('SELECT value FROM kv WHERE key = ?').get(CATALOG_KEY)
397
+ if (!row) return
398
+ const body = parse(parse(row.value).body)
399
+ for (const provider of list(body.providers ?? body)) {
400
+ for (const model of list(provider?.models)) {
401
+ const limit = number(model?.limit?.context)
402
+ if (provider?.id && model?.id && limit) byModel.set(`${provider.id}/${model.id}`, limit)
403
+ }
404
+ }
405
+ }
406
+
407
+ function cached(context, key, updated, compute) {
408
+ context.seen.add(key)
409
+ const hit = context.facts.get(key)
410
+ let turn = hit?.value
411
+ if (!hit || hit.updated !== updated) {
412
+ turn = compute()
413
+ context.facts.set(key, { updated, value: turn })
414
+ }
415
+ const open = turn.busy && context.at - updated < ACTIVE_WINDOW_MS
416
+ return { ...turn, open, tool: open ? turn.tool : null }
417
+ }
418
+
419
+ function statusOf({ open, failed, errands, tool, lastActivityAt, read, at }) {
420
+ if (open) {
421
+ if (tool?.name === QUESTION_TOOL) return ['asking', 'question']
422
+ if (errands && (!tool || SUBAGENT_TOOLS.has(tool.name))) return ['delegating', 'subagents']
423
+ return ['working', 'turn-open']
424
+ }
425
+ if (errands) return ['delegating', 'subagents']
426
+ if (failed) return ['error', 'turn-failed']
427
+ if (read) return read.unread ? ['done', 'unread'] : ['idle', 'read']
428
+ return at - lastActivityAt < ACTIVE_WINDOW_MS ? ['done', 'recent'] : ['idle', 'quiet']
429
+ }
430
+
431
+ const childName = (row, turn) => text(row.title).trim() || (turn.firstPrompt ? titleFromPrompt(turn.firstPrompt) : '') || 'Subagent'
432
+
433
+ function workingChildren(rows, parent, turnOf, context) {
434
+ return rows
435
+ .filter((r) => r.parent_id === parent.id && context.at - number(r.time_updated) < SUBAGENT_WINDOW_MS && turnOf(r).open)
436
+ .sort((a, b) => number(a.time_created) - number(b.time_created))
437
+ .map((r) => ({ id: r.id, name: childName(r, turnOf(r)) }))
438
+ }
439
+
440
+ function session(context, row, turn, extra) {
441
+ const directory = text(row.directory)
442
+ const sessionModel = parse(row.model)
443
+ const lastActivityAt = number(row.time_updated)
444
+ const viewed = number(row.time_viewed)
445
+ const read = viewed > 0 ? { unread: number(row.time_idle) > viewed } : null
446
+ const errands = extra.working.length
447
+ const [status, statusReason] = statusOf({ ...turn, errands, lastActivityAt, read, at: context.at })
448
+ return {
449
+ id: `${context.id}:${row.id}`,
450
+ harness: context.id,
451
+ title: titleFromPrompt(text(row.title).trim() || turn.firstPrompt || '') || 'Untitled session',
452
+ project: path.basename(directory.replace(/\\/g, '/')) || 'unknown',
453
+ projectPath: directory,
454
+ worktree: '',
455
+ cwd: directory,
456
+ gitBranch: '',
457
+ model: turn.model || text(sessionModel.id),
458
+ effort: turn.effort || text(sessionModel.variant),
459
+ status,
460
+ live: turn.open,
461
+ startedAt: number(row.time_created),
462
+ lastActivityAt,
463
+ tool: turn.open && turn.tool ? toolNow(turn.tool) : null,
464
+ tokens: turn.tokens ? { ...turn.tokens } : null,
465
+ context: turn.context ? { used: turn.context, limit: context.byModel.get(`${turn.provider}/${turn.model}`) || 0 } : null,
466
+ contextHistory: sampleList(turn.contextSample, turn.context),
467
+ costUsd: turn.cost,
468
+ lines: extra.lines,
469
+ plan: planSummary(extra.plan),
470
+ subagents: errands,
471
+ workingSubagents: extra.working,
472
+ prompts: turn.prompts,
473
+ turnStartedAt: turn.open ? turn.turnStartedAt || null : null,
474
+ statusReason,
475
+ ask: askOf(status, { calls: turn.open && turn.tool ? [turn.tool] : [], errorText: turn.errorText }),
476
+ usageLimits: null,
477
+ }
478
+ }
479
+
480
+ function legacySessions(db, tables, context) {
481
+ const cols = columnsOf(db, 'session')
482
+ if (!['id', 'directory', 'time_created', 'time_updated'].every((c) => cols.has(c))) return []
483
+ const rows = db
484
+ .prepare(
485
+ `SELECT id, directory, time_created, time_updated, ${pickColumns(cols, ['title', 'parent_id', 'model', 'time_archived', 'summary_additions', 'summary_deletions', ...USAGE_COLUMNS])} FROM session WHERE time_updated > ?`,
486
+ )
487
+ .all(context.at - RECENT_MS)
488
+ const todoCols = tables.has('todo') ? columnsOf(db, 'todo') : new Set()
489
+ const todos = ['session_id', 'content', 'status'].every((c) => todoCols.has(c))
490
+ const turnOf = (row) => cached(context, `v1:${row.id}`, number(row.time_updated), () => legacyTurn(db, row, context))
491
+ const out = []
492
+ for (const row of rows) {
493
+ if (row.parent_id || row.time_archived != null) continue
494
+ const turn = turnOf(row)
495
+ const working = workingChildren(rows, row, turnOf, context)
496
+ const lines = context.liveSummary
497
+ ? row.summary_additions == null && row.summary_deletions == null
498
+ ? null
499
+ : { added: number(row.summary_additions), removed: number(row.summary_deletions) }
500
+ : turn.lines
501
+ const plan = todos
502
+ ? db
503
+ .prepare(`SELECT content, status FROM todo WHERE session_id = ? ORDER BY ${todoCols.has('position') ? 'position' : 'rowid'}`)
504
+ .all(row.id)
505
+ .map((t) => ({ content: text(t.content), status: text(t.status) }))
506
+ : null
507
+ out.push(session(context, row, turn, { working, lines, plan }))
508
+ }
509
+ return out
510
+ }
511
+
512
+ function legacyTurn(db, row, context) {
513
+ const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, contextSample: emptySample(), lines: null, tool: null, turnStartedAt: 0 }
514
+ const typed = new Set(
515
+ db
516
+ .prepare(`SELECT message_id FROM part WHERE session_id = ? AND data LIKE '%"type":"text"%' AND data NOT LIKE '%"synthetic":true%'`)
517
+ .all(row.id)
518
+ .map((r) => r.message_id),
519
+ )
520
+ let last = null
521
+ let lastAssistant = ''
522
+ const changed = new Set()
523
+ for (const message of db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, rowid').all(row.id)) {
524
+ const data = parse(message.data)
525
+ last = data
526
+ if (data.role === 'user') {
527
+ turn.turnStartedAt = number(data.time?.created) || number(message.time_created)
528
+ if (typed.has(message.id)) {
529
+ turn.prompts++
530
+ if (!turn.firstPrompt) turn.firstPrompt = firstText(db, message.id)
531
+ }
532
+ if (Array.isArray(data.summary?.diffs)) {
533
+ turn.lines ||= { added: 0, removed: 0 }
534
+ for (const diff of data.summary.diffs) {
535
+ turn.lines.added += number(diff?.additions)
536
+ turn.lines.removed += number(diff?.deletions)
537
+ if (text(diff?.file)) changed.add(relativePath(text(diff.file), text(row.directory)))
538
+ }
539
+ }
540
+ continue
541
+ }
542
+ if (data.role !== 'assistant') continue
543
+ lastAssistant = message.id
544
+ if (data.modelID) turn.model = text(data.modelID)
545
+ if (data.providerID) turn.provider = text(data.providerID)
546
+ if (data.variant) turn.effort = text(data.variant)
547
+ if (data.cost != null) turn.cost = (turn.cost || 0) + number(data.cost)
548
+ if (number(data.tokens?.output) > 0) {
549
+ turn.context = windowOf(data.tokens)
550
+ samplePoint(turn.contextSample, turn.context)
551
+ }
552
+ }
553
+ turn.files = changed.size || !context.liveSummary ? [...changed].map((file) => ({ path: file })) : null
554
+ const usage = usageFromColumns(row)
555
+ if (usage) {
556
+ turn.tokens = usage.tokens
557
+ if (usage.cost != null) turn.cost = usage.cost
558
+ } else {
559
+ for (const part of db.prepare(`SELECT data FROM part WHERE session_id = ? AND data LIKE '%"type":"step-finish"%'`).all(row.id)) {
560
+ turn.tokens = addTokens(turn.tokens, parse(part.data).tokens)
561
+ }
562
+ }
563
+ const error = text(last?.error?.name)
564
+ const finish = text(last?.finish)
565
+ const ended = Boolean(last?.time?.completed) || Boolean(finish)
566
+ turn.busy = last?.role === 'user' || (last?.role === 'assistant' && !error && (!ended || context.continuing.has(finish)))
567
+ const failedTool = last?.role === 'assistant' && !error && ended ? toolFailure(db, lastAssistant) : ''
568
+ turn.failed = last?.role === 'assistant' && (error ? !ABORTED.test(error) : Boolean(failedTool))
569
+ turn.errorText = turn.failed ? text(last?.error?.data?.message) || error || failedTool : ''
570
+ if (turn.busy) turn.tool = runningTool(db, lastAssistant, text(row.directory))
571
+ return turn
572
+ }
573
+
574
+ function firstText(db, messageId) {
575
+ for (const part of db.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY rowid LIMIT 8').all(messageId)) {
576
+ const data = parse(part.data)
577
+ if (data.type === 'text' && !data.synthetic && text(data.text).trim()) return text(data.text).trim()
578
+ }
579
+ return ''
580
+ }
581
+
582
+ function toolFailure(db, messageId) {
583
+ for (const part of db.prepare(`SELECT data FROM part WHERE message_id = ? AND data LIKE '%"status":"error"%'`).all(messageId)) {
584
+ const state = parse(part.data).state || {}
585
+ if (!STOPPED.test(`${text(state.error)}\n${text(state.output)}`)) return text(state.error) || text(state.output) || 'A tool failed'
586
+ }
587
+ return ''
588
+ }
589
+
590
+ function runningTool(db, messageId, cwd) {
591
+ if (!messageId) return null
592
+ let tool = null
593
+ for (const part of db.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY rowid').all(messageId)) {
594
+ const data = parse(part.data)
595
+ if (data.type !== 'tool' || !OPEN_TOOL.has(data.state?.status)) continue
596
+ tool = openTool(text(data.tool), number(data.state?.time?.start), data.state?.input, cwd)
597
+ }
598
+ return tool
599
+ }
600
+
601
+ function currentSessions(db, tables, context) {
602
+ const cols = columnsOf(db, 'session_v2')
603
+ if (!['id', 'directory', 'time_created', 'time_updated'].every((c) => cols.has(c))) return []
604
+ const rows = db
605
+ .prepare(
606
+ `SELECT id, directory, time_created, time_updated, ${pickColumns(cols, ['title', 'parent_id', 'model', 'time_archived', 'time_idle', 'time_viewed', 'time_busy', 'idle_outcome', ...USAGE_COLUMNS])} FROM session_v2 WHERE time_updated > ?`,
607
+ )
608
+ .all(context.at - RECENT_MS)
609
+ const parts = tables.has('session_part') && ['session_id', 'message_id', 'type', 'data'].every((c) => columnsOf(db, 'session_part').has(c))
610
+ const turnOf = (row) => cached(context, `v2:${row.id}`, number(row.time_updated), () => currentTurn(db, row, parts, context))
611
+ const out = []
612
+ for (const row of rows) {
613
+ if (row.parent_id || row.time_archived != null) continue
614
+ const turn = turnOf(row)
615
+ out.push(session(context, row, turn, { working: workingChildren(rows, row, turnOf, context), lines: null, plan: turn.plan }))
616
+ }
617
+ return out
618
+ }
619
+
620
+ function currentSample(db, sessionId, before) {
621
+ let { contextSample: sample, contextSeq: seq = -1, contextCount: count = 0 } = before || {}
622
+ const kept = sample && db.prepare(`SELECT COUNT(*) AS n, MAX(seq) AS top FROM session_message WHERE session_id = ? AND type = 'assistant' AND seq <= ?`).get(sessionId, seq)
623
+ if (!kept || number(kept.n) !== count || (count && number(kept.top) !== seq)) {
624
+ sample = emptySample()
625
+ seq = -1
626
+ count = 0
627
+ }
628
+ const newer = db.prepare(`SELECT seq, data FROM session_message WHERE session_id = ? AND type = 'assistant' AND seq > ? ORDER BY seq`).all(sessionId, seq)
629
+ for (const [i, message] of newer.entries()) {
630
+ const data = parse(message.data)
631
+ if (i === newer.length - 1 && !data.time?.completed) break
632
+ if (number(data.tokens?.output) > 0) samplePoint(sample, windowOf(data.tokens))
633
+ seq = number(message.seq)
634
+ count++
635
+ }
636
+ return { contextSample: sample, contextSeq: seq, contextCount: count }
637
+ }
638
+
639
+ function currentTurn(db, row, parts, context) {
640
+ const before = context.facts.get(`v2:${row.id}`)?.value
641
+ const sampled = currentSample(db, row.id, before)
642
+ const kept = sampled.contextSample === before?.contextSample ? before : null
643
+ const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, tool: null, plan: null, turnStartedAt: 0, ...sampled }
644
+ turn.prompts = number(db.prepare(`SELECT COUNT(*) AS n FROM session_message WHERE session_id = ? AND type = 'user'`).get(row.id)?.n)
645
+ const lastUser = db.prepare(`SELECT time_created FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq DESC LIMIT 1`).get(row.id)
646
+ turn.turnStartedAt = number(row.time_busy) || number(lastUser?.time_created)
647
+ turn.firstPrompt = text(parse(db.prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq ASC LIMIT 1`).get(row.id)?.data).text).trim()
648
+ const assistant = db.prepare(`SELECT id, seq, data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq DESC LIMIT 1`).get(row.id)
649
+ const latest = parse(assistant?.data)
650
+ turn.model = text(latest.model?.id)
651
+ turn.provider = text(latest.model?.providerID)
652
+ turn.effort = text(latest.model?.variant)
653
+ const withTokens = db
654
+ .prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' AND data LIKE '%"tokens"%' ORDER BY seq DESC LIMIT 1`)
655
+ .get(row.id)
656
+ turn.context = windowOf(parse(withTokens?.data).tokens)
657
+ const usage = usageFromColumns(row)
658
+ if (usage) {
659
+ turn.tokens = usage.tokens
660
+ turn.cost = usage.cost
661
+ }
662
+ const last = db
663
+ .prepare(`SELECT type, data FROM session_message WHERE session_id = ? AND type IN (${TURN_MESSAGES.map(() => '?').join(', ')}) ORDER BY seq DESC LIMIT 1`)
664
+ .get(row.id, ...TURN_MESSAGES)
665
+ const busy = row.time_busy != null ? number(row.time_busy) > number(row.time_idle) : Boolean(last) && last.type !== 'idle'
666
+ turn.busy = busy
667
+ const outcome = text(row.idle_outcome) || (last?.type === 'idle' ? text(parse(last.data).outcome) : '')
668
+ turn.failed = !busy && outcome === 'failed'
669
+ turn.errorText = turn.failed && last?.type === 'idle' ? text(parse(last.data).error?.message) : ''
670
+ const tools = currentTools(db, row.id, assistant, parts, text(row.directory), kept)
671
+ turn.planSeq = tools.planSeq
672
+ turn.olderTodos = tools.olderTodos
673
+ turn.planRow = tools.planRow
674
+ turn.rowTodos = tools.rowTodos
675
+ if (busy) turn.tool = tools.running
676
+ turn.plan = tools.todos
677
+ return turn
678
+ }
679
+
680
+ const completedTodos = (parts) => {
681
+ const found = parts.find((p) => p?.name === TODO_TOOL && p.state?.status === 'completed' && Array.isArray(p.state?.input?.todos))
682
+ return found ? found.state.input.todos.map((t) => ({ content: text(t?.content), status: text(t?.status) })) : null
683
+ }
684
+
685
+ function rowTodos(db, sessionId, assistant, before) {
686
+ const newest = text(assistant?.id)
687
+ const from = before?.planRow ?? -1
688
+ let older = before?.rowTodos ?? null
689
+ const rows = db
690
+ .prepare(`SELECT data FROM session_part WHERE session_id = ? AND type = 'tool' AND rowid > ? AND message_id != ? AND data LIKE ? ORDER BY rowid DESC`)
691
+ .all(sessionId, from, newest, `%"${TODO_TOOL}"%`)
692
+ for (const row of rows) {
693
+ const found = completedTodos([parse(row.data)])
694
+ if (found) {
695
+ older = found
696
+ break
697
+ }
698
+ }
699
+ const top = db.prepare('SELECT MAX(rowid) AS row FROM session_part WHERE session_id = ? AND message_id != ?').get(sessionId, newest)
700
+ return { rowTodos: older, planRow: top?.row == null ? from : number(top.row) }
701
+ }
702
+
703
+ function inlineTodos(db, sessionId, assistant, before) {
704
+ const newest = number(assistant?.seq)
705
+ let older = before?.olderTodos ?? null
706
+ const rows = db
707
+ .prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' AND seq > ? AND seq < ? AND data LIKE ? ORDER BY seq DESC`)
708
+ .all(sessionId, before?.planSeq ?? -1, newest, `%"${TODO_TOOL}"%`)
709
+ for (const row of rows) {
710
+ const found = completedTodos(list(parse(row.data).content).filter((c) => c?.type === 'tool').reverse())
711
+ if (found) {
712
+ older = found
713
+ break
714
+ }
715
+ }
716
+ return { olderTodos: older, planSeq: assistant ? newest - 1 : before?.planSeq ?? -1 }
717
+ }
718
+
719
+ function currentTools(db, sessionId, assistant, parts, cwd, before = null) {
720
+ const result = { running: null, todos: null }
721
+ let latest = []
722
+ if (parts && assistant) {
723
+ latest = db.prepare(`SELECT data FROM session_part WHERE session_id = ? AND message_id = ? AND type = 'tool' ORDER BY rowid`).all(sessionId, assistant.id).map((p) => parse(p.data))
724
+ }
725
+ if (!latest.length && assistant) latest = list(parse(assistant.data).content).filter((c) => c?.type === 'tool')
726
+ for (const part of latest) {
727
+ if (OPEN_TOOL.has(part.state?.status)) result.running = openTool(text(part.name), number(part.time?.ran) || number(part.time?.created), part.state?.input, cwd)
728
+ }
729
+ const rows = parts ? rowTodos(db, sessionId, assistant, before) : { rowTodos: null, planRow: -1 }
730
+ const inline = inlineTodos(db, sessionId, assistant, before)
731
+ Object.assign(result, rows, inline)
732
+ result.todos = completedTodos([...latest].reverse()) ?? rows.rowTodos ?? inline.olderTodos
733
+ return result
734
+ }