opencode-global-habit-profile 0.0.0-stage → 0.2.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/src/mcp.js ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from "node:readline"
3
+ import { getData, listCheckpoints, listCheckpointContexts, saveCheckpoint, deleteCheckpoint, clearCheckpoints } from "./store.js"
4
+
5
+ const tools = [
6
+ { name: "habit_memory_get", description: "Get aggregated habit counts only.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
7
+ { name: "checkpoint_list", description: "List checkpoints for a project and context.", inputSchema: { type: "object", properties: { project: { type: "string", maxLength: 128 }, context: { type: "string", maxLength: 128 } }, required: ["project", "context"], additionalProperties: false } },
8
+ { name: "checkpoint_projects", description: "List project scope hashes already present, for sharing plugin checkpoints across clients.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
9
+ { name: "checkpoint_contexts", description: "List checkpoint contexts for a project scope.", inputSchema: { type: "object", properties: { project: { type: "string", maxLength: 128 } }, required: ["project"], additionalProperties: false } },
10
+ { name: "checkpoint_save", description: "Save an explicit short checkpoint; never pass prompts or transcripts.", inputSchema: { type: "object", properties: { project: { type: "string", maxLength: 128 }, context: { type: "string", maxLength: 128 }, summary: { type: "string", maxLength: 240 } }, required: ["project", "context", "summary"], additionalProperties: false } },
11
+ { name: "checkpoint_delete", description: "Delete one checkpoint by ID.", inputSchema: { type: "object", properties: { project: { type: "string", maxLength: 128 }, context: { type: "string", maxLength: 128 }, id: { type: "string", maxLength: 80 } }, required: ["project", "context", "id"], additionalProperties: false } },
12
+ { name: "checkpoint_clear", description: "Explicitly delete all checkpoints for one project/context.", inputSchema: { type: "object", properties: { project: { type: "string", maxLength: 128 }, context: { type: "string", maxLength: 128 }, confirm: { type: "boolean", const: true } }, required: ["project", "context", "confirm"], additionalProperties: false } },
13
+ ]
14
+ const reply = (id, result, error) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, ...(error ? { error: { code: -32603, message: error.message } } : { result }) })}\n`)
15
+ async function call(name, a) {
16
+ if (!a || Object.keys(a).some((k) => !({ habit_memory_get: [], checkpoint_list: ["project","context"], checkpoint_projects: [], checkpoint_contexts: ["project"], checkpoint_save: ["project","context","summary"], checkpoint_delete: ["project","context","id"], checkpoint_clear: ["project","context","confirm"] }[name] ?? []).includes(k))) throw new Error("Invalid tool arguments")
17
+ if (name === "habit_memory_get" && Object.keys(a).length === 0) return (await getData()).habits
18
+ if (name === "checkpoint_projects" && Object.keys(a).length === 0) return [...new Set((await getData()).checkpoints.map((c) => c.project))]
19
+ if (name === "checkpoint_contexts") return listCheckpointContexts(a.project)
20
+ if (name === "checkpoint_list") return (await listCheckpoints(a.project, a.context)).map(({ id, project, context, summary, createdAt }) => ({ id, project, context, summary, createdAt }))
21
+ if (name === "checkpoint_save") return saveCheckpoint(a.project, a.context, a.summary)
22
+ if (name === "checkpoint_delete") return deleteCheckpoint(a.project, a.context, a.id)
23
+ if (name === "checkpoint_clear" && a.confirm === true) return clearCheckpoints(a.project, a.context)
24
+ throw new Error("Unknown tool or explicit confirmation required")
25
+ }
26
+ const rl = createInterface({ input: process.stdin })
27
+ for await (const line of rl) {
28
+ try {
29
+ const msg = JSON.parse(line)
30
+ if (msg.method === "initialize") reply(msg.id, { protocolVersion: msg.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "habit-memory", version: "1.0.0" } })
31
+ else if (msg.method === "notifications/initialized" || msg.id === undefined) continue
32
+ else if (msg.method === "ping") reply(msg.id, {})
33
+ else if (msg.method === "tools/list") reply(msg.id, { tools })
34
+ else if (msg.method === "tools/call") {
35
+ try { const result = await call(msg.params?.name, msg.params?.arguments ?? {}); reply(msg.id, { content: [{ type: "text", text: JSON.stringify(result) }] }) }
36
+ catch (error) { reply(msg.id, { content: [{ type: "text", text: error.message }], isError: true }) }
37
+ } else reply(msg.id, null, new Error("Method not found"))
38
+ } catch (error) { reply(null, null, error) }
39
+ }
package/src/metrics.js ADDED
@@ -0,0 +1,614 @@
1
+ import { createHash } from "node:crypto"
2
+ import { chmodSync, existsSync, mkdirSync } from "node:fs"
3
+ import { basename, dirname, join } from "node:path"
4
+ import { DatabaseSync } from "node:sqlite"
5
+ import { storePath } from "./store.js"
6
+ import { recordCollectorFlush, recordCollectorPending } from "./plugin-stats.js"
7
+
8
+ // F2 contract: OpenCode v2.2.7 events are `{ id, created, type, data }`.
9
+ // normalizeEvent also tolerates `properties` / `createdAt` / `eventID` so a
10
+ // future or alternate runtime does not silently drop data. Live ingestion uses
11
+ // session.step.ended and session.usage.recorded; backfill uses Session.Info
12
+ // (one approximate row per session, source = backfill).
13
+ export const metricsPath = join(dirname(storePath), "metrics.db")
14
+ export const retentionDays = 90
15
+ const stepKinds = ["primary", "subagent", "title", "compaction", "generate", "other"]
16
+ const sources = ["live", "backfill"]
17
+ const defaultRangeDays = 30
18
+ const maxRangeDays = 90
19
+ const topLimit = 50
20
+
21
+ const schema = `
22
+ CREATE TABLE IF NOT EXISTS steps (
23
+ id INTEGER PRIMARY KEY,
24
+ day TEXT NOT NULL,
25
+ created_at INTEGER NOT NULL,
26
+ -- sha256("message:" + assistantMessageID) for steps; sha256("event:" + event.id) for usage events.
27
+ message_hash TEXT NOT NULL UNIQUE,
28
+ session_hash TEXT NOT NULL,
29
+ parent_hash TEXT,
30
+ project_hash TEXT,
31
+ directory TEXT,
32
+ agent TEXT,
33
+ provider TEXT,
34
+ model TEXT,
35
+ variant TEXT,
36
+ kind TEXT NOT NULL CHECK (kind IN (${stepKinds.map((kind) => `'${kind}'`).join(", ")})),
37
+ source TEXT NOT NULL DEFAULT 'live' CHECK (source IN (${sources.map((source) => `'${source}'`).join(", ")})),
38
+ input_tokens INTEGER NOT NULL DEFAULT 0,
39
+ output_tokens INTEGER NOT NULL DEFAULT 0,
40
+ reasoning_tokens INTEGER NOT NULL DEFAULT 0,
41
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
42
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
43
+ cost REAL NOT NULL DEFAULT 0,
44
+ -- 1 when the runtime reported a positive cost; 0 means missing/free.
45
+ priced INTEGER NOT NULL DEFAULT 0 CHECK (priced IN (0, 1))
46
+ );
47
+ CREATE INDEX IF NOT EXISTS steps_day_idx ON steps(day);
48
+ CREATE INDEX IF NOT EXISTS steps_session_idx ON steps(session_hash);
49
+ CREATE TABLE IF NOT EXISTS monthly (
50
+ month TEXT NOT NULL,
51
+ project_hash TEXT NOT NULL DEFAULT '',
52
+ agent TEXT NOT NULL DEFAULT '',
53
+ provider TEXT NOT NULL DEFAULT '',
54
+ model TEXT NOT NULL DEFAULT '',
55
+ kind TEXT NOT NULL,
56
+ steps INTEGER NOT NULL,
57
+ input_tokens INTEGER NOT NULL,
58
+ output_tokens INTEGER NOT NULL,
59
+ reasoning_tokens INTEGER NOT NULL,
60
+ cache_read_tokens INTEGER NOT NULL,
61
+ cache_write_tokens INTEGER NOT NULL,
62
+ cost REAL NOT NULL,
63
+ priced_steps INTEGER NOT NULL,
64
+ PRIMARY KEY (month, project_hash, agent, provider, model, kind)
65
+ );
66
+ CREATE TABLE IF NOT EXISTS meta (
67
+ key TEXT PRIMARY KEY,
68
+ value TEXT NOT NULL
69
+ );
70
+ `
71
+
72
+ const hash = (value) => createHash("sha256").update(value).digest("hex")
73
+ const isId = (value) => typeof value === "string" && value.length > 0 && value.length <= 512 && !/[\u0000-\u001f\u007f]/.test(value)
74
+ const requiredId = (value, name) => {
75
+ if (!isId(value)) throw new Error(`Invalid metrics ${name}`)
76
+ return value
77
+ }
78
+ const count = (value) => {
79
+ if (value === undefined) return 0
80
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error("Invalid token count")
81
+ return value
82
+ }
83
+ const money = (value) => {
84
+ if (value === undefined) return 0
85
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("Invalid cost")
86
+ return value
87
+ }
88
+ const label = (value, max, allowSlash) => {
89
+ if (value === undefined || value === null) return null
90
+ if (typeof value !== "string") throw new Error("Invalid metrics label")
91
+ const trimmed = value.trim()
92
+ if (!trimmed) return null
93
+ if (trimmed.length > max || /[\u0000-\u001f\u007f]/.test(trimmed) || trimmed.includes("://") || trimmed.startsWith("/") || trimmed.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.includes("..")) throw new Error("Invalid metrics label")
94
+ const pattern = allowSlash ? /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/ : /^[A-Za-z0-9][A-Za-z0-9._-]*$/
95
+ if (!pattern.test(trimmed)) throw new Error("Invalid metrics label")
96
+ return trimmed
97
+ }
98
+ const directoryName = (value) => {
99
+ if (typeof value !== "string") return null
100
+ const trimmed = value.trim()
101
+ if (!trimmed || trimmed.length > 4096) return null
102
+ const name = basename(trimmed)
103
+ return name && name !== "." && name !== ".." && name.length <= 128 && !/[\\/\u0000-\u001f\u007f]/.test(name) ? name : null
104
+ }
105
+ const tokenUsage = (value) => {
106
+ if (value === undefined) return { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }
107
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid token usage")
108
+ const cache = value.cache === undefined ? {} : value.cache
109
+ if (!cache || typeof cache !== "object" || Array.isArray(cache)) throw new Error("Invalid token cache usage")
110
+ return { input: count(value.input), output: count(value.output), reasoning: count(value.reasoning), read: count(cache.read), write: count(cache.write) }
111
+ }
112
+ const eventData = (event) => event?.data ?? event?.properties?.data ?? event?.properties
113
+
114
+ function buildRow({ sessionID, messageHash, created, tokens, cost, kind, source, session }) {
115
+ const dimensions = session && typeof session === "object" && !Array.isArray(session) ? session : {}
116
+ const model = dimensions.model
117
+ const modelID = typeof model === "string" ? model : model?.id
118
+ const providerID = typeof model === "string" ? undefined : model?.providerID
119
+ const variant = typeof model === "string" ? undefined : model?.variant
120
+ return {
121
+ day: new Date(created).toISOString().slice(0, 10),
122
+ created_at: created,
123
+ message_hash: messageHash,
124
+ session_hash: hash(sessionID),
125
+ parent_hash: isId(dimensions.parentID) ? hash(dimensions.parentID) : null,
126
+ project_hash: isId(dimensions.projectID) ? hash(dimensions.projectID) : null,
127
+ directory: directoryName(dimensions.location?.directory),
128
+ agent: label(dimensions.agent, 64, false),
129
+ provider: label(providerID, 64, true),
130
+ model: label(modelID, 128, true),
131
+ variant: label(variant, 64, true),
132
+ kind,
133
+ source,
134
+ input_tokens: tokens.input,
135
+ output_tokens: tokens.output,
136
+ reasoning_tokens: tokens.reasoning,
137
+ cache_read_tokens: tokens.read,
138
+ cache_write_tokens: tokens.write,
139
+ cost,
140
+ priced: cost > 0 ? 1 : 0,
141
+ }
142
+ }
143
+
144
+ export function normalizeEvent(event, { session, now = Date.now } = {}) {
145
+ if (!event || typeof event !== "object" || Array.isArray(event)) throw new Error("Invalid metrics event")
146
+ if (event.type !== "session.step.ended" && event.type !== "session.usage.recorded") return undefined
147
+ const data = eventData(event)
148
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Invalid metrics event data")
149
+ const sessionID = requiredId(data.sessionID, "sessionID")
150
+ const created = event.created ?? event.createdAt ?? now()
151
+ if (!Number.isSafeInteger(created) || created < 0) throw new Error("Invalid metrics timestamp")
152
+ const tokens = tokenUsage(data.tokens)
153
+ const cost = money(data.cost)
154
+ const step = event.type === "session.step.ended"
155
+ const messageHash = step
156
+ ? hash(`message:${requiredId(data.assistantMessageID, "assistantMessageID")}`)
157
+ : hash(`event:${requiredId(event.id ?? event.eventID ?? `${sessionID}:${data.source}:${created}`, "event id")}`)
158
+ const kind = step
159
+ ? (isId(session?.parentID) ? "subagent" : "primary")
160
+ : (["title", "compaction", "generate"].includes(data.source) ? data.source : "other")
161
+ return buildRow({ sessionID, messageHash, created, tokens, cost, kind, source: "live", session })
162
+ }
163
+
164
+ export function normalizeSession(session, { now = Date.now } = {}) {
165
+ if (!session || typeof session !== "object" || Array.isArray(session)) throw new Error("Invalid metrics session")
166
+ const sessionID = requiredId(session.id, "session id")
167
+ const created = session.time?.updated ?? session.time?.created ?? now()
168
+ if (!Number.isSafeInteger(created) || created < 0) throw new Error("Invalid metrics timestamp")
169
+ return buildRow({
170
+ sessionID,
171
+ messageHash: hash(`session:${sessionID}`),
172
+ created,
173
+ tokens: tokenUsage(session.tokens),
174
+ cost: money(session.cost),
175
+ kind: isId(session.parentID) ? "subagent" : "primary",
176
+ source: "backfill",
177
+ session,
178
+ })
179
+ }
180
+
181
+ function migrate(db) {
182
+ const columns = new Set(db.prepare("PRAGMA table_info(steps)").all().map((column) => column.name))
183
+ if (!columns.has("source")) db.exec("ALTER TABLE steps ADD COLUMN source TEXT NOT NULL DEFAULT 'live'")
184
+ db.exec("CREATE INDEX IF NOT EXISTS steps_session_source_idx ON steps(session_hash, source)")
185
+ }
186
+
187
+ function openDatabase(path) {
188
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
189
+ const db = new DatabaseSync(path)
190
+ db.exec("PRAGMA journal_mode = WAL")
191
+ db.exec("PRAGMA busy_timeout = 5000")
192
+ db.exec("PRAGMA synchronous = NORMAL")
193
+ db.exec(schema)
194
+ migrate(db)
195
+ secure(path)
196
+ return db
197
+ }
198
+
199
+ function secure(path) {
200
+ for (const suffix of ["", "-wal", "-shm"]) {
201
+ try { chmodSync(`${path}${suffix}`, 0o600) } catch (error) { if (error.code !== "ENOENT") throw error }
202
+ }
203
+ }
204
+
205
+ export function createMetricsStore({ path = metricsPath, now = Date.now } = {}) {
206
+ if (typeof path !== "string" || !path) throw new Error("metrics path is required")
207
+ if (typeof now !== "function") throw new Error("now must be a function")
208
+ const db = openDatabase(path)
209
+ const insert = db.prepare(`INSERT INTO steps (
210
+ day, created_at, message_hash, session_hash, parent_hash, project_hash, directory,
211
+ agent, provider, model, variant, kind, source, input_tokens, output_tokens, reasoning_tokens,
212
+ cache_read_tokens, cache_write_tokens, cost, priced
213
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(message_hash) DO NOTHING`)
214
+ const deleteOld = db.prepare("DELETE FROM steps WHERE day < ?")
215
+ const liveSession = db.prepare("SELECT 1 AS found FROM steps WHERE session_hash = ? AND source = 'live' LIMIT 1")
216
+ const getMetaStatement = db.prepare("SELECT value FROM meta WHERE key = ?")
217
+ const setMetaStatement = db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
218
+ let closed = false
219
+
220
+ const ensureOpen = () => { if (closed) throw new Error("Metrics store is closed") }
221
+ const cutoffDay = () => {
222
+ const date = new Date(now())
223
+ if (!Number.isSafeInteger(date.getTime())) throw new Error("Invalid metrics clock")
224
+ const cutoff = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
225
+ cutoff.setUTCDate(cutoff.getUTCDate() - (retentionDays - 1))
226
+ return cutoff.toISOString().slice(0, 10)
227
+ }
228
+ const insertRows = (rows, { skipLive = false } = {}) => {
229
+ ensureOpen()
230
+ if (!rows.length) return 0
231
+ let inserted = 0
232
+ db.exec("BEGIN IMMEDIATE")
233
+ try {
234
+ for (const row of rows) {
235
+ if (skipLive && liveSession.get(row.session_hash)) continue
236
+ inserted += Number(insert.run(
237
+ row.day, row.created_at, row.message_hash, row.session_hash, row.parent_hash, row.project_hash, row.directory,
238
+ row.agent, row.provider, row.model, row.variant, row.kind, row.source, row.input_tokens, row.output_tokens, row.reasoning_tokens,
239
+ row.cache_read_tokens, row.cache_write_tokens, row.cost, row.priced,
240
+ ).changes)
241
+ }
242
+ deleteOld.run(cutoffDay())
243
+ db.exec("COMMIT")
244
+ secure(path)
245
+ } catch (error) {
246
+ try { db.exec("ROLLBACK") } catch {}
247
+ throw error
248
+ }
249
+ return inserted
250
+ }
251
+ const normalizeSessions = (sessions) => {
252
+ const rows = []
253
+ for (const session of sessions) {
254
+ try {
255
+ const row = normalizeSession(session, { now })
256
+ if (row) rows.push(row)
257
+ } catch {}
258
+ }
259
+ return rows
260
+ }
261
+
262
+ return {
263
+ path,
264
+ ingestEvent(event, { session } = {}) {
265
+ const row = normalizeEvent(event, { session, now })
266
+ return row ? insertRows([row]) : 0
267
+ },
268
+ ingestEvents(events, { sessions } = {}) {
269
+ if (!Array.isArray(events)) throw new Error("events must be an array")
270
+ const rows = []
271
+ for (const event of events) {
272
+ try {
273
+ const data = eventData(event)
274
+ const session = sessions instanceof Map ? sessions.get(data?.sessionID) : undefined
275
+ const row = normalizeEvent(event, { session, now })
276
+ if (row) rows.push(row)
277
+ } catch {}
278
+ }
279
+ return insertRows(rows)
280
+ },
281
+ ingestSessions(sessions) {
282
+ if (!Array.isArray(sessions)) throw new Error("sessions must be an array")
283
+ return insertRows(normalizeSessions(sessions), { skipLive: true })
284
+ },
285
+ getMeta(key) {
286
+ ensureOpen()
287
+ if (typeof key !== "string" || !key || key.length > 128) throw new Error("Invalid metrics meta key")
288
+ return getMetaStatement.get(key)?.value
289
+ },
290
+ setMeta(key, value) {
291
+ ensureOpen()
292
+ if (typeof key !== "string" || !key || key.length > 128) throw new Error("Invalid metrics meta key")
293
+ if (typeof value !== "string" || value.length > 512) throw new Error("Invalid metrics meta value")
294
+ setMetaStatement.run(key, value)
295
+ },
296
+ prune() {
297
+ ensureOpen()
298
+ const changes = Number(deleteOld.run(cutoffDay()).changes)
299
+ secure(path)
300
+ return changes
301
+ },
302
+ rebuildMonthly() {
303
+ ensureOpen()
304
+ db.exec("BEGIN IMMEDIATE")
305
+ try {
306
+ db.exec("DELETE FROM monthly")
307
+ db.exec(`INSERT INTO monthly (
308
+ month, project_hash, agent, provider, model, kind, steps, input_tokens, output_tokens,
309
+ reasoning_tokens, cache_read_tokens, cache_write_tokens, cost, priced_steps
310
+ ) SELECT substr(day, 1, 7), coalesce(project_hash, ''), coalesce(agent, ''), coalesce(provider, ''),
311
+ coalesce(model, ''), kind, count(*), sum(input_tokens), sum(output_tokens), sum(reasoning_tokens),
312
+ sum(cache_read_tokens), sum(cache_write_tokens), sum(cost), sum(priced)
313
+ FROM steps
314
+ GROUP BY substr(day, 1, 7), coalesce(project_hash, ''), coalesce(agent, ''), coalesce(provider, ''), coalesce(model, ''), kind`)
315
+ db.exec("COMMIT")
316
+ } catch (error) {
317
+ try { db.exec("ROLLBACK") } catch {}
318
+ throw error
319
+ }
320
+ },
321
+ close() {
322
+ if (closed) return
323
+ closed = true
324
+ db.close()
325
+ },
326
+ }
327
+ }
328
+
329
+ export function createMetricsCollector({
330
+ store,
331
+ subscribe,
332
+ getSession,
333
+ now = Date.now,
334
+ flushInterval = 15_000,
335
+ flushSize = 500,
336
+ cacheSize = 512,
337
+ } = {}) {
338
+ if (!store || typeof store.ingestEvents !== "function" || typeof store.getMeta !== "function" || typeof store.setMeta !== "function") throw new Error("metrics store is required")
339
+ if (typeof subscribe !== "function") throw new Error("subscribe is required")
340
+ if (!Number.isSafeInteger(flushInterval) || flushInterval < 0) throw new Error("flushInterval must be a non-negative integer")
341
+ if (!Number.isSafeInteger(flushSize) || flushSize < 1) throw new Error("flushSize must be a positive integer")
342
+ if (!Number.isSafeInteger(cacheSize) || cacheSize < 1) throw new Error("cacheSize must be a positive integer")
343
+
344
+ const sessions = new Map()
345
+ const pending = []
346
+ const controller = new AbortController()
347
+ let timer
348
+ let closed = false
349
+ let lastError
350
+
351
+ const remember = (sessionID, session) => {
352
+ if (sessions.has(sessionID)) sessions.delete(sessionID)
353
+ else if (sessions.size >= cacheSize) sessions.delete(sessions.keys().next().value)
354
+ sessions.set(sessionID, session)
355
+ }
356
+ const flush = () => {
357
+ if (timer) { clearTimeout(timer); timer = undefined }
358
+ if (!pending.length) return 0
359
+ const batch = pending.splice(0)
360
+ const sessionMap = new Map()
361
+ for (const { event, session } of batch) {
362
+ const sessionID = eventData(event)?.sessionID
363
+ if (typeof sessionID === "string" && session) sessionMap.set(sessionID, session)
364
+ }
365
+ try {
366
+ const inserted = store.ingestEvents(batch.map(({ event }) => event), { sessions: sessionMap })
367
+ recordCollectorFlush(0, false)
368
+ return inserted
369
+ } catch (error) {
370
+ recordCollectorFlush(batch.length, true)
371
+ throw error
372
+ }
373
+ }
374
+ const schedule = () => {
375
+ if (closed) return
376
+ if (timer) clearTimeout(timer)
377
+ timer = setTimeout(() => { timer = undefined; try { flush() } catch {} }, flushInterval)
378
+ timer.unref?.()
379
+ }
380
+ const run = async () => {
381
+ try {
382
+ if (!store.getMeta("source.liveFrom")) store.setMeta("source.liveFrom", new Date(now()).toISOString())
383
+ } catch {}
384
+ try {
385
+ for await (const event of subscribe({ types: ["session.step.ended", "session.usage.recorded"], signal: controller.signal })) {
386
+ if (closed) break
387
+ const sessionID = eventData(event)?.sessionID
388
+ let session
389
+ if (typeof sessionID === "string" && sessionID) {
390
+ if (sessions.has(sessionID)) session = sessions.get(sessionID)
391
+ else if (typeof getSession === "function") {
392
+ try { session = await getSession(sessionID) } catch {}
393
+ }
394
+ if (session) remember(sessionID, session)
395
+ }
396
+ pending.push({ event, session })
397
+ recordCollectorPending(pending.length)
398
+ if (pending.length >= flushSize) { try { flush() } catch {} }
399
+ else schedule()
400
+ }
401
+ } catch (error) {
402
+ if (!controller.signal.aborted) lastError = error
403
+ } finally {
404
+ try { flush() } catch {}
405
+ }
406
+ }
407
+ const done = run()
408
+
409
+ return {
410
+ done,
411
+ flush,
412
+ stop: async () => {
413
+ if (closed) return
414
+ closed = true
415
+ controller.abort()
416
+ if (timer) { clearTimeout(timer); timer = undefined }
417
+ try { flush() } catch {}
418
+ await done
419
+ },
420
+ get error() { return lastError },
421
+ }
422
+ }
423
+
424
+ export async function backfillMetrics({ store, listSessions, now = Date.now, limit = 5000 } = {}) {
425
+ if (!store || typeof store.ingestSessions !== "function" || typeof store.getMeta !== "function" || typeof store.setMeta !== "function") throw new Error("metrics store is required")
426
+ if (typeof listSessions !== "function") throw new Error("listSessions is required")
427
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("limit must be a positive integer")
428
+ let inserted = 0
429
+ let seen = 0
430
+ try {
431
+ if (store.getMeta("source.backfillAt")) return { skipped: true, inserted: 0, seen: 0 }
432
+ const current = now()
433
+ if (!Number.isSafeInteger(current) || current < 0) throw new Error("Invalid metrics clock")
434
+ const cutoff = current - retentionDays * 86_400_000
435
+ let cursor
436
+ while (seen < limit) {
437
+ const result = await listSessions({ limit: 100, order: "desc", cursor })
438
+ const sessions = Array.isArray(result?.data) ? result.data : Array.isArray(result) ? result : []
439
+ if (!sessions.length) break
440
+ const page = []
441
+ let stop = false
442
+ for (const session of sessions) {
443
+ seen++
444
+ const updated = session?.time?.updated ?? session?.time?.created
445
+ if (Number.isFinite(updated) && updated < cutoff) { stop = true; break }
446
+ if (session?.id) page.push(session)
447
+ if (seen >= limit) { stop = true; break }
448
+ }
449
+ if (page.length) inserted += store.ingestSessions(page)
450
+ if (stop) break
451
+ cursor = result?.cursor?.next
452
+ if (!cursor) break
453
+ }
454
+ store.setMeta("source.backfillAt", new Date(current).toISOString())
455
+ return { skipped: false, inserted, seen }
456
+ } catch (error) {
457
+ return { skipped: false, inserted, seen, error: error instanceof Error ? error.message : "Backfill failed" }
458
+ }
459
+ }
460
+
461
+ export class MetricsQueryError extends Error {
462
+ constructor(message) {
463
+ super(message)
464
+ this.name = "MetricsQueryError"
465
+ this.code = "INVALID_METRICS_QUERY"
466
+ }
467
+ }
468
+
469
+ const kindSet = new Set(stepKinds)
470
+ const validDay = (value) => typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10) === value
471
+ const dayOf = (timestamp) => {
472
+ const date = new Date(timestamp)
473
+ if (!Number.isSafeInteger(date.getTime())) throw new MetricsQueryError("Invalid metrics clock")
474
+ return date.toISOString().slice(0, 10)
475
+ }
476
+ const shiftDay = (day, delta) => {
477
+ const date = new Date(`${day}T00:00:00.000Z`)
478
+ date.setUTCDate(date.getUTCDate() + delta)
479
+ return date.toISOString().slice(0, 10)
480
+ }
481
+ const ratio = (value, total) => total > 0 ? Number((value / total).toFixed(4)) : 0
482
+ const cacheHitRate = (tokens) => ratio(tokens.cacheRead, tokens.input + tokens.cacheRead + tokens.cacheWrite)
483
+ const zeroTokens = () => ({ input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 })
484
+ const tokensFrom = (row) => ({
485
+ input: Number(row.input),
486
+ output: Number(row.output),
487
+ reasoning: Number(row.reasoning),
488
+ cacheRead: Number(row.cache_read),
489
+ cacheWrite: Number(row.cache_write),
490
+ })
491
+ const rowSummary = (row, extra = {}) => {
492
+ const steps = Number(row.steps)
493
+ const tokens = tokensFrom(row)
494
+ const pricedSteps = Number(row.priced_steps)
495
+ return {
496
+ key: row.key || null,
497
+ ...extra,
498
+ steps,
499
+ tokens,
500
+ cost: Number(row.cost),
501
+ pricedSteps,
502
+ costCoverage: ratio(pricedSteps, steps),
503
+ cacheHitRate: cacheHitRate(tokens),
504
+ }
505
+ }
506
+ const emptyResult = (from, to) => {
507
+ const days = Math.round((Date.parse(`${to}T00:00:00.000Z`) - Date.parse(`${from}T00:00:00.000Z`)) / 86_400_000) + 1
508
+ const series = []
509
+ for (let day = from; day <= to; day = shiftDay(day, 1)) {
510
+ series.push({ day, steps: 0, tokens: zeroTokens(), cost: 0, pricedSteps: 0, costCoverage: 0, cacheHitRate: 0 })
511
+ if (series.length >= days) break
512
+ }
513
+ return {
514
+ range: { from, to, days },
515
+ totals: { steps: 0, tokens: zeroTokens(), cost: 0, pricedSteps: 0, costCoverage: 0, cacheHitRate: 0 },
516
+ series,
517
+ byAgent: [],
518
+ byModel: [],
519
+ byProject: [],
520
+ byKind: [],
521
+ }
522
+ }
523
+ const filterText = (value, name, allowSlash = true) => {
524
+ if (value === undefined) return undefined
525
+ if (typeof value !== "string") throw new MetricsQueryError(`Invalid ${name}`)
526
+ try { return label(value, name === "agent" ? 64 : 128, allowSlash) ?? undefined } catch { throw new MetricsQueryError(`Invalid ${name}`) }
527
+ }
528
+ const filterProject = (value) => {
529
+ if (value === undefined) return undefined
530
+ if (typeof value !== "string" || !value || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value)) throw new MetricsQueryError("Invalid project")
531
+ return { hash: /^[a-f0-9]{64}$/i.test(value) ? value.toLowerCase() : hash(value), directory: value }
532
+ }
533
+
534
+ export function queryMetrics(filters = {}, { path = metricsPath, now = Date.now } = {}) {
535
+ if (!filters || typeof filters !== "object" || Array.isArray(filters)) throw new MetricsQueryError("Invalid filters")
536
+ const today = dayOf(now())
537
+ const from = filters.from ?? shiftDay(today, -(defaultRangeDays - 1))
538
+ const to = filters.to ?? today
539
+ if (!validDay(from) || !validDay(to)) throw new MetricsQueryError("Invalid date range")
540
+ const fromMs = Date.parse(`${from}T00:00:00.000Z`)
541
+ const toMs = Date.parse(`${to}T00:00:00.000Z`)
542
+ const days = Math.round((toMs - fromMs) / 86_400_000) + 1
543
+ if (days < 1 || days > maxRangeDays) throw new MetricsQueryError("Invalid date range")
544
+ const agent = filterText(filters.agent, "agent", false)
545
+ const provider = filterText(filters.provider, "provider", true)
546
+ const model = filterText(filters.model, "model", true)
547
+ const project = filterProject(filters.project)
548
+ const kind = filters.kind
549
+ if (kind !== undefined && !kindSet.has(kind)) throw new MetricsQueryError("Invalid kind")
550
+ if (!existsSync(path)) return emptyResult(from, to)
551
+
552
+ const params = [from, to]
553
+ let where = "WHERE day >= ? AND day <= ?"
554
+ const add = (clause, ...values) => { where += ` AND ${clause}`; params.push(...values) }
555
+ if (agent !== undefined) add("agent = ?", agent)
556
+ if (provider !== undefined) add("provider = ?", provider)
557
+ if (model !== undefined) add("model = ?", model)
558
+ if (project !== undefined) add("(project_hash = ? OR directory = ?)", project.hash, project.directory)
559
+ if (kind !== undefined) add("kind = ?", kind)
560
+
561
+ const db = new DatabaseSync(path, { readOnly: true })
562
+ try {
563
+ db.exec("PRAGMA busy_timeout = 5000")
564
+ const total = db.prepare(`SELECT count(*) AS steps,
565
+ coalesce(sum(input_tokens), 0) AS input, coalesce(sum(output_tokens), 0) AS output,
566
+ coalesce(sum(reasoning_tokens), 0) AS reasoning, coalesce(sum(cache_read_tokens), 0) AS cache_read,
567
+ coalesce(sum(cache_write_tokens), 0) AS cache_write, coalesce(sum(cost), 0) AS cost,
568
+ coalesce(sum(priced), 0) AS priced_steps FROM steps ${where}`).get(...params)
569
+ const totalTokens = tokensFrom(total)
570
+ const totals = {
571
+ steps: Number(total.steps),
572
+ tokens: totalTokens,
573
+ cost: Number(total.cost),
574
+ pricedSteps: Number(total.priced_steps),
575
+ costCoverage: ratio(Number(total.priced_steps), Number(total.steps)),
576
+ cacheHitRate: cacheHitRate(totalTokens),
577
+ }
578
+ const daily = new Map(db.prepare(`SELECT day, count(*) AS steps,
579
+ coalesce(sum(input_tokens), 0) AS input, coalesce(sum(output_tokens), 0) AS output,
580
+ coalesce(sum(reasoning_tokens), 0) AS reasoning, coalesce(sum(cache_read_tokens), 0) AS cache_read,
581
+ coalesce(sum(cache_write_tokens), 0) AS cache_write, coalesce(sum(cost), 0) AS cost,
582
+ coalesce(sum(priced), 0) AS priced_steps FROM steps ${where} GROUP BY day ORDER BY day`).all(...params).map((row) => {
583
+ const summary = rowSummary(row)
584
+ delete summary.key
585
+ return [row.day, { day: row.day, ...summary }]
586
+ }))
587
+ const series = []
588
+ for (let day = from; day <= to; day = shiftDay(day, 1)) {
589
+ series.push(daily.get(day) ?? { day, steps: 0, tokens: zeroTokens(), cost: 0, pricedSteps: 0, costCoverage: 0, cacheHitRate: 0 })
590
+ if (series.length >= days) break
591
+ }
592
+ const group = (select, groupBy, extra) => db.prepare(`SELECT ${select}, count(*) AS steps,
593
+ coalesce(sum(input_tokens), 0) AS input, coalesce(sum(output_tokens), 0) AS output,
594
+ coalesce(sum(reasoning_tokens), 0) AS reasoning, coalesce(sum(cache_read_tokens), 0) AS cache_read,
595
+ coalesce(sum(cache_write_tokens), 0) AS cache_write, coalesce(sum(cost), 0) AS cost,
596
+ coalesce(sum(priced), 0) AS priced_steps,
597
+ (coalesce(sum(input_tokens), 0) + coalesce(sum(output_tokens), 0) + coalesce(sum(reasoning_tokens), 0)
598
+ + coalesce(sum(cache_read_tokens), 0) + coalesce(sum(cache_write_tokens), 0)) AS total_tokens
599
+ FROM steps ${where} GROUP BY ${groupBy} ORDER BY total_tokens DESC, steps DESC LIMIT ${topLimit}`)
600
+ .all(...params)
601
+ .map((row) => rowSummary(row, extra?.(row) ?? {}))
602
+ return {
603
+ range: { from, to, days },
604
+ totals,
605
+ series,
606
+ byAgent: group("coalesce(agent, '') AS key", "coalesce(agent, '')"),
607
+ byModel: group("coalesce(model, '') AS key, coalesce(provider, '') AS provider", "coalesce(model, ''), coalesce(provider, '')", (row) => ({ provider: row.provider || null })),
608
+ byProject: group("coalesce(project_hash, '') AS key, max(directory) AS directory", "coalesce(project_hash, '')", (row) => ({ directory: row.directory || null })),
609
+ byKind: group("kind AS key", "kind"),
610
+ }
611
+ } finally {
612
+ db.close()
613
+ }
614
+ }