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