@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.
@@ -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) || titleFromPrompt(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()
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()