@threadcrumbs/common 0.1.8 → 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 +1 -1
- package/telemetry/README.md +290 -290
- package/telemetry/claude-code.mjs +3 -2
- package/telemetry/codex.mjs +700 -700
- package/telemetry/hermes.mjs +441 -441
- package/ui/rows.css +5 -0
package/telemetry/hermes.mjs
CHANGED
|
@@ -1,441 +1,441 @@
|
|
|
1
|
-
import fsp from 'node:fs/promises'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import { exists, listDirs } from '../lib/fsutil.mjs'
|
|
5
|
-
import { resolveHermesHome } from '../lib/hermes-home.mjs'
|
|
6
|
-
import { columnsOf, tablesOf, withReadOnly } from '../lib/sqlite.mjs'
|
|
7
|
-
import {
|
|
8
|
-
DETAIL_LIMITS,
|
|
9
|
-
RECENT_MS,
|
|
10
|
-
activityDetail,
|
|
11
|
-
askOf,
|
|
12
|
-
clip,
|
|
13
|
-
clipBlock,
|
|
14
|
-
emptyActivity,
|
|
15
|
-
endCall,
|
|
16
|
-
keepNewest,
|
|
17
|
-
keepText,
|
|
18
|
-
number,
|
|
19
|
-
openTool,
|
|
20
|
-
planItems,
|
|
21
|
-
planSummary,
|
|
22
|
-
settleCalls,
|
|
23
|
-
startCall,
|
|
24
|
-
text,
|
|
25
|
-
textList,
|
|
26
|
-
titleFromPrompt,
|
|
27
|
-
toolFile,
|
|
28
|
-
toolInput,
|
|
29
|
-
toolNow,
|
|
30
|
-
toolTarget,
|
|
31
|
-
} from '../lib/telemetry-fields.mjs'
|
|
32
|
-
|
|
33
|
-
const ACTIVE_WINDOW_MS = 30 * 60 * 1000
|
|
34
|
-
const FORK_MARKERS = ['_branched_from', '_delegate_from', '_reset_from']
|
|
35
|
-
const DELEGATE_TOOLS = new Set(['delegate_task'])
|
|
36
|
-
const TODO_TOOLS = ['todo_list', 'todo']
|
|
37
|
-
const ASKING = /^waiting for user (approval|clarify)/i
|
|
38
|
-
const TOOL_LABEL = /^(?:executing tool|sequential tool running \([^)]*\)): (.+)$/i
|
|
39
|
-
const SESSION_COLUMNS = [
|
|
40
|
-
'id', 'title', 'model', 'source', 'cwd', 'git_branch', 'git_repo_root', 'started_at', 'ended_at',
|
|
41
|
-
'last_activity_at', 'last_activity_description', 'parent_session_id', 'end_reason', 'model_config', 'hidden',
|
|
42
|
-
'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'estimated_cost_usd',
|
|
43
|
-
'actual_cost_usd', 'billing_base_url', 'archived', 'last_read_at',
|
|
44
|
-
]
|
|
45
|
-
|
|
46
|
-
const seconds = (v) => Math.round(number(v) * 1000)
|
|
47
|
-
|
|
48
|
-
function parseConfig(raw) {
|
|
49
|
-
try {
|
|
50
|
-
const config = JSON.parse(raw || '{}')
|
|
51
|
-
return config && typeof config === 'object' ? config : {}
|
|
52
|
-
} catch {
|
|
53
|
-
return {}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const unquote = (key) => {
|
|
58
|
-
if (/^'.*'$/.test(key)) return key.slice(1, -1).replace(/''/g, "'")
|
|
59
|
-
if (/^".*"$/.test(key)) {
|
|
60
|
-
try {
|
|
61
|
-
return JSON.parse(key)
|
|
62
|
-
} catch {
|
|
63
|
-
return key.slice(1, -1)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return key
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function parseContextLengths(source) {
|
|
70
|
-
const lengths = new Map()
|
|
71
|
-
let inSection = false
|
|
72
|
-
for (const line of text(source).split(/\r?\n/)) {
|
|
73
|
-
if (/^\S/.test(line)) {
|
|
74
|
-
inSection = /^context_lengths:\s*$/.test(line)
|
|
75
|
-
continue
|
|
76
|
-
}
|
|
77
|
-
const entry = inSection && /^\s+(.+?):\s+(\d+)\s*$/.exec(line)
|
|
78
|
-
if (entry) lengths.set(unquote(entry[1]), Number(entry[2]))
|
|
79
|
-
}
|
|
80
|
-
return lengths
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function contextLimit(lengths, model, baseUrl) {
|
|
84
|
-
if (!model) return 0
|
|
85
|
-
const exact = lengths.get(`${model}@${text(baseUrl).replace(/\/+$/, '')}`)
|
|
86
|
-
if (exact) return exact
|
|
87
|
-
for (const [key, limit] of lengths) if (key.startsWith(`${model}@`)) return limit
|
|
88
|
-
return 0
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function hermes({ home = resolveHermesHome(), now = Date.now } = {}) {
|
|
92
|
-
const mainDb = path.join(home, 'state.db')
|
|
93
|
-
const contextCaches = new Map()
|
|
94
|
-
|
|
95
|
-
async function storeFiles() {
|
|
96
|
-
const files = [{ profile: 'main', dir: home, file: mainDb }]
|
|
97
|
-
for (const dir of await listDirs(path.join(home, 'profiles'))) {
|
|
98
|
-
const file = path.join(dir, 'state.db')
|
|
99
|
-
if (await exists(file)) files.push({ profile: path.basename(dir), dir, file })
|
|
100
|
-
}
|
|
101
|
-
return files
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async function contextLengths(dir) {
|
|
105
|
-
const file = path.join(dir, 'context_length_cache.yaml')
|
|
106
|
-
const stat = await fsp.stat(file).catch(() => null)
|
|
107
|
-
if (!stat) return new Map()
|
|
108
|
-
const hit = contextCaches.get(file)
|
|
109
|
-
if (hit && hit.mtime === stat.mtimeMs) return hit.lengths
|
|
110
|
-
const lengths = parseContextLengths(await fsp.readFile(file, 'utf8').catch(() => ''))
|
|
111
|
-
contextCaches.set(file, { mtime: stat.mtimeMs, lengths })
|
|
112
|
-
return lengths
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async function scanSessions() {
|
|
116
|
-
const sessions = []
|
|
117
|
-
for (const store of await storeFiles()) {
|
|
118
|
-
const lengths = await contextLengths(store.dir)
|
|
119
|
-
const found = await withReadOnly(store.file, (db) => sessionsFrom(db, store.profile, lengths, home, now()), [])
|
|
120
|
-
sessions.push(...found)
|
|
121
|
-
}
|
|
122
|
-
return sessions
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function sessionDetail(key, { filesOnly = false } = {}) {
|
|
126
|
-
const match = /^hermes:([^:]+):(.+)$/.exec(String(key))
|
|
127
|
-
if (!match) return null
|
|
128
|
-
const [, profile, rootId] = match
|
|
129
|
-
if (filesOnly) return { id: `hermes:${profile}:${rootId}`, sessionId: rootId, files: null }
|
|
130
|
-
const store = (await storeFiles()).find((s) => s.profile === profile)
|
|
131
|
-
if (!store) return null
|
|
132
|
-
const detail = await withReadOnly(store.file, (db) => detailFrom(db, rootId, now()), null)
|
|
133
|
-
return detail && { id: `hermes:${profile}:${rootId}`, sessionId: rootId, source: store.file, ...detail }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
id: 'hermes',
|
|
138
|
-
name: 'Hermes',
|
|
139
|
-
detect: () => exists(mainDb),
|
|
140
|
-
watchRoots: () => [home],
|
|
141
|
-
scanSessions,
|
|
142
|
-
sessionDetail,
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function lineage(db, at) {
|
|
147
|
-
const tables = tablesOf(db)
|
|
148
|
-
if (!tables.has('sessions') || !tables.has('messages')) return null
|
|
149
|
-
const cols = columnsOf(db, 'sessions')
|
|
150
|
-
if (!['id', 'started_at'].every((c) => cols.has(c))) return null
|
|
151
|
-
const rows = db.prepare(`SELECT ${SESSION_COLUMNS.map((c) => (cols.has(c) ? c : `NULL AS ${c}`)).join(', ')} FROM sessions`).all()
|
|
152
|
-
for (const row of rows) row.config = parseConfig(row.model_config)
|
|
153
|
-
const leases = tables.has('session_turn_leases')
|
|
154
|
-
? new Set(db.prepare('SELECT conversation_id FROM session_turn_leases WHERE expires_at > ?').all(at / 1000).map((r) => r.conversation_id))
|
|
155
|
-
: null
|
|
156
|
-
const messages = columnsOf(db, 'messages')
|
|
157
|
-
if (!['id', 'session_id', 'role', 'content'].every((c) => messages.has(c))) return null
|
|
158
|
-
const visible = messages.has('active') ? (messages.has('compacted') ? 'AND (active = 1 OR compacted = 1)' : 'AND active = 1') : ''
|
|
159
|
-
|
|
160
|
-
const byId = new Map(rows.map((r) => [r.id, r]))
|
|
161
|
-
const fork = (row) => FORK_MARKERS.find((key) => row.config[key] != null) || ''
|
|
162
|
-
const continues = (row) => {
|
|
163
|
-
const parent = row.parent_session_id ? byId.get(row.parent_session_id) : null
|
|
164
|
-
return parent && parent.end_reason === 'compression' && !fork(row) ? parent : null
|
|
165
|
-
}
|
|
166
|
-
const continued = new Set(rows.map(continues).filter(Boolean).map((p) => p.id))
|
|
167
|
-
const isErrand = (row) => row.source === 'subagent' || fork(row) === '_delegate_from'
|
|
168
|
-
const lastAt = (row) => seconds(row.last_activity_at || row.ended_at || row.started_at)
|
|
169
|
-
const chainOf = (tip) => {
|
|
170
|
-
const chain = [tip]
|
|
171
|
-
for (let parent = continues(tip); parent && chain.length <= rows.length; parent = continues(parent)) chain.push(parent)
|
|
172
|
-
return chain
|
|
173
|
-
}
|
|
174
|
-
const turnOpen = (row, root) =>
|
|
175
|
-
leases ? leases.has(root.id) : Boolean(text(row.last_activity_description)) && at - lastAt(row) < ACTIVE_WINDOW_MS
|
|
176
|
-
return { rows, continued, isErrand, lastAt, chainOf, turnOpen, messages, visible }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function sessionsFrom(db, profile, lengths, home, at) {
|
|
180
|
-
const found = lineage(db, at)
|
|
181
|
-
if (!found) return []
|
|
182
|
-
const { rows, continued, isErrand, lastAt, chainOf, turnOpen, messages, visible } = found
|
|
183
|
-
|
|
184
|
-
const out = []
|
|
185
|
-
for (const tip of rows) {
|
|
186
|
-
if (continued.has(tip.id) || isErrand(tip) || number(tip.hidden) || number(tip.archived) || tip.source === 'cron') continue
|
|
187
|
-
const chain = chainOf(tip)
|
|
188
|
-
const root = chain[chain.length - 1]
|
|
189
|
-
const open = turnOpen(tip, root)
|
|
190
|
-
if (!open && at - lastAt(tip) > RECENT_MS) continue
|
|
191
|
-
const ids = chain.map((r) => r.id)
|
|
192
|
-
const working = rows
|
|
193
|
-
.filter((r) => isErrand(r) && ids.includes(r.parent_session_id) && turnOpen(r, chainOf(r).at(-1)))
|
|
194
|
-
.sort((a, b) => seconds(a.started_at) - seconds(b.started_at))
|
|
195
|
-
.map((r) => ({ id: r.id, name: text(r.title) || 'Subagent' }))
|
|
196
|
-
out.push(toSession({ db, profile, tip, root, chain, ids, open, errands: working.length, workingSubagents: working, lengths, home, messages, visible, at, lastAt: lastAt(tip) }))
|
|
197
|
-
}
|
|
198
|
-
return out
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function parseJson(raw, fallback) {
|
|
202
|
-
try {
|
|
203
|
-
return JSON.parse(raw)
|
|
204
|
-
} catch {
|
|
205
|
-
return fallback
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const FAILED_END = /error|fail/i
|
|
210
|
-
|
|
211
|
-
function detailFrom(db, rootId, at) {
|
|
212
|
-
const found = lineage(db, at)
|
|
213
|
-
if (!found) return null
|
|
214
|
-
const { rows, continued, isErrand, lastAt, chainOf, turnOpen, messages, visible } = found
|
|
215
|
-
const tip = rows.find((r) => !continued.has(r.id) && !isErrand(r) && chainOf(r).at(-1).id === rootId)
|
|
216
|
-
if (!tip) return null
|
|
217
|
-
const chain = chainOf(tip)
|
|
218
|
-
const ids = chain.map((r) => r.id)
|
|
219
|
-
const placeholders = ids.map(() => '?').join(', ')
|
|
220
|
-
const open = turnOpen(tip, chain.at(-1))
|
|
221
|
-
const cwd = text(tip.cwd)
|
|
222
|
-
|
|
223
|
-
const activity = emptyActivity()
|
|
224
|
-
const prompts = []
|
|
225
|
-
const thoughts = []
|
|
226
|
-
const replies = []
|
|
227
|
-
const pick = (column, fallback = 'NULL') => (messages.has(column) ? column : `${fallback} AS ${column}`)
|
|
228
|
-
const said = (column) => (messages.has(column) ? `CASE WHEN role = 'assistant' THEN ${column} END AS ${column}` : `NULL AS ${column}`)
|
|
229
|
-
const reasons = messages.has('reasoning') || messages.has('reasoning_content')
|
|
230
|
-
const order = messages.has('timestamp') ? 'timestamp, id' : 'id'
|
|
231
|
-
const records = db
|
|
232
|
-
.prepare(
|
|
233
|
-
`SELECT id, role, CASE WHEN role IN ('user', 'assistant') THEN content END AS content, ${said('reasoning')}, ${said('reasoning_content')}, ${pick('tool_calls')}, ${pick('tool_call_id')}, ${pick('timestamp', '0')}
|
|
234
|
-
FROM messages WHERE session_id IN (${placeholders}) ${visible} ORDER BY ${order}`,
|
|
235
|
-
)
|
|
236
|
-
.all(...ids)
|
|
237
|
-
for (const record of records) {
|
|
238
|
-
const time = seconds(record.timestamp)
|
|
239
|
-
if (record.role === 'user') {
|
|
240
|
-
settleCalls(activity)
|
|
241
|
-
const prompt = text(record.content).trim()
|
|
242
|
-
if (prompt) keepNewest(prompts, { at: time, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
|
|
243
|
-
} else if (record.role === 'assistant') {
|
|
244
|
-
keepText(thoughts, 'thoughts', { at: time, text: text(record.reasoning) || text(record.reasoning_content) })
|
|
245
|
-
keepText(replies, 'replies', { at: time, text: text(record.content) })
|
|
246
|
-
for (const call of (record.tool_calls && parseJson(record.tool_calls, [])) || []) {
|
|
247
|
-
if (!call?.id) continue
|
|
248
|
-
const args = callArgs(call)
|
|
249
|
-
startCall(activity, { id: text(call.id), name: text(call.function?.name), target: toolTarget(args, cwd), input: toolInput(args), ...toolFile(args, cwd), startedAt: time })
|
|
250
|
-
}
|
|
251
|
-
} else if (record.role === 'tool' && record.tool_call_id) {
|
|
252
|
-
endCall(activity, text(record.tool_call_id), time, null)
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
if (!open) settleCalls(activity)
|
|
256
|
-
|
|
257
|
-
const subagents = rows
|
|
258
|
-
.filter((r) => isErrand(r) && ids.includes(r.parent_session_id))
|
|
259
|
-
.map((r) => {
|
|
260
|
-
const working = turnOpen(r, chainOf(r).at(-1))
|
|
261
|
-
const label = TOOL_LABEL.exec(text(r.last_activity_description))
|
|
262
|
-
const tokens = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens'].map((c) => (r[c] == null ? null : number(r[c])))
|
|
263
|
-
return {
|
|
264
|
-
id: r.id,
|
|
265
|
-
name: text(r.title) || 'Subagent',
|
|
266
|
-
type: text(r.source) === 'subagent' ? '' : 'delegate',
|
|
267
|
-
status: working ? 'working' : FAILED_END.test(text(r.end_reason)) ? 'error' : r.ended_at != null ? 'done' : 'idle',
|
|
268
|
-
model: text(r.model),
|
|
269
|
-
tokens: tokens.some((t) => t != null) ? { input: tokens[0] || 0, output: tokens[1] || 0, cacheRead: tokens[2] || 0, cacheWrite: tokens[3] || 0 } : null,
|
|
270
|
-
tool: working && label ? toolNow({ name: label[1].trim(), startedAt: seconds(r.last_activity_at) }) : null,
|
|
271
|
-
startedAt: seconds(r.started_at),
|
|
272
|
-
lastActivityAt: lastAt(r),
|
|
273
|
-
}
|
|
274
|
-
})
|
|
275
|
-
.sort((a, b) => a.startedAt - b.startedAt)
|
|
276
|
-
.slice(-DETAIL_LIMITS.subagents)
|
|
277
|
-
|
|
278
|
-
const models = new Map()
|
|
279
|
-
for (const row of chain) {
|
|
280
|
-
const model = text(row.model)
|
|
281
|
-
if (!model) continue
|
|
282
|
-
const entry = models.get(model) || { model, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, costUsd: null }
|
|
283
|
-
entry.tokens.input += number(row.input_tokens)
|
|
284
|
-
entry.tokens.output += number(row.output_tokens)
|
|
285
|
-
entry.tokens.cacheRead += number(row.cache_read_tokens)
|
|
286
|
-
entry.tokens.cacheWrite += number(row.cache_write_tokens)
|
|
287
|
-
const cost = row.actual_cost_usd ?? row.estimated_cost_usd
|
|
288
|
-
if (cost != null) entry.costUsd = (entry.costUsd || 0) + number(cost)
|
|
289
|
-
models.set(model, entry)
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const errors = chain
|
|
293
|
-
.filter((r) => FAILED_END.test(text(r.end_reason)))
|
|
294
|
-
.map((r) => ({ at: seconds(r.ended_at), message: `The session ended: ${text(r.end_reason)}` }))
|
|
295
|
-
.reverse()
|
|
296
|
-
|
|
297
|
-
return {
|
|
298
|
-
models: [...models.values()],
|
|
299
|
-
history: null,
|
|
300
|
-
compactions: null,
|
|
301
|
-
contextParts: null,
|
|
302
|
-
...activityDetail(activity),
|
|
303
|
-
plan: planItems(latestPlan(db, ids, messages)),
|
|
304
|
-
subagents,
|
|
305
|
-
files: null,
|
|
306
|
-
prompts,
|
|
307
|
-
thoughts: reasons ? textList(thoughts) : null,
|
|
308
|
-
replies: textList(replies),
|
|
309
|
-
contextBreakdown: null,
|
|
310
|
-
errors,
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function openCalls(db, sessionId, messages, visible, cwd) {
|
|
315
|
-
if (!['tool_calls', 'tool_call_id', 'timestamp'].every((c) => messages.has(c))) return []
|
|
316
|
-
const last = db
|
|
317
|
-
.prepare(`SELECT id, tool_calls, timestamp FROM messages WHERE session_id = ? AND role = 'assistant' ${visible} ORDER BY id DESC LIMIT 1`)
|
|
318
|
-
.get(sessionId)
|
|
319
|
-
if (!last?.tool_calls) return []
|
|
320
|
-
let calls
|
|
321
|
-
try {
|
|
322
|
-
calls = JSON.parse(last.tool_calls)
|
|
323
|
-
} catch {
|
|
324
|
-
return []
|
|
325
|
-
}
|
|
326
|
-
const answered = new Set(
|
|
327
|
-
db.prepare(`SELECT tool_call_id FROM messages WHERE session_id = ? AND role = 'tool' AND id > ?`).all(sessionId, last.id).map((r) => r.tool_call_id),
|
|
328
|
-
)
|
|
329
|
-
return (Array.isArray(calls) ? calls : [])
|
|
330
|
-
.filter((c) => c?.id && !answered.has(c.id))
|
|
331
|
-
.map((c) => openTool(text(c.function?.name), seconds(last.timestamp), callArgs(c), cwd))
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const callArgs = (call) => (typeof call.function?.arguments === 'string' ? parseJson(call.function.arguments, null) : call.function?.arguments)
|
|
335
|
-
|
|
336
|
-
function turnStart(db, ids, messages, visible) {
|
|
337
|
-
if (!messages.has('timestamp')) return null
|
|
338
|
-
const row = db
|
|
339
|
-
.prepare(`SELECT MAX(timestamp) AS at FROM messages WHERE session_id IN (${ids.map(() => '?').join(', ')}) AND role = 'user' ${visible}`)
|
|
340
|
-
.get(...ids)
|
|
341
|
-
return row?.at == null ? null : seconds(row.at)
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function latestPlan(db, ids, messages) {
|
|
345
|
-
if (!messages.has('tool_name') || !messages.has('timestamp')) return null
|
|
346
|
-
const row = db
|
|
347
|
-
.prepare(
|
|
348
|
-
`SELECT content FROM messages WHERE session_id IN (${ids.map(() => '?').join(', ')}) AND role = 'tool' AND tool_name IN (${TODO_TOOLS.map(() => '?').join(', ')}) ORDER BY timestamp DESC, id DESC LIMIT 1`,
|
|
349
|
-
)
|
|
350
|
-
.get(...ids, ...TODO_TOOLS)
|
|
351
|
-
try {
|
|
352
|
-
const todos = JSON.parse(row?.content || '{}').todos
|
|
353
|
-
return Array.isArray(todos) ? todos.map((t) => ({ content: text(t?.content), status: text(t?.status) || 'pending' })) : null
|
|
354
|
-
} catch {
|
|
355
|
-
return null
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function sum(chain, column) {
|
|
360
|
-
let total = 0
|
|
361
|
-
let known = false
|
|
362
|
-
for (const row of chain) {
|
|
363
|
-
if (row[column] == null) continue
|
|
364
|
-
total += number(row[column])
|
|
365
|
-
known = true
|
|
366
|
-
}
|
|
367
|
-
return known ? total : null
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function toSession({ db, profile, tip, root, chain, ids, open, errands, workingSubagents, lengths, home, messages, visible, at, lastAt }) {
|
|
371
|
-
const description = text(tip.last_activity_description)
|
|
372
|
-
const placeholders = ids.map(() => '?').join(', ')
|
|
373
|
-
const calls = open ? openCalls(db, tip.id, messages, visible, text(tip.cwd)) : []
|
|
374
|
-
const working = calls.filter((c) => !DELEGATE_TOOLS.has(c.name))
|
|
375
|
-
const asking = ASKING.exec(description)
|
|
376
|
-
let status
|
|
377
|
-
let statusReason
|
|
378
|
-
if (open) {
|
|
379
|
-
if (asking) [status, statusReason] = ['asking', /approval/i.test(asking[1]) ? 'approval' : 'question']
|
|
380
|
-
else if (!working.length && (calls.length || errands)) [status, statusReason] = ['delegating', 'subagents']
|
|
381
|
-
else [status, statusReason] = ['working', 'turn-open']
|
|
382
|
-
} else if (errands) [status, statusReason] = ['delegating', 'subagents']
|
|
383
|
-
else if (tip.last_read_at != null) [status, statusReason] = seconds(tip.last_activity_at || tip.started_at) > seconds(tip.last_read_at) ? ['done', 'unread'] : ['idle', 'read']
|
|
384
|
-
else [status, statusReason] = tip.ended_at == null && at - lastAt < ACTIVE_WINDOW_MS ? ['done', 'recent'] : ['idle', 'quiet']
|
|
385
|
-
|
|
386
|
-
let tool = null
|
|
387
|
-
if (status === 'working' || status === 'delegating') {
|
|
388
|
-
const latest = (working.length ? working : calls).at(-1)
|
|
389
|
-
const label = TOOL_LABEL.exec(description)
|
|
390
|
-
if (latest) tool = toolNow(latest)
|
|
391
|
-
else if (label) tool = toolNow({ name: label[1].trim(), startedAt: seconds(tip.last_activity_at) })
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const where = text(tip.git_repo_root) || text(tip.cwd)
|
|
395
|
-
const userHome = os.homedir()
|
|
396
|
-
const atHome = !where || where === userHome || where === home || where === path.join(userHome, '.hermes')
|
|
397
|
-
const anchor = tip.config._usage_anchor
|
|
398
|
-
const used = anchor ? number(anchor.prompt_tokens) + number(anchor.completion_tokens) : 0
|
|
399
|
-
const reasoning = tip.config.reasoning_config
|
|
400
|
-
const tokens = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens'].map((c) => sum(chain, c))
|
|
401
|
-
const costs = chain.map((r) => r.actual_cost_usd ?? r.estimated_cost_usd)
|
|
402
|
-
const firstPrompt = db
|
|
403
|
-
.prepare(`SELECT content FROM messages WHERE session_id = ? AND role = 'user' ORDER BY id ASC LIMIT 1`)
|
|
404
|
-
.get(root.id)?.content
|
|
405
|
-
const prompts = db.prepare(`SELECT COUNT(*) AS n FROM messages WHERE session_id IN (${placeholders}) AND role = 'user' ${visible}`).get(...ids)
|
|
406
|
-
|
|
407
|
-
return {
|
|
408
|
-
id: `hermes:${profile}:${root.id}`,
|
|
409
|
-
harness: 'hermes',
|
|
410
|
-
title: text(tip.title) || text(root.title) ||
|
|
411
|
-
project: atHome ? 'Hermes' : path.basename(where.replace(/\\/g, '/')),
|
|
412
|
-
projectPath: atHome ? userHome : where,
|
|
413
|
-
worktree: '',
|
|
414
|
-
cwd: text(tip.cwd),
|
|
415
|
-
gitBranch: text(tip.git_branch),
|
|
416
|
-
model: text(tip.model),
|
|
417
|
-
effort: reasoning && reasoning.enabled !== false ? text(reasoning.effort) : '',
|
|
418
|
-
status,
|
|
419
|
-
live: open,
|
|
420
|
-
startedAt: seconds(root.started_at),
|
|
421
|
-
lastActivityAt: lastAt,
|
|
422
|
-
tool,
|
|
423
|
-
tokens: tokens.some((t) => t != null)
|
|
424
|
-
? { input: tokens[0] || 0, output: tokens[1] || 0, cacheRead: tokens[2] || 0, cacheWrite: tokens[3] || 0 }
|
|
425
|
-
: null,
|
|
426
|
-
context: used ? { used, limit: contextLimit(lengths, text(tip.model), tip.billing_base_url) } : null,
|
|
427
|
-
contextHistory: null,
|
|
428
|
-
costUsd: costs.some((c) => c != null) ? costs.reduce((total, c) => total + number(c), 0) : null,
|
|
429
|
-
lines: null,
|
|
430
|
-
plan: planSummary(latestPlan(db, ids, messages)),
|
|
431
|
-
subagents: errands,
|
|
432
|
-
workingSubagents,
|
|
433
|
-
prompts: number(prompts?.n),
|
|
434
|
-
turnStartedAt: open ? turnStart(db, ids, messages, visible) : null,
|
|
435
|
-
statusReason,
|
|
436
|
-
ask: askOf(status),
|
|
437
|
-
usageLimits: null,
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
export default hermes()
|
|
1
|
+
import fsp from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { exists, listDirs } from '../lib/fsutil.mjs'
|
|
5
|
+
import { resolveHermesHome } from '../lib/hermes-home.mjs'
|
|
6
|
+
import { columnsOf, tablesOf, withReadOnly } from '../lib/sqlite.mjs'
|
|
7
|
+
import {
|
|
8
|
+
DETAIL_LIMITS,
|
|
9
|
+
RECENT_MS,
|
|
10
|
+
activityDetail,
|
|
11
|
+
askOf,
|
|
12
|
+
clip,
|
|
13
|
+
clipBlock,
|
|
14
|
+
emptyActivity,
|
|
15
|
+
endCall,
|
|
16
|
+
keepNewest,
|
|
17
|
+
keepText,
|
|
18
|
+
number,
|
|
19
|
+
openTool,
|
|
20
|
+
planItems,
|
|
21
|
+
planSummary,
|
|
22
|
+
settleCalls,
|
|
23
|
+
startCall,
|
|
24
|
+
text,
|
|
25
|
+
textList,
|
|
26
|
+
titleFromPrompt,
|
|
27
|
+
toolFile,
|
|
28
|
+
toolInput,
|
|
29
|
+
toolNow,
|
|
30
|
+
toolTarget,
|
|
31
|
+
} from '../lib/telemetry-fields.mjs'
|
|
32
|
+
|
|
33
|
+
const ACTIVE_WINDOW_MS = 30 * 60 * 1000
|
|
34
|
+
const FORK_MARKERS = ['_branched_from', '_delegate_from', '_reset_from']
|
|
35
|
+
const DELEGATE_TOOLS = new Set(['delegate_task'])
|
|
36
|
+
const TODO_TOOLS = ['todo_list', 'todo']
|
|
37
|
+
const ASKING = /^waiting for user (approval|clarify)/i
|
|
38
|
+
const TOOL_LABEL = /^(?:executing tool|sequential tool running \([^)]*\)): (.+)$/i
|
|
39
|
+
const SESSION_COLUMNS = [
|
|
40
|
+
'id', 'title', 'model', 'source', 'cwd', 'git_branch', 'git_repo_root', 'started_at', 'ended_at',
|
|
41
|
+
'last_activity_at', 'last_activity_description', 'parent_session_id', 'end_reason', 'model_config', 'hidden',
|
|
42
|
+
'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'estimated_cost_usd',
|
|
43
|
+
'actual_cost_usd', 'billing_base_url', 'archived', 'last_read_at',
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const seconds = (v) => Math.round(number(v) * 1000)
|
|
47
|
+
|
|
48
|
+
function parseConfig(raw) {
|
|
49
|
+
try {
|
|
50
|
+
const config = JSON.parse(raw || '{}')
|
|
51
|
+
return config && typeof config === 'object' ? config : {}
|
|
52
|
+
} catch {
|
|
53
|
+
return {}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const unquote = (key) => {
|
|
58
|
+
if (/^'.*'$/.test(key)) return key.slice(1, -1).replace(/''/g, "'")
|
|
59
|
+
if (/^".*"$/.test(key)) {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(key)
|
|
62
|
+
} catch {
|
|
63
|
+
return key.slice(1, -1)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return key
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseContextLengths(source) {
|
|
70
|
+
const lengths = new Map()
|
|
71
|
+
let inSection = false
|
|
72
|
+
for (const line of text(source).split(/\r?\n/)) {
|
|
73
|
+
if (/^\S/.test(line)) {
|
|
74
|
+
inSection = /^context_lengths:\s*$/.test(line)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
const entry = inSection && /^\s+(.+?):\s+(\d+)\s*$/.exec(line)
|
|
78
|
+
if (entry) lengths.set(unquote(entry[1]), Number(entry[2]))
|
|
79
|
+
}
|
|
80
|
+
return lengths
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function contextLimit(lengths, model, baseUrl) {
|
|
84
|
+
if (!model) return 0
|
|
85
|
+
const exact = lengths.get(`${model}@${text(baseUrl).replace(/\/+$/, '')}`)
|
|
86
|
+
if (exact) return exact
|
|
87
|
+
for (const [key, limit] of lengths) if (key.startsWith(`${model}@`)) return limit
|
|
88
|
+
return 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function hermes({ home = resolveHermesHome(), now = Date.now } = {}) {
|
|
92
|
+
const mainDb = path.join(home, 'state.db')
|
|
93
|
+
const contextCaches = new Map()
|
|
94
|
+
|
|
95
|
+
async function storeFiles() {
|
|
96
|
+
const files = [{ profile: 'main', dir: home, file: mainDb }]
|
|
97
|
+
for (const dir of await listDirs(path.join(home, 'profiles'))) {
|
|
98
|
+
const file = path.join(dir, 'state.db')
|
|
99
|
+
if (await exists(file)) files.push({ profile: path.basename(dir), dir, file })
|
|
100
|
+
}
|
|
101
|
+
return files
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function contextLengths(dir) {
|
|
105
|
+
const file = path.join(dir, 'context_length_cache.yaml')
|
|
106
|
+
const stat = await fsp.stat(file).catch(() => null)
|
|
107
|
+
if (!stat) return new Map()
|
|
108
|
+
const hit = contextCaches.get(file)
|
|
109
|
+
if (hit && hit.mtime === stat.mtimeMs) return hit.lengths
|
|
110
|
+
const lengths = parseContextLengths(await fsp.readFile(file, 'utf8').catch(() => ''))
|
|
111
|
+
contextCaches.set(file, { mtime: stat.mtimeMs, lengths })
|
|
112
|
+
return lengths
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function scanSessions() {
|
|
116
|
+
const sessions = []
|
|
117
|
+
for (const store of await storeFiles()) {
|
|
118
|
+
const lengths = await contextLengths(store.dir)
|
|
119
|
+
const found = await withReadOnly(store.file, (db) => sessionsFrom(db, store.profile, lengths, home, now()), [])
|
|
120
|
+
sessions.push(...found)
|
|
121
|
+
}
|
|
122
|
+
return sessions
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function sessionDetail(key, { filesOnly = false } = {}) {
|
|
126
|
+
const match = /^hermes:([^:]+):(.+)$/.exec(String(key))
|
|
127
|
+
if (!match) return null
|
|
128
|
+
const [, profile, rootId] = match
|
|
129
|
+
if (filesOnly) return { id: `hermes:${profile}:${rootId}`, sessionId: rootId, files: null }
|
|
130
|
+
const store = (await storeFiles()).find((s) => s.profile === profile)
|
|
131
|
+
if (!store) return null
|
|
132
|
+
const detail = await withReadOnly(store.file, (db) => detailFrom(db, rootId, now()), null)
|
|
133
|
+
return detail && { id: `hermes:${profile}:${rootId}`, sessionId: rootId, source: store.file, ...detail }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
id: 'hermes',
|
|
138
|
+
name: 'Hermes',
|
|
139
|
+
detect: () => exists(mainDb),
|
|
140
|
+
watchRoots: () => [home],
|
|
141
|
+
scanSessions,
|
|
142
|
+
sessionDetail,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function lineage(db, at) {
|
|
147
|
+
const tables = tablesOf(db)
|
|
148
|
+
if (!tables.has('sessions') || !tables.has('messages')) return null
|
|
149
|
+
const cols = columnsOf(db, 'sessions')
|
|
150
|
+
if (!['id', 'started_at'].every((c) => cols.has(c))) return null
|
|
151
|
+
const rows = db.prepare(`SELECT ${SESSION_COLUMNS.map((c) => (cols.has(c) ? c : `NULL AS ${c}`)).join(', ')} FROM sessions`).all()
|
|
152
|
+
for (const row of rows) row.config = parseConfig(row.model_config)
|
|
153
|
+
const leases = tables.has('session_turn_leases')
|
|
154
|
+
? new Set(db.prepare('SELECT conversation_id FROM session_turn_leases WHERE expires_at > ?').all(at / 1000).map((r) => r.conversation_id))
|
|
155
|
+
: null
|
|
156
|
+
const messages = columnsOf(db, 'messages')
|
|
157
|
+
if (!['id', 'session_id', 'role', 'content'].every((c) => messages.has(c))) return null
|
|
158
|
+
const visible = messages.has('active') ? (messages.has('compacted') ? 'AND (active = 1 OR compacted = 1)' : 'AND active = 1') : ''
|
|
159
|
+
|
|
160
|
+
const byId = new Map(rows.map((r) => [r.id, r]))
|
|
161
|
+
const fork = (row) => FORK_MARKERS.find((key) => row.config[key] != null) || ''
|
|
162
|
+
const continues = (row) => {
|
|
163
|
+
const parent = row.parent_session_id ? byId.get(row.parent_session_id) : null
|
|
164
|
+
return parent && parent.end_reason === 'compression' && !fork(row) ? parent : null
|
|
165
|
+
}
|
|
166
|
+
const continued = new Set(rows.map(continues).filter(Boolean).map((p) => p.id))
|
|
167
|
+
const isErrand = (row) => row.source === 'subagent' || fork(row) === '_delegate_from'
|
|
168
|
+
const lastAt = (row) => seconds(row.last_activity_at || row.ended_at || row.started_at)
|
|
169
|
+
const chainOf = (tip) => {
|
|
170
|
+
const chain = [tip]
|
|
171
|
+
for (let parent = continues(tip); parent && chain.length <= rows.length; parent = continues(parent)) chain.push(parent)
|
|
172
|
+
return chain
|
|
173
|
+
}
|
|
174
|
+
const turnOpen = (row, root) =>
|
|
175
|
+
leases ? leases.has(root.id) : Boolean(text(row.last_activity_description)) && at - lastAt(row) < ACTIVE_WINDOW_MS
|
|
176
|
+
return { rows, continued, isErrand, lastAt, chainOf, turnOpen, messages, visible }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function sessionsFrom(db, profile, lengths, home, at) {
|
|
180
|
+
const found = lineage(db, at)
|
|
181
|
+
if (!found) return []
|
|
182
|
+
const { rows, continued, isErrand, lastAt, chainOf, turnOpen, messages, visible } = found
|
|
183
|
+
|
|
184
|
+
const out = []
|
|
185
|
+
for (const tip of rows) {
|
|
186
|
+
if (continued.has(tip.id) || isErrand(tip) || number(tip.hidden) || number(tip.archived) || tip.source === 'cron') continue
|
|
187
|
+
const chain = chainOf(tip)
|
|
188
|
+
const root = chain[chain.length - 1]
|
|
189
|
+
const open = turnOpen(tip, root)
|
|
190
|
+
if (!open && at - lastAt(tip) > RECENT_MS) continue
|
|
191
|
+
const ids = chain.map((r) => r.id)
|
|
192
|
+
const working = rows
|
|
193
|
+
.filter((r) => isErrand(r) && ids.includes(r.parent_session_id) && turnOpen(r, chainOf(r).at(-1)))
|
|
194
|
+
.sort((a, b) => seconds(a.started_at) - seconds(b.started_at))
|
|
195
|
+
.map((r) => ({ id: r.id, name: text(r.title) || 'Subagent' }))
|
|
196
|
+
out.push(toSession({ db, profile, tip, root, chain, ids, open, errands: working.length, workingSubagents: working, lengths, home, messages, visible, at, lastAt: lastAt(tip) }))
|
|
197
|
+
}
|
|
198
|
+
return out
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseJson(raw, fallback) {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(raw)
|
|
204
|
+
} catch {
|
|
205
|
+
return fallback
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const FAILED_END = /error|fail/i
|
|
210
|
+
|
|
211
|
+
function detailFrom(db, rootId, at) {
|
|
212
|
+
const found = lineage(db, at)
|
|
213
|
+
if (!found) return null
|
|
214
|
+
const { rows, continued, isErrand, lastAt, chainOf, turnOpen, messages, visible } = found
|
|
215
|
+
const tip = rows.find((r) => !continued.has(r.id) && !isErrand(r) && chainOf(r).at(-1).id === rootId)
|
|
216
|
+
if (!tip) return null
|
|
217
|
+
const chain = chainOf(tip)
|
|
218
|
+
const ids = chain.map((r) => r.id)
|
|
219
|
+
const placeholders = ids.map(() => '?').join(', ')
|
|
220
|
+
const open = turnOpen(tip, chain.at(-1))
|
|
221
|
+
const cwd = text(tip.cwd)
|
|
222
|
+
|
|
223
|
+
const activity = emptyActivity()
|
|
224
|
+
const prompts = []
|
|
225
|
+
const thoughts = []
|
|
226
|
+
const replies = []
|
|
227
|
+
const pick = (column, fallback = 'NULL') => (messages.has(column) ? column : `${fallback} AS ${column}`)
|
|
228
|
+
const said = (column) => (messages.has(column) ? `CASE WHEN role = 'assistant' THEN ${column} END AS ${column}` : `NULL AS ${column}`)
|
|
229
|
+
const reasons = messages.has('reasoning') || messages.has('reasoning_content')
|
|
230
|
+
const order = messages.has('timestamp') ? 'timestamp, id' : 'id'
|
|
231
|
+
const records = db
|
|
232
|
+
.prepare(
|
|
233
|
+
`SELECT id, role, CASE WHEN role IN ('user', 'assistant') THEN content END AS content, ${said('reasoning')}, ${said('reasoning_content')}, ${pick('tool_calls')}, ${pick('tool_call_id')}, ${pick('timestamp', '0')}
|
|
234
|
+
FROM messages WHERE session_id IN (${placeholders}) ${visible} ORDER BY ${order}`,
|
|
235
|
+
)
|
|
236
|
+
.all(...ids)
|
|
237
|
+
for (const record of records) {
|
|
238
|
+
const time = seconds(record.timestamp)
|
|
239
|
+
if (record.role === 'user') {
|
|
240
|
+
settleCalls(activity)
|
|
241
|
+
const prompt = text(record.content).trim()
|
|
242
|
+
if (prompt) keepNewest(prompts, { at: time, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
|
|
243
|
+
} else if (record.role === 'assistant') {
|
|
244
|
+
keepText(thoughts, 'thoughts', { at: time, text: text(record.reasoning) || text(record.reasoning_content) })
|
|
245
|
+
keepText(replies, 'replies', { at: time, text: text(record.content) })
|
|
246
|
+
for (const call of (record.tool_calls && parseJson(record.tool_calls, [])) || []) {
|
|
247
|
+
if (!call?.id) continue
|
|
248
|
+
const args = callArgs(call)
|
|
249
|
+
startCall(activity, { id: text(call.id), name: text(call.function?.name), target: toolTarget(args, cwd), input: toolInput(args), ...toolFile(args, cwd), startedAt: time })
|
|
250
|
+
}
|
|
251
|
+
} else if (record.role === 'tool' && record.tool_call_id) {
|
|
252
|
+
endCall(activity, text(record.tool_call_id), time, null)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!open) settleCalls(activity)
|
|
256
|
+
|
|
257
|
+
const subagents = rows
|
|
258
|
+
.filter((r) => isErrand(r) && ids.includes(r.parent_session_id))
|
|
259
|
+
.map((r) => {
|
|
260
|
+
const working = turnOpen(r, chainOf(r).at(-1))
|
|
261
|
+
const label = TOOL_LABEL.exec(text(r.last_activity_description))
|
|
262
|
+
const tokens = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens'].map((c) => (r[c] == null ? null : number(r[c])))
|
|
263
|
+
return {
|
|
264
|
+
id: r.id,
|
|
265
|
+
name: text(r.title) || 'Subagent',
|
|
266
|
+
type: text(r.source) === 'subagent' ? '' : 'delegate',
|
|
267
|
+
status: working ? 'working' : FAILED_END.test(text(r.end_reason)) ? 'error' : r.ended_at != null ? 'done' : 'idle',
|
|
268
|
+
model: text(r.model),
|
|
269
|
+
tokens: tokens.some((t) => t != null) ? { input: tokens[0] || 0, output: tokens[1] || 0, cacheRead: tokens[2] || 0, cacheWrite: tokens[3] || 0 } : null,
|
|
270
|
+
tool: working && label ? toolNow({ name: label[1].trim(), startedAt: seconds(r.last_activity_at) }) : null,
|
|
271
|
+
startedAt: seconds(r.started_at),
|
|
272
|
+
lastActivityAt: lastAt(r),
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
.sort((a, b) => a.startedAt - b.startedAt)
|
|
276
|
+
.slice(-DETAIL_LIMITS.subagents)
|
|
277
|
+
|
|
278
|
+
const models = new Map()
|
|
279
|
+
for (const row of chain) {
|
|
280
|
+
const model = text(row.model)
|
|
281
|
+
if (!model) continue
|
|
282
|
+
const entry = models.get(model) || { model, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, costUsd: null }
|
|
283
|
+
entry.tokens.input += number(row.input_tokens)
|
|
284
|
+
entry.tokens.output += number(row.output_tokens)
|
|
285
|
+
entry.tokens.cacheRead += number(row.cache_read_tokens)
|
|
286
|
+
entry.tokens.cacheWrite += number(row.cache_write_tokens)
|
|
287
|
+
const cost = row.actual_cost_usd ?? row.estimated_cost_usd
|
|
288
|
+
if (cost != null) entry.costUsd = (entry.costUsd || 0) + number(cost)
|
|
289
|
+
models.set(model, entry)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const errors = chain
|
|
293
|
+
.filter((r) => FAILED_END.test(text(r.end_reason)))
|
|
294
|
+
.map((r) => ({ at: seconds(r.ended_at), message: `The session ended: ${text(r.end_reason)}` }))
|
|
295
|
+
.reverse()
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
models: [...models.values()],
|
|
299
|
+
history: null,
|
|
300
|
+
compactions: null,
|
|
301
|
+
contextParts: null,
|
|
302
|
+
...activityDetail(activity),
|
|
303
|
+
plan: planItems(latestPlan(db, ids, messages)),
|
|
304
|
+
subagents,
|
|
305
|
+
files: null,
|
|
306
|
+
prompts,
|
|
307
|
+
thoughts: reasons ? textList(thoughts) : null,
|
|
308
|
+
replies: textList(replies),
|
|
309
|
+
contextBreakdown: null,
|
|
310
|
+
errors,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function openCalls(db, sessionId, messages, visible, cwd) {
|
|
315
|
+
if (!['tool_calls', 'tool_call_id', 'timestamp'].every((c) => messages.has(c))) return []
|
|
316
|
+
const last = db
|
|
317
|
+
.prepare(`SELECT id, tool_calls, timestamp FROM messages WHERE session_id = ? AND role = 'assistant' ${visible} ORDER BY id DESC LIMIT 1`)
|
|
318
|
+
.get(sessionId)
|
|
319
|
+
if (!last?.tool_calls) return []
|
|
320
|
+
let calls
|
|
321
|
+
try {
|
|
322
|
+
calls = JSON.parse(last.tool_calls)
|
|
323
|
+
} catch {
|
|
324
|
+
return []
|
|
325
|
+
}
|
|
326
|
+
const answered = new Set(
|
|
327
|
+
db.prepare(`SELECT tool_call_id FROM messages WHERE session_id = ? AND role = 'tool' AND id > ?`).all(sessionId, last.id).map((r) => r.tool_call_id),
|
|
328
|
+
)
|
|
329
|
+
return (Array.isArray(calls) ? calls : [])
|
|
330
|
+
.filter((c) => c?.id && !answered.has(c.id))
|
|
331
|
+
.map((c) => openTool(text(c.function?.name), seconds(last.timestamp), callArgs(c), cwd))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const callArgs = (call) => (typeof call.function?.arguments === 'string' ? parseJson(call.function.arguments, null) : call.function?.arguments)
|
|
335
|
+
|
|
336
|
+
function turnStart(db, ids, messages, visible) {
|
|
337
|
+
if (!messages.has('timestamp')) return null
|
|
338
|
+
const row = db
|
|
339
|
+
.prepare(`SELECT MAX(timestamp) AS at FROM messages WHERE session_id IN (${ids.map(() => '?').join(', ')}) AND role = 'user' ${visible}`)
|
|
340
|
+
.get(...ids)
|
|
341
|
+
return row?.at == null ? null : seconds(row.at)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function latestPlan(db, ids, messages) {
|
|
345
|
+
if (!messages.has('tool_name') || !messages.has('timestamp')) return null
|
|
346
|
+
const row = db
|
|
347
|
+
.prepare(
|
|
348
|
+
`SELECT content FROM messages WHERE session_id IN (${ids.map(() => '?').join(', ')}) AND role = 'tool' AND tool_name IN (${TODO_TOOLS.map(() => '?').join(', ')}) ORDER BY timestamp DESC, id DESC LIMIT 1`,
|
|
349
|
+
)
|
|
350
|
+
.get(...ids, ...TODO_TOOLS)
|
|
351
|
+
try {
|
|
352
|
+
const todos = JSON.parse(row?.content || '{}').todos
|
|
353
|
+
return Array.isArray(todos) ? todos.map((t) => ({ content: text(t?.content), status: text(t?.status) || 'pending' })) : null
|
|
354
|
+
} catch {
|
|
355
|
+
return null
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function sum(chain, column) {
|
|
360
|
+
let total = 0
|
|
361
|
+
let known = false
|
|
362
|
+
for (const row of chain) {
|
|
363
|
+
if (row[column] == null) continue
|
|
364
|
+
total += number(row[column])
|
|
365
|
+
known = true
|
|
366
|
+
}
|
|
367
|
+
return known ? total : null
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function toSession({ db, profile, tip, root, chain, ids, open, errands, workingSubagents, lengths, home, messages, visible, at, lastAt }) {
|
|
371
|
+
const description = text(tip.last_activity_description)
|
|
372
|
+
const placeholders = ids.map(() => '?').join(', ')
|
|
373
|
+
const calls = open ? openCalls(db, tip.id, messages, visible, text(tip.cwd)) : []
|
|
374
|
+
const working = calls.filter((c) => !DELEGATE_TOOLS.has(c.name))
|
|
375
|
+
const asking = ASKING.exec(description)
|
|
376
|
+
let status
|
|
377
|
+
let statusReason
|
|
378
|
+
if (open) {
|
|
379
|
+
if (asking) [status, statusReason] = ['asking', /approval/i.test(asking[1]) ? 'approval' : 'question']
|
|
380
|
+
else if (!working.length && (calls.length || errands)) [status, statusReason] = ['delegating', 'subagents']
|
|
381
|
+
else [status, statusReason] = ['working', 'turn-open']
|
|
382
|
+
} else if (errands) [status, statusReason] = ['delegating', 'subagents']
|
|
383
|
+
else if (tip.last_read_at != null) [status, statusReason] = seconds(tip.last_activity_at || tip.started_at) > seconds(tip.last_read_at) ? ['done', 'unread'] : ['idle', 'read']
|
|
384
|
+
else [status, statusReason] = tip.ended_at == null && at - lastAt < ACTIVE_WINDOW_MS ? ['done', 'recent'] : ['idle', 'quiet']
|
|
385
|
+
|
|
386
|
+
let tool = null
|
|
387
|
+
if (status === 'working' || status === 'delegating') {
|
|
388
|
+
const latest = (working.length ? working : calls).at(-1)
|
|
389
|
+
const label = TOOL_LABEL.exec(description)
|
|
390
|
+
if (latest) tool = toolNow(latest)
|
|
391
|
+
else if (label) tool = toolNow({ name: label[1].trim(), startedAt: seconds(tip.last_activity_at) })
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const where = text(tip.git_repo_root) || text(tip.cwd)
|
|
395
|
+
const userHome = os.homedir()
|
|
396
|
+
const atHome = !where || where === userHome || where === home || where === path.join(userHome, '.hermes')
|
|
397
|
+
const anchor = tip.config._usage_anchor
|
|
398
|
+
const used = anchor ? number(anchor.prompt_tokens) + number(anchor.completion_tokens) : 0
|
|
399
|
+
const reasoning = tip.config.reasoning_config
|
|
400
|
+
const tokens = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens'].map((c) => sum(chain, c))
|
|
401
|
+
const costs = chain.map((r) => r.actual_cost_usd ?? r.estimated_cost_usd)
|
|
402
|
+
const firstPrompt = db
|
|
403
|
+
.prepare(`SELECT content FROM messages WHERE session_id = ? AND role = 'user' ORDER BY id ASC LIMIT 1`)
|
|
404
|
+
.get(root.id)?.content
|
|
405
|
+
const prompts = db.prepare(`SELECT COUNT(*) AS n FROM messages WHERE session_id IN (${placeholders}) AND role = 'user' ${visible}`).get(...ids)
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
id: `hermes:${profile}:${root.id}`,
|
|
409
|
+
harness: 'hermes',
|
|
410
|
+
title: titleFromPrompt(text(tip.title) || text(root.title) || text(firstPrompt)) || 'Untitled session',
|
|
411
|
+
project: atHome ? 'Hermes' : path.basename(where.replace(/\\/g, '/')),
|
|
412
|
+
projectPath: atHome ? userHome : where,
|
|
413
|
+
worktree: '',
|
|
414
|
+
cwd: text(tip.cwd),
|
|
415
|
+
gitBranch: text(tip.git_branch),
|
|
416
|
+
model: text(tip.model),
|
|
417
|
+
effort: reasoning && reasoning.enabled !== false ? text(reasoning.effort) : '',
|
|
418
|
+
status,
|
|
419
|
+
live: open,
|
|
420
|
+
startedAt: seconds(root.started_at),
|
|
421
|
+
lastActivityAt: lastAt,
|
|
422
|
+
tool,
|
|
423
|
+
tokens: tokens.some((t) => t != null)
|
|
424
|
+
? { input: tokens[0] || 0, output: tokens[1] || 0, cacheRead: tokens[2] || 0, cacheWrite: tokens[3] || 0 }
|
|
425
|
+
: null,
|
|
426
|
+
context: used ? { used, limit: contextLimit(lengths, text(tip.model), tip.billing_base_url) } : null,
|
|
427
|
+
contextHistory: null,
|
|
428
|
+
costUsd: costs.some((c) => c != null) ? costs.reduce((total, c) => total + number(c), 0) : null,
|
|
429
|
+
lines: null,
|
|
430
|
+
plan: planSummary(latestPlan(db, ids, messages)),
|
|
431
|
+
subagents: errands,
|
|
432
|
+
workingSubagents,
|
|
433
|
+
prompts: number(prompts?.n),
|
|
434
|
+
turnStartedAt: open ? turnStart(db, ids, messages, visible) : null,
|
|
435
|
+
statusReason,
|
|
436
|
+
ask: askOf(status),
|
|
437
|
+
usageLimits: null,
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export default hermes()
|