@threadcrumbs/common 0.0.0-stage → 0.1.1
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/LICENSE.md +34 -0
- package/NOTICES.md +33 -0
- package/README.md +109 -3
- package/harnesses/README.md +213 -0
- package/harnesses/antigravity.mjs +269 -0
- package/harnesses/claude-code.mjs +578 -0
- package/harnesses/codex.mjs +419 -0
- package/harnesses/cursor.mjs +199 -0
- package/harnesses/hermes.mjs +211 -0
- package/harnesses/index.mjs +36 -0
- package/harnesses/kilocode.mjs +361 -0
- package/harnesses/opencode.mjs +449 -0
- package/launch/index.mjs +163 -0
- package/launch/macos.mjs +21 -0
- package/launch/registry.mjs +52 -0
- package/launch/terminal.mjs +213 -0
- package/launch/win-terminal.mjs +87 -0
- package/launch/windows.mjs +96 -0
- package/launch/xdg.mjs +44 -0
- package/lib/anthropic-prices.mjs +70 -0
- package/lib/claude-desktop.mjs +208 -0
- package/lib/claude-transcript.mjs +462 -0
- package/lib/cursor-folder.mjs +107 -0
- package/lib/fsutil.mjs +191 -0
- package/lib/hermes-home.mjs +30 -0
- package/lib/jsonl-follower.mjs +45 -0
- package/lib/opencode-store.mjs +602 -0
- package/lib/sqlite.mjs +29 -0
- package/lib/telemetry-fields.mjs +245 -0
- package/license/index.mjs +102 -0
- package/license/store.mjs +155 -0
- package/orgs/index.mjs +157 -0
- package/package.json +40 -4
- package/telemetry/README.md +148 -0
- package/telemetry/antigravity.mjs +268 -0
- package/telemetry/claude-code.mjs +370 -0
- package/telemetry/codex.mjs +556 -0
- package/telemetry/cursor.mjs +276 -0
- package/telemetry/hermes.mjs +404 -0
- package/telemetry/index.mjs +9 -0
- package/telemetry/kilocode.mjs +31 -0
- package/telemetry/opencode.mjs +36 -0
|
@@ -0,0 +1,602 @@
|
|
|
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
|
+
clip,
|
|
10
|
+
clipBlock,
|
|
11
|
+
emptyActivity,
|
|
12
|
+
endCall,
|
|
13
|
+
fileList,
|
|
14
|
+
keepNewest,
|
|
15
|
+
number,
|
|
16
|
+
planItems,
|
|
17
|
+
planSummary,
|
|
18
|
+
settleCalls,
|
|
19
|
+
startCall,
|
|
20
|
+
text,
|
|
21
|
+
titleFromPrompt,
|
|
22
|
+
toolFile,
|
|
23
|
+
toolInput,
|
|
24
|
+
toolTarget,
|
|
25
|
+
} from './telemetry-fields.mjs'
|
|
26
|
+
|
|
27
|
+
const ACTIVE_WINDOW_MS = 30 * 60 * 1000
|
|
28
|
+
const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
|
|
29
|
+
const OPEN_TOOL = new Set(['pending', 'streaming', 'running'])
|
|
30
|
+
const ABORTED = /abort/i
|
|
31
|
+
const STOPPED = /user rejected permission|permission.{0,20}denied|denied.{0,20}permission|aborted|cancelled/i
|
|
32
|
+
const QUESTION_TOOL = 'question'
|
|
33
|
+
const SUBAGENT_TOOLS = new Set(['task', 'subagent'])
|
|
34
|
+
const TODO_TOOL = 'todowrite'
|
|
35
|
+
const CATALOG_KEY = 'models-dev:v2:catalog'
|
|
36
|
+
const TURN_MESSAGES = ['user', 'assistant', 'shell', 'synthetic', 'compaction', 'idle']
|
|
37
|
+
const USAGE_COLUMNS = ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write']
|
|
38
|
+
|
|
39
|
+
function parse(raw) {
|
|
40
|
+
try {
|
|
41
|
+
const value = typeof raw === 'string' ? JSON.parse(raw) : raw
|
|
42
|
+
return value && typeof value === 'object' ? value : {}
|
|
43
|
+
} catch {
|
|
44
|
+
return {}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const list = (value) => (Array.isArray(value) ? value : Object.values(value || {}))
|
|
49
|
+
|
|
50
|
+
function addTokens(total, t) {
|
|
51
|
+
if (!t) return total
|
|
52
|
+
const next = total || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
53
|
+
next.input += number(t.input)
|
|
54
|
+
next.output += number(t.output) + number(t.reasoning)
|
|
55
|
+
next.cacheRead += number(t.cache?.read)
|
|
56
|
+
next.cacheWrite += number(t.cache?.write)
|
|
57
|
+
return next
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const windowOf = (t) => number(t?.input) + number(t?.output) + number(t?.reasoning) + number(t?.cache?.read) + number(t?.cache?.write)
|
|
61
|
+
|
|
62
|
+
function usageFromColumns(row) {
|
|
63
|
+
if (row.tokens_input == null && row.cost == null) return null
|
|
64
|
+
return {
|
|
65
|
+
tokens: {
|
|
66
|
+
input: number(row.tokens_input),
|
|
67
|
+
output: number(row.tokens_output) + number(row.tokens_reasoning),
|
|
68
|
+
cacheRead: number(row.tokens_cache_read),
|
|
69
|
+
cacheWrite: number(row.tokens_cache_write),
|
|
70
|
+
},
|
|
71
|
+
cost: row.cost == null ? null : number(row.cost),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const pickColumns = (cols, names) => names.map((c) => (cols.has(c) ? c : `NULL AS ${c}`)).join(', ')
|
|
76
|
+
|
|
77
|
+
export function opencodeStore({ id, name, dbPath, watchDirs, limitsFile, continuing = ['tool-calls'], liveSummary = false, now = Date.now }) {
|
|
78
|
+
const facts = new Map()
|
|
79
|
+
const options = { id, continuing: new Set(continuing), liveSummary }
|
|
80
|
+
let limits = { file: '', mtime: -1, byModel: new Map() }
|
|
81
|
+
|
|
82
|
+
async function fileLimits() {
|
|
83
|
+
const file = await limitsFile()
|
|
84
|
+
const stat = file ? await fsp.stat(file).catch(() => null) : null
|
|
85
|
+
if (!stat) return new Map()
|
|
86
|
+
if (limits.file === file && limits.mtime === stat.mtimeMs) return limits.byModel
|
|
87
|
+
const byModel = new Map()
|
|
88
|
+
for (const [providerId, provider] of Object.entries(parse(await fsp.readFile(file, 'utf8').catch(() => '{}')))) {
|
|
89
|
+
for (const [modelId, model] of Object.entries(provider?.models || {})) {
|
|
90
|
+
const limit = number(model?.limit?.context)
|
|
91
|
+
if (limit) byModel.set(`${providerId}/${modelId}`, limit)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
limits = { file, mtime: stat.mtimeMs, byModel }
|
|
95
|
+
return byModel
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function scanSessions() {
|
|
99
|
+
const file = await dbPath()
|
|
100
|
+
if (!file) return []
|
|
101
|
+
const context = { ...options, facts, seen: new Set(), byModel: new Map(await fileLimits()), at: now() }
|
|
102
|
+
const sessions = await withReadOnly(
|
|
103
|
+
file,
|
|
104
|
+
(db) => {
|
|
105
|
+
const tables = tablesOf(db)
|
|
106
|
+
const byId = new Map()
|
|
107
|
+
const keep = (s) => {
|
|
108
|
+
const existing = byId.get(s.id)
|
|
109
|
+
if (!existing || s.lastActivityAt >= existing.lastActivityAt) byId.set(s.id, s)
|
|
110
|
+
}
|
|
111
|
+
if (['session', 'message', 'part'].every((t) => tables.has(t))) legacySessions(db, tables, context).forEach(keep)
|
|
112
|
+
if (tables.has('session_v2') && tables.has('session_message')) {
|
|
113
|
+
if (tables.has('kv')) catalogLimits(db, context.byModel)
|
|
114
|
+
currentSessions(db, tables, context).forEach(keep)
|
|
115
|
+
}
|
|
116
|
+
return [...byId.values()]
|
|
117
|
+
},
|
|
118
|
+
[],
|
|
119
|
+
)
|
|
120
|
+
for (const key of facts.keys()) if (!context.seen.has(key)) facts.delete(key)
|
|
121
|
+
return sessions
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function sessionDetail(key) {
|
|
125
|
+
const prefix = `${id}:`
|
|
126
|
+
const sessionId = String(key).startsWith(prefix) ? String(key).slice(prefix.length) : ''
|
|
127
|
+
const file = sessionId ? await dbPath() : ''
|
|
128
|
+
if (!file) return null
|
|
129
|
+
const context = { ...options, facts, seen: new Set(), byModel: new Map(), at: now() }
|
|
130
|
+
return withReadOnly(
|
|
131
|
+
file,
|
|
132
|
+
(db) => {
|
|
133
|
+
const tables = tablesOf(db)
|
|
134
|
+
const legacy = ['session', 'message', 'part'].every((t) => tables.has(t)) ? sessionRow(db, 'session', sessionId) : null
|
|
135
|
+
const current = tables.has('session_v2') && tables.has('session_message') ? sessionRow(db, 'session_v2', sessionId) : null
|
|
136
|
+
if (!legacy && !current) return null
|
|
137
|
+
const useCurrent = current && (!legacy || number(current.time_updated) >= number(legacy.time_updated))
|
|
138
|
+
const detail = useCurrent ? currentDetail(db, tables, current, context) : legacyDetail(db, tables, legacy, context)
|
|
139
|
+
return { id: `${id}:${sessionId}`, sessionId, source: file, ...detail }
|
|
140
|
+
},
|
|
141
|
+
null,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
id,
|
|
147
|
+
name,
|
|
148
|
+
detect: async () => Boolean(await dbPath()),
|
|
149
|
+
watchRoots: () => watchDirs,
|
|
150
|
+
scanSessions,
|
|
151
|
+
sessionDetail,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ROW_COLUMNS = ['title', 'parent_id', 'model', 'time_archived', 'time_idle', 'time_busy', 'idle_outcome', ...USAGE_COLUMNS]
|
|
156
|
+
|
|
157
|
+
function sessionRows(db, table, where, value) {
|
|
158
|
+
const cols = columnsOf(db, table)
|
|
159
|
+
if (!['id', 'directory', 'time_created', 'time_updated'].every((c) => cols.has(c))) return []
|
|
160
|
+
if (where === 'parent_id' && !cols.has('parent_id')) return []
|
|
161
|
+
return db.prepare(`SELECT id, directory, time_created, time_updated, ${pickColumns(cols, ROW_COLUMNS)} FROM ${table} WHERE ${where} = ?`).all(value)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sessionRow(db, table, sessionId) {
|
|
165
|
+
const row = sessionRows(db, table, 'id', sessionId)[0]
|
|
166
|
+
return row && !row.parent_id && row.time_archived == null ? row : null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function addModelUsage(models, model, tokens, cost) {
|
|
170
|
+
if (!model) return
|
|
171
|
+
const entry = models.get(model) || { model, tokens: null, costUsd: null }
|
|
172
|
+
entry.tokens = addTokens(entry.tokens, tokens)
|
|
173
|
+
if (cost != null) entry.costUsd = (entry.costUsd || 0) + number(cost)
|
|
174
|
+
models.set(model, entry)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function modelList(models) {
|
|
178
|
+
return [...models.values()].map((m) => ({ model: m.model, tokens: m.tokens || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, costUsd: m.costUsd }))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function childList(rows, turnOf) {
|
|
182
|
+
return rows
|
|
183
|
+
.filter((r) => r.time_archived == null)
|
|
184
|
+
.map((r) => {
|
|
185
|
+
const turn = turnOf(r)
|
|
186
|
+
return {
|
|
187
|
+
id: r.id,
|
|
188
|
+
name: text(r.title).trim() || (turn.firstPrompt ? titleFromPrompt(turn.firstPrompt) : '') || 'Subagent',
|
|
189
|
+
type: '',
|
|
190
|
+
status: turn.open ? 'working' : turn.failed ? 'error' : 'done',
|
|
191
|
+
model: turn.model,
|
|
192
|
+
tokens: turn.tokens ? { ...turn.tokens } : null,
|
|
193
|
+
tool: turn.open && turn.tool ? { ...turn.tool } : null,
|
|
194
|
+
startedAt: number(r.time_created),
|
|
195
|
+
lastActivityAt: number(r.time_updated),
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
.sort((a, b) => a.startedAt - b.startedAt)
|
|
199
|
+
.slice(-DETAIL_LIMITS.subagents)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function legacyDetail(db, tables, row, context) {
|
|
203
|
+
const directory = text(row.directory)
|
|
204
|
+
const activity = emptyActivity()
|
|
205
|
+
const history = []
|
|
206
|
+
const prompts = []
|
|
207
|
+
const errors = []
|
|
208
|
+
const files = new Map()
|
|
209
|
+
const models = new Map()
|
|
210
|
+
let contextParts = null
|
|
211
|
+
const typed = new Set(
|
|
212
|
+
db
|
|
213
|
+
.prepare(`SELECT message_id FROM part WHERE session_id = ? AND data LIKE '%"type":"text"%' AND data NOT LIKE '%"synthetic":true%'`)
|
|
214
|
+
.all(row.id)
|
|
215
|
+
.map((r) => r.message_id),
|
|
216
|
+
)
|
|
217
|
+
const modelOf = new Map()
|
|
218
|
+
const messageUsage = []
|
|
219
|
+
for (const message of db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, rowid').all(row.id)) {
|
|
220
|
+
const data = parse(message.data)
|
|
221
|
+
const at = number(data.time?.created) || number(message.time_created)
|
|
222
|
+
if (data.role === 'user') {
|
|
223
|
+
if (typed.has(message.id)) {
|
|
224
|
+
const prompt = firstText(db, message.id)
|
|
225
|
+
if (prompt) keepNewest(prompts, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
|
|
226
|
+
}
|
|
227
|
+
for (const diff of Array.isArray(data.summary?.diffs) ? data.summary.diffs : []) {
|
|
228
|
+
if (text(diff?.file)) addFileChange(files, text(diff.file), number(diff.additions), number(diff.deletions))
|
|
229
|
+
}
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
if (data.role !== 'assistant') continue
|
|
233
|
+
const model = text(data.modelID)
|
|
234
|
+
modelOf.set(message.id, model)
|
|
235
|
+
messageUsage.push([model, data.tokens, data.cost])
|
|
236
|
+
if (number(data.tokens?.output) > 0) {
|
|
237
|
+
const output = number(data.tokens.output) + number(data.tokens.reasoning)
|
|
238
|
+
keepNewest(history, { at: number(data.time?.completed) || at, context: windowOf(data.tokens), output }, DETAIL_LIMITS.history)
|
|
239
|
+
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) }
|
|
240
|
+
}
|
|
241
|
+
const error = text(data.error?.name)
|
|
242
|
+
if (error && !ABORTED.test(error)) {
|
|
243
|
+
keepNewest(errors, { at, message: clip(text(data.error?.data?.message) || error, DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
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)
|
|
248
|
+
if (steps.length) {
|
|
249
|
+
for (const step of steps) {
|
|
250
|
+
const data = parse(step.data)
|
|
251
|
+
addModelUsage(models, modelOf.get(step.message_id), data.tokens, data.cost)
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
for (const [model, tokens, cost] of messageUsage) addModelUsage(models, model, tokens, cost)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
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)) {
|
|
258
|
+
const data = parse(part.data)
|
|
259
|
+
if (data.type !== 'tool') continue
|
|
260
|
+
const state = data.state || {}
|
|
261
|
+
const callId = text(data.callID) || part.id
|
|
262
|
+
const call = { id: callId, name: text(data.tool), target: toolTarget(state.input, directory), input: toolInput(state.input), ...toolFile(state.input, directory) }
|
|
263
|
+
if (!startCall(activity, { ...call, startedAt: number(state.time?.start) })) continue
|
|
264
|
+
if (state.status === 'completed' || state.status === 'error') {
|
|
265
|
+
const failed = state.status === 'error' && !STOPPED.test(`${text(state.error)}\n${text(state.output)}`)
|
|
266
|
+
endCall(activity, callId, number(state.time?.end), failed, text(state.error) || text(state.output))
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const turnOf = (r) => cached(context, `v1:${r.id}`, number(r.time_updated), () => legacyTurn(db, r, context))
|
|
270
|
+
if (!turnOf(row).open) settleCalls(activity)
|
|
271
|
+
|
|
272
|
+
const todoCols = tables.has('todo') ? columnsOf(db, 'todo') : new Set()
|
|
273
|
+
const plan = ['session_id', 'content', 'status'].every((c) => todoCols.has(c))
|
|
274
|
+
? db.prepare(`SELECT content, status FROM todo WHERE session_id = ? ORDER BY ${todoCols.has('position') ? 'position' : 'rowid'}`).all(row.id)
|
|
275
|
+
: []
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
models: modelList(models),
|
|
279
|
+
history,
|
|
280
|
+
compactions: null,
|
|
281
|
+
contextParts,
|
|
282
|
+
...activityDetail(activity),
|
|
283
|
+
plan: planItems(plan.map((t) => ({ content: text(t.content), status: text(t.status) }))),
|
|
284
|
+
subagents: childList(sessionRows(db, 'session', 'parent_id', row.id), turnOf),
|
|
285
|
+
files: files.size || !context.liveSummary ? fileList(files, directory) : null,
|
|
286
|
+
prompts,
|
|
287
|
+
errors,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function currentDetail(db, tables, row, context) {
|
|
292
|
+
const directory = text(row.directory)
|
|
293
|
+
const activity = emptyActivity()
|
|
294
|
+
const history = []
|
|
295
|
+
const prompts = []
|
|
296
|
+
const errors = []
|
|
297
|
+
const models = new Map()
|
|
298
|
+
let contextParts = null
|
|
299
|
+
for (const message of db.prepare('SELECT type, time_created, data FROM session_message WHERE session_id = ? ORDER BY seq').all(row.id)) {
|
|
300
|
+
const data = parse(message.data)
|
|
301
|
+
const at = number(message.time_created)
|
|
302
|
+
if (message.type === 'user') {
|
|
303
|
+
const prompt = text(data.text).trim()
|
|
304
|
+
if (prompt) keepNewest(prompts, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
|
|
305
|
+
} else if (message.type === 'assistant') {
|
|
306
|
+
addModelUsage(models, text(data.model?.id), data.tokens, data.cost)
|
|
307
|
+
if (number(data.tokens?.output) > 0) {
|
|
308
|
+
keepNewest(history, { at, context: windowOf(data.tokens), output: number(data.tokens.output) + number(data.tokens.reasoning) }, DETAIL_LIMITS.history)
|
|
309
|
+
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) }
|
|
310
|
+
}
|
|
311
|
+
} else if (message.type === 'idle' && text(data.outcome) === 'failed') {
|
|
312
|
+
keepNewest(errors, { at, message: clip(text(data.error?.message) || 'The turn failed', DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const parts = tables.has('session_part') && ['session_id', 'message_id', 'type', 'data'].every((c) => columnsOf(db, 'session_part').has(c))
|
|
317
|
+
let plan = []
|
|
318
|
+
if (parts) {
|
|
319
|
+
for (const part of db.prepare(`SELECT id, data FROM session_part WHERE session_id = ? AND type = 'tool' ORDER BY rowid`).all(row.id)) {
|
|
320
|
+
const data = parse(part.data)
|
|
321
|
+
const state = data.state || {}
|
|
322
|
+
const callId = text(data.callID) || text(data.id) || part.id
|
|
323
|
+
const call = { id: callId, name: text(data.name), target: toolTarget(state.input, directory), input: toolInput(state.input), ...toolFile(state.input, directory) }
|
|
324
|
+
if (!startCall(activity, { ...call, startedAt: number(data.time?.ran) || number(data.time?.created) })) continue
|
|
325
|
+
if (state.status === 'completed' || state.status === 'error') {
|
|
326
|
+
endCall(activity, callId, number(state.time?.end) || number(data.time?.completed), state.status === 'error', text(state.error) || text(state.output))
|
|
327
|
+
}
|
|
328
|
+
if (data.name === TODO_TOOL && state.status === 'completed' && Array.isArray(state.input?.todos)) {
|
|
329
|
+
plan = state.input.todos.map((t) => ({ content: text(t?.content), status: text(t?.status) }))
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const turnOf = (r) => cached(context, `v2:${r.id}`, number(r.time_updated), () => currentTurn(db, r, parts, context))
|
|
334
|
+
if (!turnOf(row).open) settleCalls(activity)
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
models: modelList(models),
|
|
338
|
+
history,
|
|
339
|
+
compactions: null,
|
|
340
|
+
contextParts,
|
|
341
|
+
...activityDetail(activity),
|
|
342
|
+
plan: planItems(plan),
|
|
343
|
+
subagents: childList(sessionRows(db, 'session_v2', 'parent_id', row.id), turnOf),
|
|
344
|
+
files: null,
|
|
345
|
+
prompts,
|
|
346
|
+
errors,
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function catalogLimits(db, byModel) {
|
|
351
|
+
const row = db.prepare('SELECT value FROM kv WHERE key = ?').get(CATALOG_KEY)
|
|
352
|
+
if (!row) return
|
|
353
|
+
const body = parse(parse(row.value).body)
|
|
354
|
+
for (const provider of list(body.providers ?? body)) {
|
|
355
|
+
for (const model of list(provider?.models)) {
|
|
356
|
+
const limit = number(model?.limit?.context)
|
|
357
|
+
if (provider?.id && model?.id && limit) byModel.set(`${provider.id}/${model.id}`, limit)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function cached(context, key, updated, compute) {
|
|
363
|
+
context.seen.add(key)
|
|
364
|
+
const hit = context.facts.get(key)
|
|
365
|
+
let turn = hit?.value
|
|
366
|
+
if (!hit || hit.updated !== updated) {
|
|
367
|
+
turn = compute()
|
|
368
|
+
context.facts.set(key, { updated, value: turn })
|
|
369
|
+
}
|
|
370
|
+
const open = turn.busy && context.at - updated < ACTIVE_WINDOW_MS
|
|
371
|
+
return { ...turn, open, tool: open ? turn.tool : null }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function statusOf({ open, failed, errands, tool, lastActivityAt, read, at }) {
|
|
375
|
+
if (open) {
|
|
376
|
+
if (tool?.name === QUESTION_TOOL) return 'asking'
|
|
377
|
+
if (errands && (!tool || SUBAGENT_TOOLS.has(tool.name))) return 'delegating'
|
|
378
|
+
return 'working'
|
|
379
|
+
}
|
|
380
|
+
if (errands) return 'delegating'
|
|
381
|
+
if (failed) return 'error'
|
|
382
|
+
if (read) return read.unread ? 'done' : 'idle'
|
|
383
|
+
return at - lastActivityAt < ACTIVE_WINDOW_MS ? 'done' : 'idle'
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function session(context, row, turn, extra) {
|
|
387
|
+
const directory = text(row.directory)
|
|
388
|
+
const sessionModel = parse(row.model)
|
|
389
|
+
const lastActivityAt = number(row.time_updated)
|
|
390
|
+
const viewed = number(row.time_viewed)
|
|
391
|
+
const read = viewed > 0 ? { unread: number(row.time_idle) > viewed } : null
|
|
392
|
+
const status = statusOf({ ...turn, errands: extra.errands, lastActivityAt, read, at: context.at })
|
|
393
|
+
return {
|
|
394
|
+
id: `${context.id}:${row.id}`,
|
|
395
|
+
harness: context.id,
|
|
396
|
+
title: text(row.title).trim() || (turn.firstPrompt ? titleFromPrompt(turn.firstPrompt) : '') || 'Untitled session',
|
|
397
|
+
project: path.basename(directory.replace(/\\/g, '/')) || 'unknown',
|
|
398
|
+
projectPath: directory,
|
|
399
|
+
worktree: '',
|
|
400
|
+
cwd: directory,
|
|
401
|
+
gitBranch: '',
|
|
402
|
+
model: turn.model || text(sessionModel.id),
|
|
403
|
+
effort: turn.effort || text(sessionModel.variant),
|
|
404
|
+
status,
|
|
405
|
+
live: turn.open,
|
|
406
|
+
startedAt: number(row.time_created),
|
|
407
|
+
lastActivityAt,
|
|
408
|
+
tool: turn.open && turn.tool ? { ...turn.tool } : null,
|
|
409
|
+
tokens: turn.tokens ? { ...turn.tokens } : null,
|
|
410
|
+
context: turn.context ? { used: turn.context, limit: context.byModel.get(`${turn.provider}/${turn.model}`) || 0 } : null,
|
|
411
|
+
costUsd: turn.cost,
|
|
412
|
+
lines: extra.lines,
|
|
413
|
+
plan: planSummary(extra.plan),
|
|
414
|
+
subagents: extra.errands,
|
|
415
|
+
prompts: turn.prompts,
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function legacySessions(db, tables, context) {
|
|
420
|
+
const cols = columnsOf(db, 'session')
|
|
421
|
+
if (!['id', 'directory', 'time_created', 'time_updated'].every((c) => cols.has(c))) return []
|
|
422
|
+
const rows = db
|
|
423
|
+
.prepare(
|
|
424
|
+
`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 > ?`,
|
|
425
|
+
)
|
|
426
|
+
.all(context.at - RECENT_MS)
|
|
427
|
+
const todoCols = tables.has('todo') ? columnsOf(db, 'todo') : new Set()
|
|
428
|
+
const todos = ['session_id', 'content', 'status'].every((c) => todoCols.has(c))
|
|
429
|
+
const turnOf = (row) => cached(context, `v1:${row.id}`, number(row.time_updated), () => legacyTurn(db, row, context))
|
|
430
|
+
const out = []
|
|
431
|
+
for (const row of rows) {
|
|
432
|
+
if (row.parent_id || row.time_archived != null) continue
|
|
433
|
+
const turn = turnOf(row)
|
|
434
|
+
const errands = rows.filter((r) => r.parent_id === row.id && context.at - number(r.time_updated) < SUBAGENT_WINDOW_MS && turnOf(r).open).length
|
|
435
|
+
const lines = context.liveSummary
|
|
436
|
+
? row.summary_additions == null && row.summary_deletions == null
|
|
437
|
+
? null
|
|
438
|
+
: { added: number(row.summary_additions), removed: number(row.summary_deletions) }
|
|
439
|
+
: turn.lines
|
|
440
|
+
const plan = todos
|
|
441
|
+
? db
|
|
442
|
+
.prepare(`SELECT content, status FROM todo WHERE session_id = ? ORDER BY ${todoCols.has('position') ? 'position' : 'rowid'}`)
|
|
443
|
+
.all(row.id)
|
|
444
|
+
.map((t) => ({ content: text(t.content), status: text(t.status) }))
|
|
445
|
+
: null
|
|
446
|
+
out.push(session(context, row, turn, { errands, lines, plan }))
|
|
447
|
+
}
|
|
448
|
+
return out
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function legacyTurn(db, row, context) {
|
|
452
|
+
const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, lines: null, tool: null }
|
|
453
|
+
const typed = new Set(
|
|
454
|
+
db
|
|
455
|
+
.prepare(`SELECT message_id FROM part WHERE session_id = ? AND data LIKE '%"type":"text"%' AND data NOT LIKE '%"synthetic":true%'`)
|
|
456
|
+
.all(row.id)
|
|
457
|
+
.map((r) => r.message_id),
|
|
458
|
+
)
|
|
459
|
+
let last = null
|
|
460
|
+
let lastAssistant = ''
|
|
461
|
+
for (const message of db.prepare('SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created, rowid').all(row.id)) {
|
|
462
|
+
const data = parse(message.data)
|
|
463
|
+
last = data
|
|
464
|
+
if (data.role === 'user') {
|
|
465
|
+
if (typed.has(message.id)) {
|
|
466
|
+
turn.prompts++
|
|
467
|
+
if (!turn.firstPrompt) turn.firstPrompt = firstText(db, message.id)
|
|
468
|
+
}
|
|
469
|
+
if (Array.isArray(data.summary?.diffs)) {
|
|
470
|
+
turn.lines ||= { added: 0, removed: 0 }
|
|
471
|
+
for (const diff of data.summary.diffs) {
|
|
472
|
+
turn.lines.added += number(diff?.additions)
|
|
473
|
+
turn.lines.removed += number(diff?.deletions)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
continue
|
|
477
|
+
}
|
|
478
|
+
if (data.role !== 'assistant') continue
|
|
479
|
+
lastAssistant = message.id
|
|
480
|
+
if (data.modelID) turn.model = text(data.modelID)
|
|
481
|
+
if (data.providerID) turn.provider = text(data.providerID)
|
|
482
|
+
if (data.variant) turn.effort = text(data.variant)
|
|
483
|
+
if (data.cost != null) turn.cost = (turn.cost || 0) + number(data.cost)
|
|
484
|
+
if (number(data.tokens?.output) > 0) turn.context = windowOf(data.tokens)
|
|
485
|
+
}
|
|
486
|
+
const usage = usageFromColumns(row)
|
|
487
|
+
if (usage) {
|
|
488
|
+
turn.tokens = usage.tokens
|
|
489
|
+
if (usage.cost != null) turn.cost = usage.cost
|
|
490
|
+
} else {
|
|
491
|
+
for (const part of db.prepare(`SELECT data FROM part WHERE session_id = ? AND data LIKE '%"type":"step-finish"%'`).all(row.id)) {
|
|
492
|
+
turn.tokens = addTokens(turn.tokens, parse(part.data).tokens)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const error = text(last?.error?.name)
|
|
496
|
+
const finish = text(last?.finish)
|
|
497
|
+
const ended = Boolean(last?.time?.completed) || Boolean(finish)
|
|
498
|
+
turn.busy = last?.role === 'user' || (last?.role === 'assistant' && !error && (!ended || context.continuing.has(finish)))
|
|
499
|
+
turn.failed = last?.role === 'assistant' && (error ? !ABORTED.test(error) : ended && toolFailed(db, lastAssistant))
|
|
500
|
+
if (turn.busy) turn.tool = runningTool(db, lastAssistant)
|
|
501
|
+
return turn
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function firstText(db, messageId) {
|
|
505
|
+
for (const part of db.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY rowid LIMIT 8').all(messageId)) {
|
|
506
|
+
const data = parse(part.data)
|
|
507
|
+
if (data.type === 'text' && !data.synthetic && text(data.text).trim()) return text(data.text).trim()
|
|
508
|
+
}
|
|
509
|
+
return ''
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function toolFailed(db, messageId) {
|
|
513
|
+
for (const part of db.prepare(`SELECT data FROM part WHERE message_id = ? AND data LIKE '%"status":"error"%'`).all(messageId)) {
|
|
514
|
+
const state = parse(part.data).state || {}
|
|
515
|
+
if (!STOPPED.test(`${text(state.error)}\n${text(state.output)}`)) return true
|
|
516
|
+
}
|
|
517
|
+
return false
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function runningTool(db, messageId) {
|
|
521
|
+
if (!messageId) return null
|
|
522
|
+
let tool = null
|
|
523
|
+
for (const part of db.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY rowid').all(messageId)) {
|
|
524
|
+
const data = parse(part.data)
|
|
525
|
+
if (data.type !== 'tool' || !OPEN_TOOL.has(data.state?.status)) continue
|
|
526
|
+
tool = { name: text(data.tool), startedAt: number(data.state?.time?.start) }
|
|
527
|
+
}
|
|
528
|
+
return tool
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function currentSessions(db, tables, context) {
|
|
532
|
+
const cols = columnsOf(db, 'session_v2')
|
|
533
|
+
if (!['id', 'directory', 'time_created', 'time_updated'].every((c) => cols.has(c))) return []
|
|
534
|
+
const rows = db
|
|
535
|
+
.prepare(
|
|
536
|
+
`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 > ?`,
|
|
537
|
+
)
|
|
538
|
+
.all(context.at - RECENT_MS)
|
|
539
|
+
const parts = tables.has('session_part') && ['session_id', 'message_id', 'type', 'data'].every((c) => columnsOf(db, 'session_part').has(c))
|
|
540
|
+
const turnOf = (row) => cached(context, `v2:${row.id}`, number(row.time_updated), () => currentTurn(db, row, parts, context))
|
|
541
|
+
const out = []
|
|
542
|
+
for (const row of rows) {
|
|
543
|
+
if (row.parent_id || row.time_archived != null) continue
|
|
544
|
+
const turn = turnOf(row)
|
|
545
|
+
const errands = rows.filter((r) => r.parent_id === row.id && context.at - number(r.time_updated) < SUBAGENT_WINDOW_MS && turnOf(r).open).length
|
|
546
|
+
out.push(session(context, row, turn, { errands, lines: null, plan: turn.plan }))
|
|
547
|
+
}
|
|
548
|
+
return out
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function currentTurn(db, row, parts, context) {
|
|
552
|
+
const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, tool: null, plan: null }
|
|
553
|
+
turn.prompts = number(db.prepare(`SELECT COUNT(*) AS n FROM session_message WHERE session_id = ? AND type = 'user'`).get(row.id)?.n)
|
|
554
|
+
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()
|
|
555
|
+
const assistant = db.prepare(`SELECT id, data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq DESC LIMIT 1`).get(row.id)
|
|
556
|
+
const latest = parse(assistant?.data)
|
|
557
|
+
turn.model = text(latest.model?.id)
|
|
558
|
+
turn.provider = text(latest.model?.providerID)
|
|
559
|
+
turn.effort = text(latest.model?.variant)
|
|
560
|
+
const withTokens = db
|
|
561
|
+
.prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' AND data LIKE '%"tokens"%' ORDER BY seq DESC LIMIT 1`)
|
|
562
|
+
.get(row.id)
|
|
563
|
+
turn.context = windowOf(parse(withTokens?.data).tokens)
|
|
564
|
+
const usage = usageFromColumns(row)
|
|
565
|
+
if (usage) {
|
|
566
|
+
turn.tokens = usage.tokens
|
|
567
|
+
turn.cost = usage.cost
|
|
568
|
+
}
|
|
569
|
+
const last = db
|
|
570
|
+
.prepare(`SELECT type, data FROM session_message WHERE session_id = ? AND type IN (${TURN_MESSAGES.map(() => '?').join(', ')}) ORDER BY seq DESC LIMIT 1`)
|
|
571
|
+
.get(row.id, ...TURN_MESSAGES)
|
|
572
|
+
const busy = row.time_busy != null ? number(row.time_busy) > number(row.time_idle) : Boolean(last) && last.type !== 'idle'
|
|
573
|
+
turn.busy = busy
|
|
574
|
+
const outcome = text(row.idle_outcome) || (last?.type === 'idle' ? text(parse(last.data).outcome) : '')
|
|
575
|
+
turn.failed = !busy && outcome === 'failed'
|
|
576
|
+
const tools = currentTools(db, row.id, assistant, parts)
|
|
577
|
+
if (busy) turn.tool = tools.running
|
|
578
|
+
turn.plan = tools.todos
|
|
579
|
+
return turn
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function currentTools(db, sessionId, assistant, parts) {
|
|
583
|
+
const result = { running: null, todos: null }
|
|
584
|
+
let latest = []
|
|
585
|
+
let recent = []
|
|
586
|
+
if (parts && assistant) {
|
|
587
|
+
latest = db.prepare(`SELECT data FROM session_part WHERE session_id = ? AND message_id = ? AND type = 'tool'`).all(sessionId, assistant.id).map((p) => parse(p.data))
|
|
588
|
+
}
|
|
589
|
+
if (parts) {
|
|
590
|
+
recent = db
|
|
591
|
+
.prepare(`SELECT data FROM session_part WHERE session_id = ? AND type = 'tool' AND data LIKE ? ORDER BY rowid DESC LIMIT 20`)
|
|
592
|
+
.all(sessionId, `%"${TODO_TOOL}"%`)
|
|
593
|
+
.map((p) => parse(p.data))
|
|
594
|
+
}
|
|
595
|
+
if (!latest.length && assistant) latest = list(parse(assistant.data).content).filter((c) => c?.type === 'tool')
|
|
596
|
+
for (const part of latest) {
|
|
597
|
+
if (OPEN_TOOL.has(part.state?.status)) result.running = { name: text(part.name), startedAt: number(part.time?.ran) || number(part.time?.created) }
|
|
598
|
+
}
|
|
599
|
+
const todos = recent.find((p) => p.name === TODO_TOOL && p.state?.status === 'completed' && Array.isArray(p.state?.input?.todos))
|
|
600
|
+
if (todos) result.todos = todos.state.input.todos.map((t) => ({ content: text(t?.content), status: text(t?.status) }))
|
|
601
|
+
return result
|
|
602
|
+
}
|
package/lib/sqlite.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
let sqlitePromise
|
|
2
|
+
export const sqliteApi = () => (sqlitePromise ??= import('node:sqlite').catch(() => null))
|
|
3
|
+
|
|
4
|
+
export async function openReadOnly(file) {
|
|
5
|
+
const sqlite = await sqliteApi()
|
|
6
|
+
if (!file || !sqlite?.DatabaseSync) return null
|
|
7
|
+
try {
|
|
8
|
+
return new sqlite.DatabaseSync(file, { readOnly: true })
|
|
9
|
+
} catch {
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function withReadOnly(file, run, fallback) {
|
|
15
|
+
const db = await openReadOnly(file)
|
|
16
|
+
if (!db) return fallback
|
|
17
|
+
try {
|
|
18
|
+
return await run(db)
|
|
19
|
+
} finally {
|
|
20
|
+
try {
|
|
21
|
+
db.close()
|
|
22
|
+
} catch {}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const tablesOf = (db) =>
|
|
27
|
+
new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name))
|
|
28
|
+
|
|
29
|
+
export const columnsOf = (db, table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((r) => r.name))
|