@threadcrumbs/common 0.1.7 → 0.1.9
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.
- package/harnesses/claude-code.mjs +578 -578
- package/harnesses/hermes.mjs +2 -1
- package/lib/claude-transcript.mjs +16 -0
- package/lib/opencode-store.mjs +734 -734
- package/package.json +7 -2
- package/telemetry/README.md +290 -290
- package/telemetry/claude-code.mjs +5 -2
- package/telemetry/codex.mjs +700 -700
- package/telemetry/hermes.mjs +441 -441
- package/ui/rows.css +5 -0
package/lib/opencode-store.mjs
CHANGED
|
@@ -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() ||
|
|
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
|
+
}
|