@wrongstack/core 0.302.0 → 0.303.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.
Files changed (74) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-status-tracker.d.ts +6 -2
  3. package/dist/chronicle/index.js +1949 -1671
  4. package/dist/chronicle/metrics-store.d.ts +14 -0
  5. package/dist/chronicle/project-server-protocol.d.ts +13 -0
  6. package/dist/chronicle/project-server.js +1756 -1573
  7. package/dist/chronicle/rollup-adapter.d.ts +2 -0
  8. package/dist/chronicle/sqlite-journal.d.ts +59 -0
  9. package/dist/coordination/agents/index.js +4313 -3516
  10. package/dist/coordination/agents/project-agent-auto-optimize.d.ts +116 -0
  11. package/dist/coordination/agents/project-agent-capture-window.d.ts +29 -0
  12. package/dist/coordination/agents/project-agent-config-io.d.ts +11 -0
  13. package/dist/coordination/agents/project-agent-consolidation.d.ts +29 -2
  14. package/dist/coordination/agents/project-agent-files.d.ts +12 -3
  15. package/dist/coordination/agents/project-agent-identity-types.d.ts +4 -0
  16. package/dist/coordination/agents/project-agent-identity.d.ts +22 -9
  17. package/dist/coordination/agents/project-agent-learning-entries.d.ts +8 -2
  18. package/dist/coordination/agents/project-agent-learning-structured.d.ts +27 -1
  19. package/dist/coordination/agents/project-agent-optimizer.d.ts +49 -0
  20. package/dist/coordination/agents/project-agent-skill-layer.d.ts +101 -0
  21. package/dist/coordination/agents/role-skills.d.ts +11 -1
  22. package/dist/coordination/index.d.ts +1 -1
  23. package/dist/coordination/index.js +4927 -3589
  24. package/dist/coordination/mail-tools.d.ts +3 -3
  25. package/dist/core/context.d.ts +4 -0
  26. package/dist/core/continue-intent.d.ts +2 -0
  27. package/dist/core/conversation-state.d.ts +5 -0
  28. package/dist/core/index.js +129 -19
  29. package/dist/defaults/index.js +1620 -768
  30. package/dist/execution/index.js +2941 -2630
  31. package/dist/goal/index.js +7 -0
  32. package/dist/index.d.ts +3 -1
  33. package/dist/index.js +12269 -9212
  34. package/dist/infrastructure/index.js +722 -672
  35. package/dist/kernel/events/agent-events.d.ts +28 -0
  36. package/dist/kernel/events/memory-events.d.ts +62 -0
  37. package/dist/plugin/index.js +2167 -1986
  38. package/dist/security/index.js +69 -3
  39. package/dist/security/kanban-boundary.d.ts +5 -1
  40. package/dist/session-catalog/client.d.ts +62 -0
  41. package/dist/session-catalog/endpoint.d.ts +6 -0
  42. package/dist/session-catalog/index.d.ts +6 -0
  43. package/dist/session-catalog/index.js +2000 -0
  44. package/dist/session-catalog/project-server.d.ts +3 -0
  45. package/dist/session-catalog/project-server.js +1861 -0
  46. package/dist/session-catalog/protocol.d.ts +284 -0
  47. package/dist/session-catalog/registry.d.ts +59 -0
  48. package/dist/session-catalog/store.d.ts +71 -0
  49. package/dist/storage/index.d.ts +42 -38
  50. package/dist/storage/index.js +13896 -12931
  51. package/dist/storage/plan-store.d.ts +1 -1
  52. package/dist/storage/session-event-bridge.d.ts +2 -2
  53. package/dist/storage/session-store.d.ts +6 -0
  54. package/dist/tasking/index.js +5 -0
  55. package/dist/tools/index.js +2832 -2606
  56. package/dist/types/config/root.d.ts +11 -1
  57. package/dist/types/config/skills-fleet-brain.d.ts +34 -0
  58. package/dist/types/config/ui.d.ts +14 -0
  59. package/dist/types/config.d.ts +1 -0
  60. package/dist/types/context-evidence.d.ts +2 -0
  61. package/dist/types/index.d.ts +2 -2
  62. package/dist/types/index.js +20 -0
  63. package/dist/types/messages.d.ts +8 -0
  64. package/dist/types/multi-agent.d.ts +7 -0
  65. package/dist/types/session.d.ts +19 -0
  66. package/dist/types/task-graph.d.ts +2 -0
  67. package/dist/types/tool-executor.d.ts +2 -0
  68. package/dist/utils/context-evidence.d.ts +13 -1
  69. package/dist/utils/index.js +29 -2
  70. package/instructions/system-lite.md +23 -8
  71. package/instructions/system-pro.md +29 -9
  72. package/instructions/system.md +29 -9
  73. package/package.json +7 -3
  74. package/skills/wrongstack-kanban/SKILL.md +39 -8
@@ -1436,9 +1436,9 @@ function errorMessage(error) {
1436
1436
  }
1437
1437
 
1438
1438
  // src/chronicle/metrics-store.ts
1439
- import * as fs6 from "node:fs/promises";
1440
- import { createRequire } from "node:module";
1441
- import * as path9 from "node:path";
1439
+ import * as fs7 from "node:fs/promises";
1440
+ import { createRequire as createRequire2 } from "node:module";
1441
+ import * as path10 from "node:path";
1442
1442
 
1443
1443
  // src/utils/sqlite-warning.ts
1444
1444
  var SQLITE_EXPERIMENTAL_WARNING_RE = /sqlite is an experimental feature/i;
@@ -2264,1127 +2264,1076 @@ function facetValue(event, field) {
2264
2264
  return values[field];
2265
2265
  }
2266
2266
 
2267
- // src/chronicle/metrics-store.ts
2268
- var SCHEMA_VERSION = 3;
2269
- var READ_CHUNK_BYTES = 1024 * 1024;
2270
- var EMPTY_FAMILIES = {
2271
- llm: 0,
2272
- agent: 0,
2273
- tool: 0,
2274
- file: 0,
2275
- memory: 0,
2276
- task: 0,
2277
- decision: 0,
2278
- runtime: 0,
2279
- finding: 0
2280
- };
2281
- var Ctor;
2282
- function loadDatabaseSync() {
2283
- if (Ctor) return Ctor;
2284
- if (Ctor === null) throw new Error("node:sqlite is unavailable in this runtime");
2267
+ // src/chronicle/sqlite-journal.ts
2268
+ import { randomUUID as randomUUID3 } from "node:crypto";
2269
+ import * as fs6 from "node:fs";
2270
+ import { createRequire } from "node:module";
2271
+ import * as path9 from "node:path";
2272
+
2273
+ // src/utils/pid.ts
2274
+ function isPidAlive(pid) {
2275
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2276
+ if (pid === process.pid) return true;
2285
2277
  try {
2286
- Ctor = withSqliteExperimentalWarningSuppressed(
2287
- () => createRequire(import.meta.url)("node:sqlite").DatabaseSync
2288
- );
2289
- return Ctor;
2290
- } catch (error) {
2291
- Ctor = null;
2292
- throw new Error(
2293
- "Chronicle metrics need Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
2294
- );
2278
+ process.kill(pid, 0);
2279
+ return true;
2280
+ } catch (err) {
2281
+ const code = err.code;
2282
+ if (code === "EPERM") return true;
2283
+ return false;
2295
2284
  }
2296
2285
  }
2297
- function isChronicleMetricsAvailable() {
2286
+
2287
+ // src/chronicle/sqlite-query.ts
2288
+ var MAX_LIMIT = 1e4;
2289
+ function encodeCursor2(cursor) {
2290
+ return Buffer.from(`${cursor.day}:${cursor.sequence}`, "utf8").toString("base64url");
2291
+ }
2292
+ function decodeCursor2(raw) {
2293
+ if (!raw) return void 0;
2298
2294
  try {
2299
- loadDatabaseSync();
2300
- return true;
2295
+ const [day, sequence] = Buffer.from(raw, "base64url").toString("utf8").split(":");
2296
+ if (!day || sequence === void 0) return void 0;
2297
+ const parsed = Number(sequence);
2298
+ return Number.isSafeInteger(parsed) ? { day, sequence: parsed } : void 0;
2301
2299
  } catch {
2302
- return false;
2300
+ return void 0;
2303
2301
  }
2304
2302
  }
2305
- var ChronicleMetricsStore = class _ChronicleMetricsStore {
2306
- db;
2307
- directory;
2308
- dbPath;
2309
- constructor(directory) {
2310
- this.directory = path9.resolve(directory);
2311
- this.dbPath = path9.join(this.directory, "metrics.db");
2312
- const Database = loadDatabaseSync();
2313
- this.db = new Database(this.dbPath);
2314
- this.db.exec("PRAGMA journal_mode = WAL");
2315
- this.ensureSchema();
2303
+ function pushDown(query) {
2304
+ const clauses = [];
2305
+ const params = [];
2306
+ const eq = (column, value) => {
2307
+ if (value === void 0) return;
2308
+ clauses.push(`${column} = ?`);
2309
+ params.push(value);
2310
+ };
2311
+ eq("event_id", query.eventId);
2312
+ eq("project_id", query.projectId);
2313
+ eq("session_id", query.sessionId);
2314
+ eq("agent_id", query.agentId);
2315
+ eq("task_id", query.taskId);
2316
+ eq("trace_id", query.traceId);
2317
+ eq("logical_request_id", query.logicalRequestId);
2318
+ eq("resource_kind", query.resourceKind);
2319
+ eq("resource_id", query.resourceId);
2320
+ if (query.eventTypes?.length) {
2321
+ clauses.push(`event_type IN (${query.eventTypes.map(() => "?").join(",")})`);
2322
+ params.push(...query.eventTypes);
2316
2323
  }
2317
- static open(chronicleDirectory) {
2318
- return new _ChronicleMetricsStore(chronicleDirectory);
2324
+ if (query.outcomes?.length) {
2325
+ clauses.push(`outcome IN (${query.outcomes.map(() => "?").join(",")})`);
2326
+ params.push(...query.outcomes);
2319
2327
  }
2320
- close() {
2321
- this.db.close();
2328
+ if (query.from) {
2329
+ clauses.push("occurred_at >= ?");
2330
+ params.push(query.from);
2322
2331
  }
2323
- /** Incrementally ingest journal bytes appended since the last refresh.
2324
- * Safe across processes: guarded by a file lock on the database path. */
2325
- async refresh() {
2332
+ if (query.to) {
2333
+ clauses.push("occurred_at <= ?");
2334
+ params.push(query.to);
2335
+ }
2336
+ return { clause: clauses.length ? clauses.join(" AND ") : "1=1", params };
2337
+ }
2338
+ var ChronicleSqliteQueryEngine = class {
2339
+ constructor(db, options = {}) {
2340
+ this.db = db;
2341
+ this.batchSize = Math.max(1, options.batchSize ?? 1e3);
2342
+ }
2343
+ db;
2344
+ diagnostics = { sourceFiles: 1, invalidLines: 0 };
2345
+ batchSize;
2346
+ async query(query = {}) {
2347
+ const order = query.order ?? "desc";
2348
+ const limit = Math.max(1, Math.min(query.limit ?? 100, MAX_LIMIT));
2349
+ const cursor = decodeCursor2(query.cursor);
2350
+ const pushed = pushDown(query);
2351
+ const direction = order === "asc" ? "ASC" : "DESC";
2352
+ const comparison = order === "asc" ? ">" : "<";
2353
+ const keyset = cursor ? ` AND (day, sequence) ${comparison} (?, ?)` : "";
2354
+ const sql = `SELECT day, sequence, payload FROM events WHERE ${pushed.clause}${keyset} ORDER BY day ${direction}, sequence ${direction} LIMIT ? OFFSET ?`;
2355
+ const summary = createSummaryAccumulator();
2356
+ const page = [];
2357
+ let total = 0;
2358
+ let scannedEvents = 0;
2359
+ let last;
2360
+ let offset = 0;
2361
+ for (; ; ) {
2362
+ const params = [...pushed.params];
2363
+ if (cursor) params.push(cursor.day, cursor.sequence);
2364
+ params.push(this.batchSize, offset);
2365
+ const rows = this.db.prepare(sql).all(...params);
2366
+ if (rows.length === 0) break;
2367
+ offset += rows.length;
2368
+ for (const row of rows) {
2369
+ scannedEvents++;
2370
+ let event;
2371
+ try {
2372
+ event = JSON.parse(row.payload);
2373
+ } catch {
2374
+ this.diagnostics.invalidLines++;
2375
+ continue;
2376
+ }
2377
+ if (!matches(event, query)) continue;
2378
+ total++;
2379
+ updateSummary(summary, event);
2380
+ if (page.length < limit) {
2381
+ page.push(event);
2382
+ last = { day: row.day, sequence: row.sequence };
2383
+ }
2384
+ }
2385
+ if (rows.length < this.batchSize) break;
2386
+ }
2387
+ page.sort((left, right) => compareEvents(left, right) * (order === "asc" ? 1 : -1));
2326
2388
  const result = {
2327
- ingestedEvents: 0,
2328
- ingestedBytes: 0,
2329
- sourceFiles: 0,
2330
- invalidLines: 0
2389
+ events: page,
2390
+ total,
2391
+ scannedEvents,
2392
+ sourceFiles: this.diagnostics.sourceFiles,
2393
+ invalidLines: this.diagnostics.invalidLines,
2394
+ summary: finalizeSummary(summary)
2331
2395
  };
2332
- await withFileLock(this.dbPath, async () => {
2333
- const files = await findChroniclePartitions(this.directory);
2334
- const offsets = this.loadOffsets();
2335
- for (const file of files) {
2336
- const key = normalizeKey(path9.relative(this.directory, file));
2337
- const consumed = offsets.get(key) ?? 0;
2338
- const ingested = await this.ingestFile(file, key, consumed, result);
2339
- if (ingested) result.sourceFiles++;
2340
- }
2341
- this.pruneOffsets(files);
2342
- });
2396
+ if (total > page.length && last) {
2397
+ return { ...result, nextCursor: encodeCursor2(last) };
2398
+ }
2343
2399
  return result;
2344
2400
  }
2345
- providerDaily(options = {}) {
2346
- const clauses = [];
2347
- const params = [];
2348
- if (options.from) {
2349
- clauses.push("day >= ?");
2350
- params.push(options.from.slice(0, 10));
2401
+ /**
2402
+ * Value counts per facet field.
2403
+ *
2404
+ * Not a SQL `GROUP BY`: `facetValue()` reads fields that live inside the
2405
+ * payload — provider, model, tool call, tag — so grouping in SQL would only
2406
+ * work for the handful that happen to be columns and would need a second,
2407
+ * divergent definition for the rest. The narrowing is still done by SQL; the
2408
+ * counting uses the JSONL engine's own projection.
2409
+ */
2410
+ async facets(fields, query = {}, limit = 100) {
2411
+ const uniqueFields = [...new Set(fields)];
2412
+ if (uniqueFields.length === 0) return {};
2413
+ const counts = new Map(uniqueFields.map((field) => [field, /* @__PURE__ */ new Map()]));
2414
+ for (const event of this.eachMatch(query)) {
2415
+ for (const field of uniqueFields) {
2416
+ const value = facetValue(event, field);
2417
+ if (value === void 0) continue;
2418
+ const fieldCounts = counts.get(field);
2419
+ fieldCounts?.set(value, (fieldCounts.get(value) ?? 0) + 1);
2420
+ }
2351
2421
  }
2352
- if (options.to) {
2353
- clauses.push("day <= ?");
2354
- params.push(options.to.slice(0, 10));
2422
+ const result = {};
2423
+ for (const field of uniqueFields) {
2424
+ result[field] = [...counts.get(field) ?? /* @__PURE__ */ new Map()].map(([value, count]) => ({ value, count })).sort((left, right) => right.count - left.count || left.value.localeCompare(right.value)).slice(0, Math.max(0, limit));
2355
2425
  }
2356
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
2357
- const rows = this.db.prepare(
2358
- `SELECT day, provider_id, model_id, attempts, completed, failed, retries, fallbacks,
2359
- input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
2360
- duration_ms_total, duration_ms_max, duration_count
2361
- FROM provider_daily${where} ORDER BY day DESC, provider_id, model_id`
2362
- ).all(...params);
2363
- return rows.map((row) => ({
2364
- day: String(row.day),
2365
- providerId: String(row.provider_id),
2366
- modelId: String(row.model_id),
2367
- attempts: Number(row.attempts),
2368
- completed: Number(row.completed),
2369
- failed: Number(row.failed),
2370
- retries: Number(row.retries),
2371
- fallbacks: Number(row.fallbacks),
2372
- inputTokens: Number(row.input_tokens),
2373
- outputTokens: Number(row.output_tokens),
2374
- cacheReadTokens: Number(row.cache_read_tokens),
2375
- cacheWriteTokens: Number(row.cache_write_tokens),
2376
- avgDurationMs: Number(row.duration_count) > 0 ? Number(row.duration_ms_total) / Number(row.duration_count) : 0,
2377
- maxDurationMs: Number(row.duration_ms_max)
2378
- }));
2426
+ return result;
2379
2427
  }
2380
- taskOutcomes(options = {}) {
2381
- const clauses = [];
2382
- const params = [];
2383
- if (options.runId) {
2384
- clauses.push("t.run_id = ?");
2385
- params.push(options.runId);
2386
- }
2387
- if (options.boardId) {
2388
- clauses.push("t.board_id = ?");
2389
- params.push(options.boardId);
2390
- }
2391
- if (options.sessionId) {
2392
- clauses.push("t.session_id = ?");
2393
- params.push(options.sessionId);
2394
- }
2395
- if (options.status) {
2396
- clauses.push("t.status = ?");
2397
- params.push(options.status);
2398
- }
2399
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
2400
- params.push(clampLimit(options.limit, 100));
2401
- const rows = this.db.prepare(
2402
- `SELECT t.*, (SELECT COUNT(*) FROM file_lineage f WHERE f.task_id = t.task_id) AS files_touched
2403
- FROM task_outcomes t${where}
2404
- ORDER BY COALESCE(t.started_at, '') DESC LIMIT ?`
2405
- ).all(...params);
2406
- return rows.map((row) => ({
2407
- taskId: String(row.task_id),
2408
- runId: String(row.run_id),
2409
- boardId: String(row.board_id),
2410
- sessionId: String(row.session_id),
2411
- agentId: String(row.agent_id),
2412
- status: String(row.status),
2413
- startedAt: row.started_at === null ? null : String(row.started_at),
2414
- endedAt: row.ended_at === null ? null : String(row.ended_at),
2415
- durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
2416
- retries: Number(row.retries),
2417
- verificationFailures: Number(row.verification_failures),
2418
- filesTouched: Number(row.files_touched)
2419
- }));
2428
+ async facet(field, query = {}, limit = 100) {
2429
+ return (await this.facets([field], query, limit))[field] ?? [];
2420
2430
  }
2421
- fileLineage(options = {}) {
2422
- const clauses = [];
2423
- const params = [];
2424
- if (options.path) {
2425
- clauses.push("path_key = ?");
2426
- params.push(normalizePathKey(options.path));
2427
- }
2428
- if (options.paths) {
2429
- const pathKeys = [...new Set(options.paths.map(normalizePathKey))];
2430
- if (pathKeys.length === 0) return [];
2431
- clauses.push(`path_key IN (${pathKeys.map(() => "?").join(",")})`);
2432
- params.push(...pathKeys);
2433
- }
2434
- if (options.taskId) {
2435
- clauses.push("task_id = ?");
2436
- params.push(options.taskId);
2431
+ /**
2432
+ * Expand explicit and typed correlation edges around a seed set.
2433
+ *
2434
+ * The traversal is the JSONL engine's, moved onto rows: `relationKeys()`
2435
+ * defines the edges, `compareEvents` orders the nodes, and each hop is
2436
+ * another ordered pass. Order is load-bearing rather than cosmetic — both
2437
+ * `maxNodes` truncations stop at whatever they reach first, so a differently
2438
+ * ordered scan would return a different subgraph rather than the same one
2439
+ * shuffled. `ORDER BY day, sequence` reproduces `comparePartitionPaths`
2440
+ * (family, then rotation index) followed by line order within a partition.
2441
+ */
2442
+ async graph(seed = {}, hops = 2, maxNodes = 1e3) {
2443
+ const nodeLimit = Math.max(0, Math.floor(maxNodes));
2444
+ const selected = /* @__PURE__ */ new Map();
2445
+ let seedCount = 0;
2446
+ for (const event of this.eachMatch(seed)) {
2447
+ seedCount++;
2448
+ if (selected.size < nodeLimit) selected.set(event.eventId, event);
2437
2449
  }
2438
- if (options.boardId) {
2439
- clauses.push("board_id = ?");
2440
- params.push(options.boardId);
2450
+ let frontier = [...selected.values()];
2451
+ const depthLimit = Math.max(0, Math.min(hops, 10));
2452
+ for (let depth = 0; depth < depthLimit && frontier.length > 0 && selected.size < nodeLimit; depth++) {
2453
+ const frontierKeys = new Set(
2454
+ frontier.flatMap((event) => relationKeys(event).map((relation) => relation.key))
2455
+ );
2456
+ const next = [];
2457
+ for (const event of this.eachMatch({})) {
2458
+ if (selected.has(event.eventId)) continue;
2459
+ if (!relationKeys(event).some((relation) => frontierKeys.has(relation.key))) continue;
2460
+ selected.set(event.eventId, event);
2461
+ next.push(event);
2462
+ if (selected.size >= nodeLimit) break;
2463
+ }
2464
+ frontier = next;
2441
2465
  }
2442
- if (options.sessionId) {
2443
- clauses.push("session_id = ?");
2444
- params.push(options.sessionId);
2466
+ const nodes = [...selected.values()].sort(compareEvents);
2467
+ const byKey = /* @__PURE__ */ new Map();
2468
+ for (const node of nodes) {
2469
+ for (const relation of relationKeys(node)) {
2470
+ const related = byKey.get(relation.key) ?? [];
2471
+ related.push(node);
2472
+ byKey.set(relation.key, related);
2473
+ }
2445
2474
  }
2446
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
2447
- params.push(clampLimit(options.limit, 200));
2448
- const projection = `path, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
2449
- tool_name, provider_id, model_id, source`;
2450
- const sql = options.latestPerPath ? `SELECT ${projection} FROM (
2451
- SELECT ${projection}, ROW_NUMBER() OVER (
2452
- PARTITION BY path_key ORDER BY occurred_at DESC, event_id DESC
2453
- ) AS path_rank
2454
- FROM file_lineage${where}
2455
- ) WHERE path_rank = 1 ORDER BY occurred_at DESC LIMIT ?` : `SELECT ${projection}
2456
- FROM file_lineage${where} ORDER BY occurred_at DESC LIMIT ?`;
2457
- const rows = this.db.prepare(sql).all(...params);
2458
- return rows.map((row) => ({
2459
- path: row.path,
2460
- operation: row.operation,
2461
- occurredAt: row.occurred_at,
2462
- sessionId: row.session_id,
2463
- agentId: row.agent_id,
2464
- taskId: row.task_id,
2465
- boardId: row.board_id,
2466
- runId: row.run_id,
2467
- toolName: row.tool_name,
2468
- providerId: row.provider_id,
2469
- modelId: row.model_id,
2470
- source: row.source
2471
- }));
2472
- }
2473
- summary() {
2474
- const provider = this.db.prepare(
2475
- "SELECT COALESCE(SUM(attempts),0) a, COALESCE(SUM(completed),0) c, COALESCE(SUM(failed),0) f FROM provider_daily"
2476
- ).get();
2477
- const tasks = {};
2478
- for (const row of this.db.prepare("SELECT status, COUNT(*) n FROM task_outcomes GROUP BY status").all()) {
2479
- tasks[row.status] = Number(row.n);
2475
+ const edges = [];
2476
+ const seen = /* @__PURE__ */ new Set();
2477
+ for (const node of nodes) {
2478
+ for (const relation of relationKeys(node)) {
2479
+ for (const candidate of byKey.get(relation.key) ?? []) {
2480
+ if (candidate.eventId === node.eventId) continue;
2481
+ const [from, to] = compareEvents(node, candidate) <= 0 ? [node, candidate] : [candidate, node];
2482
+ const id = `${from.eventId}:${to.eventId}:${relation.kind}`;
2483
+ if (seen.has(id)) continue;
2484
+ seen.add(id);
2485
+ edges.push({
2486
+ from: from.eventId,
2487
+ to: to.eventId,
2488
+ kind: relation.kind,
2489
+ confidence: relation.confidence
2490
+ });
2491
+ }
2492
+ }
2480
2493
  }
2481
- const files = this.db.prepare("SELECT COUNT(*) n, COUNT(DISTINCT path) p FROM file_lineage").get();
2482
- const cost = this.db.prepare("SELECT COALESCE(SUM(cost),0) c FROM token_cost").get();
2483
- const terminal = Number(provider.c) + Number(provider.f);
2484
2494
  return {
2485
- providers: {
2486
- attempts: Number(provider.a),
2487
- completed: Number(provider.c),
2488
- failed: Number(provider.f),
2489
- successRate: terminal > 0 ? Number(provider.c) / terminal : 0
2490
- },
2491
- tasks,
2492
- files: { mutations: Number(files.n), uniquePaths: Number(files.p) },
2493
- estimatedCostUsd: Number(cost.c)
2495
+ nodes,
2496
+ edges,
2497
+ truncated: seedCount > nodeLimit || selected.size >= nodeLimit
2494
2498
  };
2495
2499
  }
2496
- /**
2497
- * A `ChronicleSummary` for the default/unfiltered dashboard view — only
2498
- * `from`/`to` (day-precision) narrow it. Any other ad hoc filter (text,
2499
- * path, provider, model, session) can't be answered from these
2500
- * fixed-dimension aggregates; callers must fall back to query.ts's
2501
- * raw-scan summary for those.
2502
- */
2503
- defaultSummary(options = {}) {
2504
- const fromDay = options.from?.slice(0, 10);
2505
- const toDay = options.to?.slice(0, 10);
2506
- const dayFilter = (column) => {
2507
- const clauses = [];
2508
- const params = [];
2509
- if (fromDay) {
2510
- clauses.push(`${column} >= ?`);
2511
- params.push(fromDay);
2512
- }
2513
- if (toDay) {
2514
- clauses.push(`${column} <= ?`);
2515
- params.push(toDay);
2500
+ /** Every event satisfying `query`, pulled in batches so a wide scan stays bounded. */
2501
+ *eachMatch(query) {
2502
+ const pushed = pushDown(query);
2503
+ const sql = `SELECT payload FROM events WHERE ${pushed.clause} ORDER BY day, sequence LIMIT ? OFFSET ?`;
2504
+ let offset = 0;
2505
+ for (; ; ) {
2506
+ const rows = this.db.prepare(sql).all(...pushed.params, this.batchSize, offset);
2507
+ if (rows.length === 0) return;
2508
+ offset += rows.length;
2509
+ for (const row of rows) {
2510
+ let event;
2511
+ try {
2512
+ event = JSON.parse(row.payload);
2513
+ } catch {
2514
+ this.diagnostics.invalidLines++;
2515
+ continue;
2516
+ }
2517
+ if (matches(event, query)) yield event;
2516
2518
  }
2517
- return { where: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "", params };
2518
- };
2519
- const providerRange = dayFilter("day");
2520
- const provider = this.db.prepare(
2521
- `SELECT COALESCE(SUM(attempts),0) attempts, COALESCE(SUM(completed),0) completed, COALESCE(SUM(failed),0) failed,
2522
- COALESCE(SUM(retries),0) retries, COALESCE(SUM(fallbacks),0) fallbacks,
2523
- COUNT(DISTINCT provider_id) providers, COUNT(DISTINCT model_id) models,
2524
- COALESCE(SUM(input_tokens),0) inputTokens, COALESCE(SUM(output_tokens),0) outputTokens,
2525
- COALESCE(SUM(cache_read_tokens),0) cacheReadTokens, COALESCE(SUM(cache_write_tokens),0) cacheWriteTokens,
2526
- COALESCE(SUM(duration_ms_total),0) durationTotal, COALESCE(MAX(duration_ms_max),0) durationMax,
2527
- COALESCE(SUM(duration_count),0) durationCount
2528
- FROM provider_daily${providerRange.where}`
2529
- ).get(...providerRange.params);
2530
- const counterRange = dayFilter("day");
2531
- const counters = this.db.prepare(
2532
- `SELECT COALESCE(SUM(tool_calls),0) toolCalls, COALESCE(SUM(completed_tools),0) completedTools,
2533
- COALESCE(SUM(failed_tools),0) failedTools, COALESCE(SUM(tool_duration_ms_total),0) toolDurationTotal,
2534
- COALESCE(SUM(tool_duration_count),0) toolDurationCount, COALESCE(SUM(processes),0) processes,
2535
- COALESCE(SUM(failed_processes),0) failedProcesses, COALESCE(SUM(file_events_all),0) fileEvents,
2536
- COALESCE(SUM(decisions),0) decisions, COALESCE(SUM(escalations),0) escalations,
2537
- COALESCE(SUM(agent_events),0) agentEvents, COALESCE(SUM(failures),0) failures,
2538
- COALESCE(SUM(cancellations),0) cancellations
2539
- FROM daily_counters${counterRange.where}`
2540
- ).get(...counterRange.params);
2541
- const familyRange = dayFilter("day");
2542
- const familyRows = this.db.prepare(`SELECT family, count, failure_count FROM family_daily${familyRange.where}`).all(...familyRange.params);
2543
- const families = { ...EMPTY_FAMILIES };
2544
- const failuresByFamily = { ...EMPTY_FAMILIES };
2545
- for (const row of familyRows) {
2546
- const family = row.family;
2547
- families[family] = Number(row.count);
2548
- failuresByFamily[family] = Number(row.failure_count);
2519
+ if (rows.length < this.batchSize) return;
2549
2520
  }
2550
- const agentRange = dayFilter("day");
2551
- const uniqueAgents = this.db.prepare(`SELECT COUNT(DISTINCT agent_id) n FROM agent_daily${agentRange.where}`).get(...agentRange.params).n;
2552
- const requestRange = dayFilter("day");
2553
- const logicalRequests = this.db.prepare(`SELECT COUNT(DISTINCT logical_request_id) n FROM logical_request_daily${requestRange.where}`).get(...requestRange.params).n;
2554
- const fileRange = dayFilter("day");
2555
- const uniqueFiles = this.db.prepare(`SELECT COUNT(DISTINCT path_key) n FROM file_seen_daily${fileRange.where}`).get(...fileRange.params).n;
2556
- const costRange = dayFilter("day");
2557
- const cost = this.db.prepare(`SELECT COALESCE(SUM(cost),0) c FROM token_cost${costRange.where}`).get(...costRange.params).c;
2558
- return {
2559
- logicalRequests: Number(logicalRequests),
2560
- modelAttempts: Number(provider.attempts),
2561
- completedAttempts: Number(provider.completed),
2562
- failedAttempts: Number(provider.failed),
2563
- scheduledRetries: Number(provider.retries),
2564
- fallbacks: Number(provider.fallbacks),
2565
- providers: Number(provider.providers),
2566
- models: Number(provider.models),
2567
- inputTokens: Number(provider.inputTokens),
2568
- outputTokens: Number(provider.outputTokens),
2569
- cacheReadTokens: Number(provider.cacheReadTokens),
2570
- cacheWriteTokens: Number(provider.cacheWriteTokens),
2571
- estimatedCostUsd: Number(cost),
2572
- providerAvgDurationMs: Number(provider.durationCount) > 0 ? Number(provider.durationTotal) / Number(provider.durationCount) : 0,
2573
- // True p95 needs a retained distribution; this per-day aggregate only
2574
- // keeps sum/max/count, so approximate with the observed max rather
2575
- // than adding a per-attempt histogram write (would add the same kind
2576
- // of per-event overhead this whole effort is trying to remove).
2577
- providerP95DurationMs: Number(provider.durationMax),
2578
- toolCalls: Number(counters.toolCalls),
2579
- completedTools: Number(counters.completedTools),
2580
- failedTools: Number(counters.failedTools),
2581
- toolAvgDurationMs: Number(counters.toolDurationCount) > 0 ? Number(counters.toolDurationTotal) / Number(counters.toolDurationCount) : 0,
2582
- processes: Number(counters.processes),
2583
- failedProcesses: Number(counters.failedProcesses),
2584
- fileEvents: Number(counters.fileEvents),
2585
- uniqueFiles: Number(uniqueFiles),
2586
- agentEvents: Number(counters.agentEvents),
2587
- uniqueAgents: Number(uniqueAgents),
2588
- decisions: Number(counters.decisions),
2589
- escalations: Number(counters.escalations),
2590
- failures: Number(counters.failures),
2591
- cancellations: Number(counters.cancellations),
2592
- families,
2593
- failuresByFamily
2594
- };
2595
- }
2596
- // ─── Ingest internals ─────────────────────────────────────────────────────
2597
- ensureSchema() {
2598
- const version = this.db.prepare("PRAGMA user_version").get().user_version;
2599
- if (version !== 0 && version !== SCHEMA_VERSION) {
2600
- this.db.exec(
2601
- "DROP TABLE IF EXISTS ingest_state; DROP TABLE IF EXISTS provider_daily;DROP TABLE IF EXISTS task_outcomes; DROP TABLE IF EXISTS file_lineage;DROP TABLE IF EXISTS token_cost; DROP TABLE IF EXISTS daily_counters;DROP TABLE IF EXISTS family_daily; DROP TABLE IF EXISTS agent_daily;DROP TABLE IF EXISTS logical_request_daily; DROP TABLE IF EXISTS file_seen_daily;"
2602
- );
2603
- }
2604
- this.db.exec(`
2605
- CREATE TABLE IF NOT EXISTS ingest_state (
2606
- file TEXT PRIMARY KEY,
2607
- bytes INTEGER NOT NULL
2608
- );
2609
- CREATE TABLE IF NOT EXISTS provider_daily (
2610
- day TEXT NOT NULL,
2611
- provider_id TEXT NOT NULL,
2612
- model_id TEXT NOT NULL,
2613
- attempts INTEGER NOT NULL DEFAULT 0,
2614
- completed INTEGER NOT NULL DEFAULT 0,
2615
- failed INTEGER NOT NULL DEFAULT 0,
2616
- retries INTEGER NOT NULL DEFAULT 0,
2617
- fallbacks INTEGER NOT NULL DEFAULT 0,
2618
- input_tokens INTEGER NOT NULL DEFAULT 0,
2619
- output_tokens INTEGER NOT NULL DEFAULT 0,
2620
- cache_read_tokens INTEGER NOT NULL DEFAULT 0,
2621
- cache_write_tokens INTEGER NOT NULL DEFAULT 0,
2622
- duration_ms_total REAL NOT NULL DEFAULT 0,
2623
- duration_ms_max REAL NOT NULL DEFAULT 0,
2624
- duration_count INTEGER NOT NULL DEFAULT 0,
2625
- PRIMARY KEY (day, provider_id, model_id)
2626
- );
2627
- CREATE TABLE IF NOT EXISTS task_outcomes (
2628
- task_id TEXT PRIMARY KEY,
2629
- run_id TEXT NOT NULL DEFAULT '',
2630
- board_id TEXT NOT NULL DEFAULT '',
2631
- session_id TEXT NOT NULL DEFAULT '',
2632
- agent_id TEXT NOT NULL DEFAULT '',
2633
- status TEXT NOT NULL DEFAULT 'started',
2634
- started_at TEXT,
2635
- ended_at TEXT,
2636
- duration_ms REAL,
2637
- retries INTEGER NOT NULL DEFAULT 0,
2638
- verification_failures INTEGER NOT NULL DEFAULT 0
2639
- );
2640
- CREATE TABLE IF NOT EXISTS file_lineage (
2641
- event_id TEXT PRIMARY KEY,
2642
- path TEXT NOT NULL,
2643
- path_key TEXT NOT NULL,
2644
- operation TEXT NOT NULL,
2645
- occurred_at TEXT NOT NULL,
2646
- session_id TEXT NOT NULL DEFAULT '',
2647
- agent_id TEXT NOT NULL DEFAULT '',
2648
- task_id TEXT NOT NULL DEFAULT '',
2649
- board_id TEXT NOT NULL DEFAULT '',
2650
- run_id TEXT NOT NULL DEFAULT '',
2651
- tool_name TEXT NOT NULL DEFAULT '',
2652
- provider_id TEXT NOT NULL DEFAULT '',
2653
- model_id TEXT NOT NULL DEFAULT '',
2654
- source TEXT NOT NULL DEFAULT ''
2655
- );
2656
- -- Lookups filter on the case-normalized path_key (matching the query
2657
- -- engine); the path column retains original casing for display.
2658
- CREATE INDEX IF NOT EXISTS idx_file_lineage_path ON file_lineage(path_key, occurred_at);
2659
- CREATE INDEX IF NOT EXISTS idx_file_lineage_task ON file_lineage(task_id);
2660
- CREATE TABLE IF NOT EXISTS token_cost (
2661
- scope_key TEXT PRIMARY KEY,
2662
- day TEXT NOT NULL,
2663
- occurred_at TEXT NOT NULL,
2664
- sequence INTEGER NOT NULL,
2665
- cost REAL NOT NULL
2666
- );
2667
- -- Backing store for defaultSummary(): per-day scalar counters plus
2668
- -- dedup sets, populated for every ingested event (not just the
2669
- -- provider/task/file families above).
2670
- CREATE TABLE IF NOT EXISTS daily_counters (
2671
- day TEXT PRIMARY KEY,
2672
- tool_calls INTEGER NOT NULL DEFAULT 0,
2673
- completed_tools INTEGER NOT NULL DEFAULT 0,
2674
- failed_tools INTEGER NOT NULL DEFAULT 0,
2675
- tool_duration_ms_total REAL NOT NULL DEFAULT 0,
2676
- tool_duration_ms_max REAL NOT NULL DEFAULT 0,
2677
- tool_duration_count INTEGER NOT NULL DEFAULT 0,
2678
- processes INTEGER NOT NULL DEFAULT 0,
2679
- failed_processes INTEGER NOT NULL DEFAULT 0,
2680
- file_events_all INTEGER NOT NULL DEFAULT 0,
2681
- decisions INTEGER NOT NULL DEFAULT 0,
2682
- escalations INTEGER NOT NULL DEFAULT 0,
2683
- agent_events INTEGER NOT NULL DEFAULT 0,
2684
- failures INTEGER NOT NULL DEFAULT 0,
2685
- cancellations INTEGER NOT NULL DEFAULT 0
2686
- );
2687
- CREATE TABLE IF NOT EXISTS family_daily (
2688
- day TEXT NOT NULL,
2689
- family TEXT NOT NULL,
2690
- count INTEGER NOT NULL DEFAULT 0,
2691
- failure_count INTEGER NOT NULL DEFAULT 0,
2692
- PRIMARY KEY (day, family)
2693
- );
2694
- CREATE TABLE IF NOT EXISTS agent_daily (day TEXT NOT NULL, agent_id TEXT NOT NULL, PRIMARY KEY (day, agent_id));
2695
- CREATE TABLE IF NOT EXISTS logical_request_daily (day TEXT NOT NULL, logical_request_id TEXT NOT NULL, PRIMARY KEY (day, logical_request_id));
2696
- CREATE TABLE IF NOT EXISTS file_seen_daily (day TEXT NOT NULL, path_key TEXT NOT NULL, PRIMARY KEY (day, path_key));
2697
- PRAGMA user_version = ${SCHEMA_VERSION};
2698
- `);
2699
- }
2700
- loadOffsets() {
2701
- const rows = this.db.prepare("SELECT file, bytes FROM ingest_state").all();
2702
- return new Map(rows.map((row) => [row.file, Number(row.bytes)]));
2703
- }
2704
- pruneOffsets(existingFiles) {
2705
- const keep = new Set(
2706
- existingFiles.map((file) => normalizeKey(path9.relative(this.directory, file)))
2707
- );
2708
- for (const row of this.db.prepare("SELECT file FROM ingest_state").all()) {
2709
- if (!keep.has(row.file))
2710
- this.db.prepare("DELETE FROM ingest_state WHERE file = ?").run(row.file);
2711
- }
2712
- }
2713
- /** Read complete lines appended after `consumed` bytes. The trailing
2714
- * partial line of an actively-written partition is left for the next
2715
- * refresh — `ingest_state.bytes` only ever advances past full lines. */
2716
- async ingestFile(file, key, consumed, result) {
2717
- let handle;
2718
- try {
2719
- handle = await fs6.open(file, "r");
2720
- } catch {
2721
- return false;
2722
- }
2723
- try {
2724
- const size = (await handle.stat()).size;
2725
- if (size <= consumed) return false;
2726
- let position = consumed;
2727
- let remainder = Buffer.alloc(0);
2728
- let advanced = consumed;
2729
- this.db.exec("BEGIN");
2730
- try {
2731
- while (position < size) {
2732
- const length = Math.min(READ_CHUNK_BYTES, size - position);
2733
- const buffer = Buffer.allocUnsafe(length);
2734
- const { bytesRead } = await handle.read(buffer, 0, length, position);
2735
- if (bytesRead <= 0) break;
2736
- position += bytesRead;
2737
- const data = remainder.length > 0 ? Buffer.concat([remainder, buffer.subarray(0, bytesRead)]) : buffer.subarray(0, bytesRead);
2738
- const lastNewline = data.lastIndexOf(10);
2739
- if (lastNewline < 0) {
2740
- remainder = Buffer.from(data);
2741
- continue;
2742
- }
2743
- for (const line of data.subarray(0, lastNewline).toString("utf8").split("\n")) {
2744
- const trimmed = line.trim();
2745
- if (!trimmed) continue;
2746
- try {
2747
- this.ingestEvent(JSON.parse(trimmed));
2748
- result.ingestedEvents++;
2749
- } catch {
2750
- result.invalidLines++;
2751
- }
2752
- }
2753
- advanced += lastNewline + 1;
2754
- remainder = Buffer.from(data.subarray(lastNewline + 1));
2755
- }
2756
- this.db.prepare(
2757
- "INSERT INTO ingest_state (file, bytes) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET bytes = excluded.bytes"
2758
- ).run(key, advanced);
2759
- this.db.exec("COMMIT");
2760
- } catch (error) {
2761
- this.db.exec("ROLLBACK");
2762
- throw error;
2763
- }
2764
- result.ingestedBytes += advanced - consumed;
2765
- return advanced > consumed;
2766
- } finally {
2767
- await handle.close();
2768
- }
2769
- }
2770
- ingestEvent(event) {
2771
- if (typeof event?.eventType !== "string" || !event.scope) return;
2772
- this.ingestDailyCounters(event);
2773
- const type = event.eventType;
2774
- if (type.startsWith("provider.attempt.") || type === "provider.fallback") {
2775
- this.ingestProvider(event);
2776
- } else if (type === "token.accounted") {
2777
- this.ingestTokenCost(event);
2778
- } else if (/^(?:sdd|subagent|kanban)\.task[._]/.test(type)) {
2779
- this.ingestTask(event);
2780
- } else if (type === "file.event" || /^file\.(?:tool|external)\./.test(type)) {
2781
- this.ingestFileEvent(event);
2782
- }
2783
- }
2784
- /** Runs for every ingested event (not just the type-specific branches
2785
- * below) — mirrors query.ts's updateSummary() closely enough that
2786
- * defaultSummary() matches what a raw scan of the same window would say. */
2787
- ingestDailyCounters(event) {
2788
- const day = eventDay(event);
2789
- this.db.prepare("INSERT OR IGNORE INTO daily_counters (day) VALUES (?)").run(day);
2790
- const bump = (sql, ...params) => this.db.prepare(`UPDATE daily_counters SET ${sql} WHERE day = ?`).run(...params, day);
2791
- const family = signalFamily(event);
2792
- const failed = isTerminalFailure(event) ? 1 : 0;
2793
- this.db.prepare(
2794
- `INSERT INTO family_daily (day, family, count, failure_count) VALUES (?, ?, 1, ?)
2795
- ON CONFLICT(day, family) DO UPDATE SET count = count + 1, failure_count = failure_count + excluded.failure_count`
2796
- ).run(day, family, failed);
2797
- if (failed) bump("failures = failures + 1");
2798
- if (event.outcome === "cancelled" || event.outcome === "abandoned") bump("cancellations = cancellations + 1");
2799
- if (family === "agent") bump("agent_events = agent_events + 1");
2800
- if (event.correlation.logicalRequestId) {
2801
- this.db.prepare("INSERT OR IGNORE INTO logical_request_daily (day, logical_request_id) VALUES (?, ?)").run(day, event.correlation.logicalRequestId);
2802
- }
2803
- if (event.scope.agentId) {
2804
- this.db.prepare("INSERT OR IGNORE INTO agent_daily (day, agent_id) VALUES (?, ?)").run(day, event.scope.agentId);
2805
- }
2806
- const type = event.eventType;
2807
- if (type === "decision.requested") bump("decisions = decisions + 1");
2808
- else if (type === "decision.escalated") bump("escalations = escalations + 1");
2809
- else if (type === "tool.started") bump("tool_calls = tool_calls + 1");
2810
- else if (type === "tool.executed" || type === "tool.failed") {
2811
- const dur = durationMs2(event);
2812
- const durationCount = dur > 0 ? 1 : 0;
2813
- bump(
2814
- `${type === "tool.executed" ? "completed_tools" : "failed_tools"} = ${type === "tool.executed" ? "completed_tools" : "failed_tools"} + 1,
2815
- tool_duration_ms_total = tool_duration_ms_total + ?, tool_duration_ms_max = MAX(tool_duration_ms_max, ?), tool_duration_count = tool_duration_count + ?`,
2816
- dur,
2817
- dur,
2818
- durationCount
2819
- );
2820
- } else if (type === "process.started") bump("processes = processes + 1");
2821
- else if (type === "process.completed" && event.outcome === "failure") bump("failed_processes = failed_processes + 1");
2822
- if (event.resource?.kind === "file" || type.startsWith("file.")) {
2823
- bump("file_events_all = file_events_all + 1");
2824
- if (event.resource?.path) {
2825
- this.db.prepare("INSERT OR IGNORE INTO file_seen_daily (day, path_key) VALUES (?, ?)").run(day, normalizePathKey(event.resource.path));
2826
- }
2827
- }
2828
- }
2829
- ingestProvider(event) {
2830
- const day = eventDay(event);
2831
- const providerId = event.runtime?.providerId ?? asString(readPath2(event.attributes ?? {}, "from.providerId")) ?? "";
2832
- const modelId = event.runtime?.modelId ?? asString(readPath2(event.attributes ?? {}, "from.model")) ?? "";
2833
- if (!providerId && !modelId) return;
2834
- this.db.prepare("INSERT OR IGNORE INTO provider_daily (day, provider_id, model_id) VALUES (?, ?, ?)").run(day, providerId, modelId);
2835
- const update = (sql, ...params) => this.db.prepare(
2836
- `UPDATE provider_daily SET ${sql} WHERE day = ? AND provider_id = ? AND model_id = ?`
2837
- ).run(...params, day, providerId, modelId);
2838
- const duration = durationMs2(event);
2839
- switch (event.eventType) {
2840
- case "provider.attempt.started":
2841
- update("attempts = attempts + 1");
2842
- break;
2843
- case "provider.attempt.completed":
2844
- update(
2845
- "completed = completed + 1, input_tokens = input_tokens + ?, output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?, cache_write_tokens = cache_write_tokens + ?, duration_ms_total = duration_ms_total + ?, duration_ms_max = MAX(duration_ms_max, ?), duration_count = duration_count + ?",
2846
- numberAt2(event, "usage.input"),
2847
- numberAt2(event, "usage.output"),
2848
- numberAt2(event, "usage.cacheRead"),
2849
- numberAt2(event, "usage.cacheWrite"),
2850
- duration,
2851
- duration,
2852
- duration > 0 ? 1 : 0
2853
- );
2854
- break;
2855
- case "provider.attempt.failed":
2856
- update(
2857
- "failed = failed + 1, retries = retries + ?, duration_ms_total = duration_ms_total + ?, duration_ms_max = MAX(duration_ms_max, ?), duration_count = duration_count + ?",
2858
- event.attributes?.retryScheduled === true ? 1 : 0,
2859
- duration,
2860
- duration,
2861
- duration > 0 ? 1 : 0
2862
- );
2863
- break;
2864
- case "provider.fallback":
2865
- update("fallbacks = fallbacks + 1");
2866
- break;
2867
- default:
2868
- break;
2869
- }
2870
- }
2871
- ingestTokenCost(event) {
2872
- const cost = readPath2(event.attributes ?? {}, "cost.total");
2873
- if (typeof cost !== "number" || !Number.isFinite(cost)) return;
2874
- const scopeKey2 = `${event.scope.projectId ?? ""}\0${event.scope.sessionId ?? ""}\0${event.scope.agentId ?? ""}`;
2875
- const occurredAt = event.occurredAt ?? event.observedAt;
2876
- this.db.prepare(
2877
- `INSERT INTO token_cost (scope_key, day, occurred_at, sequence, cost) VALUES (?, ?, ?, ?, ?)
2878
- ON CONFLICT(scope_key) DO UPDATE SET
2879
- day = excluded.day, occurred_at = excluded.occurred_at,
2880
- sequence = excluded.sequence, cost = excluded.cost
2881
- WHERE excluded.occurred_at > token_cost.occurred_at
2882
- OR (excluded.occurred_at = token_cost.occurred_at AND excluded.sequence > token_cost.sequence)`
2883
- ).run(scopeKey2, eventDay(event), occurredAt, event.sequence, cost);
2884
- }
2885
- ingestTask(event) {
2886
- const attributes = event.attributes ?? {};
2887
- const taskId = event.scope.taskId ?? stringAt(attributes, "taskId");
2888
- if (!taskId) return;
2889
- const occurredAt = event.occurredAt ?? event.observedAt;
2890
- this.db.prepare("INSERT OR IGNORE INTO task_outcomes (task_id) VALUES (?)").run(taskId);
2891
- const set = (sql, ...params) => this.db.prepare(`UPDATE task_outcomes SET ${sql} WHERE task_id = ?`).run(...params, taskId);
2892
- const lineage = [
2893
- ["run_id", stringAt(attributes, "runId")],
2894
- ["board_id", event.scope.kanbanBoardId ?? stringAt(attributes, "boardId")],
2895
- ["session_id", event.scope.sessionId],
2896
- ["agent_id", event.scope.agentId ?? stringAt(attributes, "subagentId")]
2897
- ];
2898
- for (const [column, value] of lineage) {
2899
- if (value) set(`${column} = ?`, value);
2900
- }
2901
- const base = event.eventType.replace(/^(?:sdd|subagent|kanban)\.task[._]/, "");
2902
- switch (base) {
2903
- case "started":
2904
- set("status = 'started', started_at = COALESCE(started_at, ?)", occurredAt);
2905
- break;
2906
- case "completed":
2907
- set(
2908
- "status = 'completed', ended_at = ?, duration_ms = ?",
2909
- occurredAt,
2910
- numberOrDuration(event, attributes)
2911
- );
2912
- break;
2913
- case "failed":
2914
- set("status = 'failed', ended_at = ?", occurredAt);
2915
- break;
2916
- case "retrying":
2917
- set("retries = retries + 1");
2918
- break;
2919
- case "verification_failed":
2920
- set("verification_failures = verification_failures + 1");
2921
- break;
2922
- case "merged":
2923
- set("status = 'merged'");
2924
- break;
2925
- case "conflict":
2926
- set("status = 'conflict'");
2927
- break;
2928
- default:
2929
- break;
2930
- }
2931
- }
2932
- ingestFileEvent(event) {
2933
- const attributes = event.attributes ?? {};
2934
- const operation = stringAt(attributes, "operation") ?? "";
2935
- if (!operation || operation === "read") return;
2936
- const filePath = event.resource?.path ?? stringAt(attributes, "filePath");
2937
- if (!filePath) return;
2938
- this.db.prepare(
2939
- `INSERT OR IGNORE INTO file_lineage
2940
- (event_id, path, path_key, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
2941
- tool_name, provider_id, model_id, source)
2942
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2943
- ).run(
2944
- event.eventId,
2945
- normalizeKey(filePath),
2946
- normalizePathKey(filePath),
2947
- operation,
2948
- event.occurredAt ?? event.observedAt,
2949
- event.scope.sessionId ?? "",
2950
- event.scope.agentId ?? "",
2951
- event.scope.taskId ?? stringAt(attributes, "taskId") ?? "",
2952
- event.scope.kanbanBoardId ?? stringAt(attributes, "boardId") ?? "",
2953
- stringAt(attributes, "runId") ?? "",
2954
- stringAt(attributes, "toolName") ?? "",
2955
- event.runtime?.providerId ?? stringAt(attributes, "provider") ?? "",
2956
- event.runtime?.modelId ?? stringAt(attributes, "model") ?? "",
2957
- stringAt(attributes, "source") ?? (event.eventType === "file.event" ? "tool" : "external")
2958
- );
2959
- }
2960
- };
2961
- function eventDay(event) {
2962
- return (event.occurredAt ?? event.observedAt).slice(0, 10);
2963
- }
2964
- function durationMs2(event) {
2965
- const value = Number(event.durationNs ?? 0) / 1e6;
2966
- return Number.isFinite(value) && value > 0 ? value : 0;
2967
- }
2968
- function numberOrDuration(event, attributes) {
2969
- const explicit = attributes.durationMs;
2970
- if (typeof explicit === "number" && Number.isFinite(explicit)) return explicit;
2971
- return durationMs2(event);
2972
- }
2973
- function numberAt2(event, dotPath) {
2974
- const value = readPath2(event.attributes ?? {}, dotPath);
2975
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
2976
- }
2977
- function readPath2(value, key) {
2978
- return key.split(".").reduce(
2979
- (current, part) => current && typeof current === "object" ? current[part] : void 0,
2980
- value
2981
- );
2982
- }
2983
- function stringAt(record, key) {
2984
- const value = record[key];
2985
- return typeof value === "string" && value.length > 0 ? value : void 0;
2986
- }
2987
- function asString(value) {
2988
- return typeof value === "string" && value.length > 0 ? value : void 0;
2989
- }
2990
- function clampLimit(limit, fallback) {
2991
- if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) return fallback;
2992
- return Math.min(Math.floor(limit), 1e4);
2993
- }
2994
- function normalizeKey(value) {
2995
- return value.replaceAll("\\", "/");
2996
- }
2997
- function normalizePathKey(value) {
2998
- return value.replaceAll("\\", "/").replace(/^\.\//, "").toLowerCase();
2999
- }
3000
-
3001
- // src/chronicle/legacy-journal-import.ts
3002
- import * as fs7 from "node:fs/promises";
3003
- import * as path10 from "node:path";
3004
- var ChronicleImportError = class extends Error {
3005
- constructor(message, day, sequence) {
3006
- super(message);
3007
- this.day = day;
3008
- this.sequence = sequence;
3009
- this.name = "ChronicleImportError";
3010
- }
3011
- day;
3012
- sequence;
3013
- };
3014
- async function discoverFamilies(directory) {
3015
- let entries;
3016
- try {
3017
- entries = await fs7.readdir(directory);
3018
- } catch {
3019
- return [];
3020
- }
3021
- const bases = /* @__PURE__ */ new Set();
3022
- for (const entry of entries) {
3023
- const match = PARTITION_FILE_PATTERN.exec(entry);
3024
- if (match?.[1]) bases.add(match[1]);
3025
- }
3026
- return [...bases].sort();
3027
- }
3028
- function dayOfFamily(familyBase) {
3029
- return familyBase.replace(/\.events$/u, "");
3030
- }
3031
- async function importLegacyChronicleJournal(journal, directory) {
3032
- if (journal.hasImportedLegacyJournal()) {
3033
- return {
3034
- alreadyImported: true,
3035
- families: 0,
3036
- events: 0,
3037
- quarantined: journal.quarantinedFamilies()
3038
- };
3039
- }
3040
- const families = await discoverFamilies(directory);
3041
- const quarantined = [];
3042
- let importedEvents = 0;
3043
- let importedFamilies = 0;
3044
- for (const familyBase of families) {
3045
- const day = dayOfFamily(familyBase);
3046
- const basePath = path10.join(directory, `${familyBase}.jsonl`);
3047
- let familyEvents = 0;
3048
- if (journal.hasImportedDay(day)) continue;
3049
- try {
3050
- await journal.runFamilyImport(async (sink) => {
3051
- familyEvents = 0;
3052
- const checkpointResult = await readRetentionCheckpoint(basePath);
3053
- if (checkpointResult.error) {
3054
- throw new ChronicleImportError(checkpointResult.error, day, 0);
3055
- }
3056
- const checkpoint = checkpointResult.checkpoint;
3057
- if (checkpoint) sink.checkpoint(day, checkpoint.sequence, checkpoint.hash);
3058
- let expectedSequence = (checkpoint?.sequence ?? 0) + 1;
3059
- let previousHash = checkpoint?.hash ?? GENESIS_HASH;
3060
- for (const partition of await collectPartitions(basePath)) {
3061
- for await (const event of streamEntriesStrict(partition)) {
3062
- if (event.sequence !== expectedSequence) {
3063
- throw new ChronicleImportError(
3064
- `sequence gap in ${day}: expected ${expectedSequence}, found ${event.sequence}`,
3065
- day,
3066
- event.sequence
3067
- );
3068
- }
3069
- if (event.previousHash !== previousHash) {
3070
- throw new ChronicleImportError(
3071
- `previous hash mismatch in ${day} at sequence ${event.sequence}`,
3072
- day,
3073
- event.sequence
3074
- );
3075
- }
3076
- if (chronicleEventHash(event) !== event.hash) {
3077
- throw new ChronicleImportError(
3078
- `entry hash mismatch in ${day} at sequence ${event.sequence}`,
3079
- day,
3080
- event.sequence
3081
- );
3082
- }
3083
- sink.insert(day, event);
3084
- familyEvents += 1;
3085
- expectedSequence = event.sequence + 1;
3086
- previousHash = event.hash;
3087
- }
3088
- }
3089
- });
3090
- } catch (error) {
3091
- if (!(error instanceof ChronicleImportError)) throw error;
3092
- quarantined.push({ day, sequence: error.sequence, reason: error.message });
3093
- continue;
3094
- }
3095
- if (familyEvents > 0) importedFamilies += 1;
3096
- importedEvents += familyEvents;
3097
2521
  }
3098
- journal.recordQuarantinedFamilies(quarantined);
3099
- journal.markLegacyJournalImported();
3100
- return {
3101
- alreadyImported: false,
3102
- families: importedFamilies,
3103
- events: importedEvents,
3104
- quarantined
3105
- };
3106
- }
2522
+ };
3107
2523
 
3108
2524
  // src/chronicle/sqlite-journal.ts
3109
- import { randomUUID as randomUUID3 } from "node:crypto";
3110
- import * as fs8 from "node:fs";
3111
- import { createRequire as createRequire2 } from "node:module";
3112
- import * as path11 from "node:path";
3113
-
3114
- // src/utils/pid.ts
3115
- function isPidAlive(pid) {
3116
- if (!Number.isInteger(pid) || pid <= 0) return false;
3117
- if (pid === process.pid) return true;
3118
- try {
3119
- process.kill(pid, 0);
3120
- return true;
3121
- } catch (err) {
3122
- const code = err.code;
3123
- if (code === "EPERM") return true;
3124
- return false;
2525
+ var CHRONICLE_SQLITE_FILE = "chronicle.sqlite";
2526
+ var LEGACY_JSONL_MIGRATION_KEY = "legacy-jsonl-v1";
2527
+ var LEGACY_JSONL_QUARANTINE_KEY = "legacy-jsonl-v1:quarantine";
2528
+ var SCHEMA_VERSION = 2;
2529
+ var SQLITE_FIXED_OVERHEAD_BYTES = 16 * 1024 * 1024;
2530
+ var MIN_SQLITE_PAGE_BUDGET_BYTES = 64 * 1024;
2531
+ var MAX_WAL_RESERVE_BYTES = 32 * 1024 * 1024;
2532
+ var WAL_AUTOCHECKPOINT_PAGES = 2e3;
2533
+ var WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024;
2534
+ var TRIM_SLACK_RATIO = 0.02;
2535
+ var MAX_TRIM_SLACK_EVENTS = 2e3;
2536
+ var ChronicleStorageQuotaError = class extends Error {
2537
+ currentBytes;
2538
+ batchBytes;
2539
+ maxBytes;
2540
+ path;
2541
+ constructor(details) {
2542
+ super(
2543
+ `Chronicle SQLite quota exceeded at ${details.path}: ${details.currentBytes} live bytes + ${details.batchBytes} batch bytes exceeds ${details.maxBytes}; lower chronicle retentionDays/maxEvents to shed data (run chronicle compact to return the freed pages to the filesystem)`
2544
+ );
2545
+ this.name = "ChronicleStorageQuotaError";
2546
+ this.currentBytes = details.currentBytes;
2547
+ this.batchBytes = details.batchBytes;
2548
+ this.maxBytes = details.maxBytes;
2549
+ this.path = details.path;
3125
2550
  }
3126
- }
3127
-
3128
- // src/chronicle/sqlite-query.ts
3129
- var MAX_LIMIT = 1e4;
3130
- function encodeCursor2(cursor) {
3131
- return Buffer.from(`${cursor.day}:${cursor.sequence}`, "utf8").toString("base64url");
3132
- }
3133
- function decodeCursor2(raw) {
3134
- if (!raw) return void 0;
2551
+ };
2552
+ var Ctor;
2553
+ function loadDatabaseSync() {
2554
+ if (Ctor) return Ctor;
2555
+ if (Ctor === null) throw new Error("node:sqlite is unavailable in this runtime");
3135
2556
  try {
3136
- const [day, sequence] = Buffer.from(raw, "base64url").toString("utf8").split(":");
3137
- if (!day || sequence === void 0) return void 0;
3138
- const parsed = Number(sequence);
3139
- return Number.isSafeInteger(parsed) ? { day, sequence: parsed } : void 0;
3140
- } catch {
3141
- return void 0;
2557
+ Ctor = withSqliteExperimentalWarningSuppressed(
2558
+ () => createRequire(import.meta.url)("node:sqlite").DatabaseSync
2559
+ );
2560
+ return Ctor;
2561
+ } catch (error) {
2562
+ Ctor = null;
2563
+ throw new Error(
2564
+ "The Chronicle journal needs Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
2565
+ );
3142
2566
  }
3143
2567
  }
3144
- function pushDown(query) {
3145
- const clauses = [];
3146
- const params = [];
3147
- const eq = (column, value) => {
3148
- if (value === void 0) return;
3149
- clauses.push(`${column} = ?`);
3150
- params.push(value);
2568
+ var ChronicleSqliteJournal = class {
2569
+ db;
2570
+ dbPath;
2571
+ now;
2572
+ monotonicNow;
2573
+ idFactory;
2574
+ retentionDays;
2575
+ maxEvents;
2576
+ maxBytes;
2577
+ retentionCheckIntervalMs;
2578
+ /** Overshoot allowed above `maxEvents` before eviction runs; see TRIM_SLACK_RATIO. */
2579
+ trimSlack;
2580
+ nextRetentionCheckAt = 0;
2581
+ retainedEventCount = 0;
2582
+ /** Resolved lazily by `pageSizeBytes()`; constant for an open database. */
2583
+ cachedPageSize;
2584
+ /**
2585
+ * Bytes the quota is known to have had spare at the last real measurement,
2586
+ * and the bytes appended since. `assertWithinByteQuota` re-measures only once
2587
+ * the second could plausibly have consumed the first — see its comment.
2588
+ */
2589
+ quotaHeadroomBytes = 0;
2590
+ bytesSinceQuotaCheck = Number.POSITIVE_INFINITY;
2591
+ /**
2592
+ * Statements reused across appends.
2593
+ *
2594
+ * `db.prepare` re-parses the SQL every call, and the append path used to
2595
+ * prepare five statements per batch. They are held rather than re-prepared
2596
+ * because the schema cannot change under an open journal.
2597
+ */
2598
+ statements;
2599
+ /**
2600
+ * Cached chain head per day. Cleared wholesale on any write failure so the
2601
+ * next append rebuilds from the database rather than trusting a counter that
2602
+ * may have advanced past what actually committed.
2603
+ */
2604
+ anchors = /* @__PURE__ */ new Map();
2605
+ counters = {
2606
+ acceptedEvents: 0,
2607
+ persistedEvents: 0,
2608
+ rejectedEvents: 0,
2609
+ failedEvents: 0,
2610
+ batches: 0,
2611
+ maxObservedPending: 0,
2612
+ largestBatch: 0
3151
2613
  };
3152
- eq("event_id", query.eventId);
3153
- eq("project_id", query.projectId);
3154
- eq("session_id", query.sessionId);
3155
- eq("agent_id", query.agentId);
3156
- eq("task_id", query.taskId);
3157
- eq("trace_id", query.traceId);
3158
- eq("logical_request_id", query.logicalRequestId);
3159
- eq("resource_kind", query.resourceKind);
3160
- eq("resource_id", query.resourceId);
3161
- if (query.eventTypes?.length) {
3162
- clauses.push(`event_type IN (${query.eventTypes.map(() => "?").join(",")})`);
3163
- params.push(...query.eventTypes);
2614
+ lastBatchDurationMs;
2615
+ constructor(options) {
2616
+ this.dbPath = path9.join(path9.resolve(options.directory), CHRONICLE_SQLITE_FILE);
2617
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
2618
+ this.monotonicNow = options.monotonicNow ?? (() => process.hrtime.bigint());
2619
+ this.idFactory = options.idFactory ?? (() => randomUUID3());
2620
+ if (options.retentionDays !== void 0 && (!Number.isFinite(options.retentionDays) || options.retentionDays <= 0)) {
2621
+ throw new RangeError("retentionDays must be a positive finite number");
2622
+ }
2623
+ if (options.retentionCheckIntervalMs !== void 0 && (!Number.isFinite(options.retentionCheckIntervalMs) || options.retentionCheckIntervalMs <= 0)) {
2624
+ throw new RangeError("retentionCheckIntervalMs must be a positive finite number");
2625
+ }
2626
+ if (options.maxEvents !== void 0 && (!Number.isInteger(options.maxEvents) || options.maxEvents < 1)) {
2627
+ throw new RangeError("maxEvents must be a positive integer");
2628
+ }
2629
+ if (options.maxBytes !== void 0 && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1)) {
2630
+ throw new RangeError("maxBytes must be a positive safe integer");
2631
+ }
2632
+ const minimumQuotaBytes = SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES;
2633
+ if (options.maxBytes !== void 0 && options.maxBytes < minimumQuotaBytes) {
2634
+ throw new RangeError(`maxBytes must be at least ${minimumQuotaBytes}`);
2635
+ }
2636
+ this.retentionDays = options.retentionDays;
2637
+ this.maxEvents = options.maxEvents;
2638
+ this.maxBytes = options.maxBytes;
2639
+ this.retentionCheckIntervalMs = options.retentionCheckIntervalMs ?? 60 * 60 * 1e3;
2640
+ this.trimSlack = this.maxEvents === void 0 ? 0 : Math.min(MAX_TRIM_SLACK_EVENTS, Math.floor(this.maxEvents * TRIM_SLACK_RATIO));
2641
+ const Database = loadDatabaseSync();
2642
+ this.db = new Database(this.dbPath);
2643
+ this.db.exec("PRAGMA journal_mode = WAL");
2644
+ this.db.exec("PRAGMA synchronous = FULL");
2645
+ this.db.exec(`PRAGMA wal_autocheckpoint = ${WAL_AUTOCHECKPOINT_PAGES}`);
2646
+ this.db.exec(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES}`);
2647
+ this.ensureSchema();
2648
+ this.configureByteQuota();
2649
+ if (this.maxEvents !== void 0) {
2650
+ const row = this.db.prepare("SELECT COUNT(*) AS count FROM events").get();
2651
+ this.retainedEventCount = row.count;
2652
+ this.enforceEventLimitAtStartup();
2653
+ }
3164
2654
  }
3165
- if (query.outcomes?.length) {
3166
- clauses.push(`outcome IN (${query.outcomes.map(() => "?").join(",")})`);
3167
- params.push(...query.outcomes);
2655
+ close() {
2656
+ this.db.close();
3168
2657
  }
3169
- if (query.from) {
3170
- clauses.push("occurred_at >= ?");
3171
- params.push(query.from);
2658
+ stats() {
2659
+ return {
2660
+ ...this.counters,
2661
+ pendingEvents: 0,
2662
+ // Retained at zero for wire compatibility: partitions do not exist here,
2663
+ // but `ChronicleJournalStats` is part of the IPC health payload.
2664
+ partitionRolls: 0,
2665
+ ...this.lastBatchDurationMs !== void 0 ? { lastBatchDurationMs: this.lastBatchDurationMs } : {}
2666
+ };
3172
2667
  }
3173
- if (query.to) {
3174
- clauses.push("occurred_at <= ?");
3175
- params.push(query.to);
2668
+ /**
2669
+ * Writes are synchronous and transactional, so there is nothing buffered to
2670
+ * flush. Kept to satisfy `ChronicleEventSink`.
2671
+ */
2672
+ async flush() {
2673
+ return Promise.resolve();
2674
+ }
2675
+ async append(input) {
2676
+ const [event] = await this.appendBatch([input]);
2677
+ return event;
2678
+ }
2679
+ /**
2680
+ * Append a batch as one transaction.
2681
+ *
2682
+ * The chain is computed in memory from a single anchor, so a partially
2683
+ * applied batch would leave a hole in `sequence` that verification can never
2684
+ * reconcile. Rollback plus anchor invalidation is what prevents that.
2685
+ */
2686
+ async appendBatch(inputs) {
2687
+ if (inputs.length === 0) return [];
2688
+ const started = performance.now();
2689
+ this.counters.acceptedEvents += inputs.length;
2690
+ this.counters.batches += 1;
2691
+ this.counters.largestBatch = Math.max(this.counters.largestBatch, inputs.length);
2692
+ const instant = this.now().toISOString();
2693
+ const day = instant.slice(0, 10);
2694
+ const events = [];
2695
+ let previous = this.readAnchor(day);
2696
+ for (const input of inputs) {
2697
+ const unhashed = {
2698
+ ...input,
2699
+ occurredAt: input.occurredAt ?? instant,
2700
+ monotonicNs: input.monotonicNs ?? this.monotonicNow().toString(),
2701
+ schemaVersion: CHRONICLE_SCHEMA_VERSION,
2702
+ eventId: this.idFactory(),
2703
+ observedAt: instant,
2704
+ persistedAt: instant,
2705
+ sequence: previous.sequence + 1,
2706
+ previousHash: previous.hash
2707
+ };
2708
+ const event = { ...unhashed, hash: hashValue(unhashed) };
2709
+ events.push(event);
2710
+ previous = { sequence: event.sequence, hash: event.hash };
2711
+ }
2712
+ const payloads = events.map((event) => JSON.stringify(event));
2713
+ let batchBytes = 0;
2714
+ for (const payload of payloads) batchBytes += Buffer.byteLength(payload, "utf8");
2715
+ let retainedCountAfterCommit = this.retainedEventCount;
2716
+ try {
2717
+ this.db.exec("BEGIN IMMEDIATE");
2718
+ this.assertWithinByteQuota(batchBytes);
2719
+ const { insert } = this.preparedStatements();
2720
+ for (const [index, event] of events.entries()) {
2721
+ const row = projectEvent(event);
2722
+ insert.run(
2723
+ day,
2724
+ event.sequence,
2725
+ event.eventId,
2726
+ event.hash,
2727
+ event.previousHash,
2728
+ row.occurredAt,
2729
+ event.eventType,
2730
+ row.outcome,
2731
+ row.projectId,
2732
+ row.sessionId,
2733
+ row.agentId,
2734
+ row.taskId,
2735
+ row.traceId,
2736
+ row.logicalRequestId,
2737
+ row.resourceKind,
2738
+ row.resourceId,
2739
+ row.resourcePath,
2740
+ row.durationNs,
2741
+ payloads[index]
2742
+ );
2743
+ }
2744
+ retainedCountAfterCommit = this.enforceEventLimitWithinTransaction(
2745
+ this.retainedEventCount + events.length
2746
+ );
2747
+ this.assertActualAllocationWithinQuota();
2748
+ this.db.exec("COMMIT");
2749
+ } catch (error) {
2750
+ try {
2751
+ this.db.exec("ROLLBACK");
2752
+ } catch {
2753
+ }
2754
+ this.anchors.clear();
2755
+ this.invalidateQuotaEstimate();
2756
+ this.counters.failedEvents += inputs.length;
2757
+ this.lastBatchDurationMs = performance.now() - started;
2758
+ throw this.normalizeQuotaError(error);
2759
+ }
2760
+ this.anchors.set(day, previous);
2761
+ this.counters.persistedEvents += events.length;
2762
+ this.retainedEventCount = retainedCountAfterCommit;
2763
+ this.bytesSinceQuotaCheck += batchBytes;
2764
+ this.lastBatchDurationMs = performance.now() - started;
2765
+ await this.enforceRetentionIfDue();
2766
+ return events;
3176
2767
  }
3177
- return { clause: clauses.length ? clauses.join(" AND ") : "1=1", params };
3178
- }
3179
- var ChronicleSqliteQueryEngine = class {
3180
- constructor(db, options = {}) {
3181
- this.db = db;
3182
- this.batchSize = Math.max(1, options.batchSize ?? 1e3);
2768
+ async readAll() {
2769
+ const rows = this.db.prepare("SELECT payload FROM events ORDER BY day, sequence").all();
2770
+ return rows.map((row) => JSON.parse(row.payload));
3183
2771
  }
3184
- db;
3185
- diagnostics = { sourceFiles: 1, invalidLines: 0 };
3186
- batchSize;
3187
- async query(query = {}) {
3188
- const order = query.order ?? "desc";
3189
- const limit = Math.max(1, Math.min(query.limit ?? 100, MAX_LIMIT));
3190
- const cursor = decodeCursor2(query.cursor);
3191
- const pushed = pushDown(query);
3192
- const direction = order === "asc" ? "ASC" : "DESC";
3193
- const comparison = order === "asc" ? ">" : "<";
3194
- const keyset = cursor ? ` AND (day, sequence) ${comparison} (?, ?)` : "";
3195
- const sql = `SELECT day, sequence, payload FROM events WHERE ${pushed.clause}${keyset} ORDER BY day ${direction}, sequence ${direction} LIMIT ? OFFSET ?`;
3196
- const summary = createSummaryAccumulator();
3197
- const page = [];
3198
- let total = 0;
3199
- let scannedEvents = 0;
3200
- let last;
3201
- let offset = 0;
3202
- for (; ; ) {
3203
- const params = [...pushed.params];
3204
- if (cursor) params.push(cursor.day, cursor.sequence);
3205
- params.push(this.batchSize, offset);
3206
- const rows = this.db.prepare(sql).all(...params);
3207
- if (rows.length === 0) break;
3208
- offset += rows.length;
2772
+ /**
2773
+ * Walk every chain and prove none has been edited.
2774
+ *
2775
+ * Chronicle chains are scoped to a day, not to the journal: the JSONL writer
2776
+ * anchors each `<day>.events.jsonl` family at `GENESIS_HASH` independently,
2777
+ * so `sequence` restarts at 1 every day. Verification mirrors that — each day
2778
+ * is validated on its own, and a break in one does not implicate the others.
2779
+ *
2780
+ * Three independent properties per day, because each catches a different
2781
+ * failure: a dense `sequence` catches deletions from the middle, the
2782
+ * `previousHash` link catches reordering, and re-hashing the payload catches
2783
+ * an in-place edit that left the links intact.
2784
+ */
2785
+ async verify() {
2786
+ let entries = 0;
2787
+ let lastSequence = 0;
2788
+ let lastHash = GENESIS_HASH;
2789
+ for (const day of this.days()) {
2790
+ const checkpoint = this.readCheckpoint(day);
2791
+ let expectedSequence = (checkpoint?.sequence ?? 0) + 1;
2792
+ let previousHash = checkpoint?.hash ?? GENESIS_HASH;
2793
+ const rows = this.db.prepare(
2794
+ "SELECT sequence, hash, previous_hash, payload FROM events WHERE day = ? ORDER BY sequence"
2795
+ ).all(day);
3209
2796
  for (const row of rows) {
3210
- scannedEvents++;
2797
+ if (row.sequence !== expectedSequence) {
2798
+ return {
2799
+ ok: false,
2800
+ entries,
2801
+ brokenAt: entries,
2802
+ reason: `sequence gap in ${day}: expected ${expectedSequence}, found ${row.sequence}`
2803
+ };
2804
+ }
2805
+ if (row.previous_hash !== previousHash) {
2806
+ return { ok: false, entries, brokenAt: entries, reason: "previous hash mismatch" };
2807
+ }
3211
2808
  let event;
3212
2809
  try {
3213
2810
  event = JSON.parse(row.payload);
3214
2811
  } catch {
3215
- this.diagnostics.invalidLines++;
3216
- continue;
2812
+ return { ok: false, entries, brokenAt: entries, reason: "invalid payload JSON" };
3217
2813
  }
3218
- if (!matches(event, query)) continue;
3219
- total++;
3220
- updateSummary(summary, event);
3221
- if (page.length < limit) {
3222
- page.push(event);
3223
- last = { day: row.day, sequence: row.sequence };
2814
+ if (chronicleEventHash(event) !== row.hash) {
2815
+ return { ok: false, entries, brokenAt: entries, reason: "entry hash mismatch" };
3224
2816
  }
2817
+ entries += 1;
2818
+ expectedSequence = row.sequence + 1;
2819
+ previousHash = row.hash;
3225
2820
  }
3226
- if (rows.length < this.batchSize) break;
2821
+ lastSequence = expectedSequence - 1;
2822
+ lastHash = previousHash;
3227
2823
  }
3228
- page.sort((left, right) => compareEvents(left, right) * (order === "asc" ? 1 : -1));
3229
- const result = {
3230
- events: page,
3231
- total,
3232
- scannedEvents,
3233
- sourceFiles: this.diagnostics.sourceFiles,
3234
- invalidLines: this.diagnostics.invalidLines,
3235
- summary: finalizeSummary(summary)
3236
- };
3237
- if (total > page.length && last) {
3238
- return { ...result, nextCursor: encodeCursor2(last) };
2824
+ return { ok: true, entries, lastSequence, lastHash };
2825
+ }
2826
+ async enforceRetentionIfDue() {
2827
+ if (this.retentionDays === void 0) return;
2828
+ const now = this.now();
2829
+ if (now.getTime() < this.nextRetentionCheckAt) return;
2830
+ this.nextRetentionCheckAt = now.getTime() + this.retentionCheckIntervalMs;
2831
+ try {
2832
+ await this.purge({ retentionDays: this.retentionDays });
2833
+ } catch {
3239
2834
  }
3240
- return result;
2835
+ }
2836
+ configureByteQuota() {
2837
+ if (this.maxBytes === void 0) {
2838
+ this.db.exec("PRAGMA max_page_count = 2147483646");
2839
+ return;
2840
+ }
2841
+ const halfSplit = Math.floor((this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES) / 2);
2842
+ const sidecarReserve = Math.min(MAX_WAL_RESERVE_BYTES, halfSplit);
2843
+ const mainBudget = this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES - sidecarReserve;
2844
+ if (mainBudget < MIN_SQLITE_PAGE_BUDGET_BYTES) {
2845
+ throw new Error(
2846
+ `maxBytes must be at least ${SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES}`
2847
+ );
2848
+ }
2849
+ const maxPages = Math.max(1, Math.floor(mainBudget / this.pageSizeBytes()));
2850
+ this.db.exec(`PRAGMA max_page_count = ${maxPages}`);
2851
+ }
2852
+ /**
2853
+ * Bytes the journal actually occupies: live pages in the main database plus
2854
+ * whatever the rollback/WAL sidecars currently hold.
2855
+ *
2856
+ * The quota MUST be measured this way rather than by `statSync` on the main
2857
+ * database file. SQLite never returns freed pages to the filesystem — it
2858
+ * parks them on the freelist and reuses them — so a database that once grew
2859
+ * large keeps that size forever, even after retention and `maxEvents` have
2860
+ * evicted almost everything. Measuring the file instead wedged the journal
2861
+ * permanently: a 3.9 GB file whose live data was 243 MB failed a 512 MB
2862
+ * quota on EVERY append, and the only escape (`chronicle compact`) refuses
2863
+ * to run while the daemon is up — and the daemon respawns on demand. Live
2864
+ * pages shrink when retention deletes rows, so the quota can recover on its
2865
+ * own; allocated size cannot.
2866
+ *
2867
+ * The sidecars keep their on-disk size: they are transient and genuinely
2868
+ * bounded by `configureByteQuota`'s half-of-budget split.
2869
+ */
2870
+ aggregateLiveBytes() {
2871
+ let total = 0;
2872
+ for (const file of [`${this.dbPath}-journal`, `${this.dbPath}-wal`, `${this.dbPath}-shm`]) {
2873
+ try {
2874
+ total += fs6.statSync(file).size;
2875
+ } catch (error) {
2876
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
2877
+ }
2878
+ }
2879
+ return total + this.mainDatabaseLiveBytes();
2880
+ }
2881
+ /** Live (non-freelist) pages of the main database, in bytes. */
2882
+ mainDatabaseLiveBytes() {
2883
+ const pageCount = Number(
2884
+ this.db.prepare("PRAGMA page_count").get().page_count
2885
+ );
2886
+ const freelist = Number(
2887
+ this.db.prepare("PRAGMA freelist_count").get().freelist_count
2888
+ );
2889
+ return Math.max(0, pageCount - freelist) * this.pageSizeBytes();
2890
+ }
2891
+ /** Page size never changes for an open database; resolve it once. */
2892
+ pageSizeBytes() {
2893
+ if (this.cachedPageSize === void 0) {
2894
+ this.cachedPageSize = Number(
2895
+ this.db.prepare("PRAGMA page_size").get().page_size
2896
+ );
2897
+ }
2898
+ return this.cachedPageSize;
2899
+ }
2900
+ normalizeQuotaError(error) {
2901
+ if (this.maxBytes === void 0 || !(error instanceof Error)) return error;
2902
+ const sqliteError = error;
2903
+ const quotaExhausted = sqliteError.code === "SQLITE_FULL" || sqliteError.code === 13 || sqliteError.errcode === 13 || /database or disk is full/i.test(sqliteError.message);
2904
+ if (!quotaExhausted) return error;
2905
+ return new ChronicleStorageQuotaError({
2906
+ currentBytes: this.aggregateLiveBytes(),
2907
+ batchBytes: 0,
2908
+ maxBytes: this.maxBytes,
2909
+ path: this.dbPath
2910
+ });
2911
+ }
2912
+ /** Drop the cached headroom so the next quota check measures for real. */
2913
+ invalidateQuotaEstimate() {
2914
+ this.quotaHeadroomBytes = 0;
2915
+ this.bytesSinceQuotaCheck = Number.POSITIVE_INFINITY;
2916
+ }
2917
+ assertActualAllocationWithinQuota() {
2918
+ if (this.maxBytes === void 0) return;
2919
+ if (!this.shouldMeasureQuota()) return;
2920
+ const currentBytes = this.aggregateLiveBytes();
2921
+ this.recordQuotaHeadroom(currentBytes);
2922
+ if (currentBytes <= this.maxBytes) return;
2923
+ throw new ChronicleStorageQuotaError({
2924
+ currentBytes,
2925
+ batchBytes: 0,
2926
+ maxBytes: this.maxBytes,
2927
+ path: this.dbPath
2928
+ });
3241
2929
  }
3242
2930
  /**
3243
- * Value counts per facet field.
2931
+ * Refuse a batch that would take the journal past its byte quota.
3244
2932
  *
3245
- * Not a SQL `GROUP BY`: `facetValue()` reads fields that live inside the
3246
- * payload provider, model, tool call, tag so grouping in SQL would only
3247
- * work for the handful that happen to be columns and would need a second,
3248
- * divergent definition for the rest. The narrowing is still done by SQL; the
3249
- * counting uses the JSONL engine's own projection.
2933
+ * This is the *courteous* bound, not the enforced one: `max_page_count` makes
2934
+ * SQLite itself fail the write with `SQLITE_FULL`, which `normalizeQuotaError`
2935
+ * turns into the same {@link ChronicleStorageQuotaError}. Its only job is to
2936
+ * produce that error before a doomed batch has been written, so it does not
2937
+ * have to run on every append — and it should not, because measuring costs two
2938
+ * PRAGMA round trips and three `statSync` calls, twice per transaction.
2939
+ *
2940
+ * So it measures adaptively instead of on a fixed cadence: after a real
2941
+ * measurement it knows the spare bytes, and it may skip until the bytes
2942
+ * appended since then could plausibly have consumed half of them. Far from the
2943
+ * ceiling that is thousands of appends; close to it, every one. The bound
2944
+ * tightens exactly where being wrong would matter.
3250
2945
  */
3251
- async facets(fields, query = {}, limit = 100) {
3252
- const uniqueFields = [...new Set(fields)];
3253
- if (uniqueFields.length === 0) return {};
3254
- const counts = new Map(uniqueFields.map((field) => [field, /* @__PURE__ */ new Map()]));
3255
- for (const event of this.eachMatch(query)) {
3256
- for (const field of uniqueFields) {
3257
- const value = facetValue(event, field);
3258
- if (value === void 0) continue;
3259
- const fieldCounts = counts.get(field);
3260
- fieldCounts?.set(value, (fieldCounts.get(value) ?? 0) + 1);
3261
- }
3262
- }
3263
- const result = {};
3264
- for (const field of uniqueFields) {
3265
- result[field] = [...counts.get(field) ?? /* @__PURE__ */ new Map()].map(([value, count]) => ({ value, count })).sort((left, right) => right.count - left.count || left.value.localeCompare(right.value)).slice(0, Math.max(0, limit));
3266
- }
3267
- return result;
2946
+ assertWithinByteQuota(batchBytes) {
2947
+ if (this.maxBytes === void 0) return;
2948
+ if (!this.shouldMeasureQuota(batchBytes)) return;
2949
+ const currentBytes = this.aggregateLiveBytes();
2950
+ this.recordQuotaHeadroom(currentBytes);
2951
+ if (currentBytes + batchBytes <= this.maxBytes) return;
2952
+ throw new ChronicleStorageQuotaError({
2953
+ currentBytes,
2954
+ batchBytes,
2955
+ maxBytes: this.maxBytes,
2956
+ path: this.dbPath
2957
+ });
3268
2958
  }
3269
- async facet(field, query = {}, limit = 100) {
3270
- return (await this.facets([field], query, limit))[field] ?? [];
2959
+ /**
2960
+ * Is the cached headroom still large enough to vouch for this batch?
2961
+ *
2962
+ * Half the headroom is the budget deliberately: rows carry index and page
2963
+ * overhead beyond their payload bytes, so `bytesSinceQuotaCheck` understates
2964
+ * real growth, and the factor absorbs that without needing to model it.
2965
+ */
2966
+ shouldMeasureQuota(batchBytes = 0) {
2967
+ return this.bytesSinceQuotaCheck + batchBytes >= this.quotaHeadroomBytes / 2;
2968
+ }
2969
+ recordQuotaHeadroom(currentBytes) {
2970
+ this.quotaHeadroomBytes = Math.max(0, (this.maxBytes ?? 0) - currentBytes);
2971
+ this.bytesSinceQuotaCheck = 0;
3271
2972
  }
3272
2973
  /**
3273
- * Expand explicit and typed correlation edges around a seed set.
2974
+ * Evict the oldest events once the row ceiling has been overshot by `trimSlack`.
3274
2975
  *
3275
- * The traversal is the JSONL engine's, moved onto rows: `relationKeys()`
3276
- * defines the edges, `compareEvents` orders the nodes, and each hop is
3277
- * another ordered pass. Order is load-bearing rather than cosmetic both
3278
- * `maxNodes` truncations stop at whatever they reach first, so a differently
3279
- * ordered scan would return a different subgraph rather than the same one
3280
- * shuffled. `ORDER BY day, sequence` reproduces `comparePartitionPaths`
3281
- * (family, then rotation index) followed by line order within a partition.
2976
+ * @param count Rows now in the table. The caller tracks this rather than the
2977
+ * method querying it: `SELECT COUNT(*)` walks the whole table, and at the
2978
+ * ceiling where every long-lived journal livesthat was a full scan of
2979
+ * `maxEvents` rows on every append, to learn a number the append path already
2980
+ * knew. The count is re-derived from the database whenever it could have
2981
+ * drifted (open, import, purge), never accumulated blindly across those.
2982
+ * @param slack Overshoot tolerated before evicting; callers that run once,
2983
+ * rather than per append, pass 0 to land on the exact ceiling.
3282
2984
  */
3283
- async graph(seed = {}, hops = 2, maxNodes = 1e3) {
3284
- const nodeLimit = Math.max(0, Math.floor(maxNodes));
3285
- const selected = /* @__PURE__ */ new Map();
3286
- let seedCount = 0;
3287
- for (const event of this.eachMatch(seed)) {
3288
- seedCount++;
3289
- if (selected.size < nodeLimit) selected.set(event.eventId, event);
2985
+ enforceEventLimitWithinTransaction(count, slack = this.trimSlack) {
2986
+ if (this.maxEvents === void 0 || count <= this.maxEvents + slack) return count;
2987
+ const excess = count - this.maxEvents;
2988
+ const statements = this.preparedStatements();
2989
+ const boundary = statements.trimBoundary.get(excess - 1);
2990
+ if (!boundary) return count;
2991
+ statements.writeCheckpoint.run(boundary.day, boundary.sequence, boundary.hash);
2992
+ statements.deletePrefix.run(boundary.day, boundary.day, boundary.sequence);
2993
+ statements.deleteCheckpoints.run(boundary.day);
2994
+ for (const day of this.anchors.keys()) {
2995
+ if (day <= boundary.day) this.anchors.delete(day);
3290
2996
  }
3291
- let frontier = [...selected.values()];
3292
- const depthLimit = Math.max(0, Math.min(hops, 10));
3293
- for (let depth = 0; depth < depthLimit && frontier.length > 0 && selected.size < nodeLimit; depth++) {
3294
- const frontierKeys = new Set(
3295
- frontier.flatMap((event) => relationKeys(event).map((relation) => relation.key))
3296
- );
3297
- const next = [];
3298
- for (const event of this.eachMatch({})) {
3299
- if (selected.has(event.eventId)) continue;
3300
- if (!relationKeys(event).some((relation) => frontierKeys.has(relation.key))) continue;
3301
- selected.set(event.eventId, event);
3302
- next.push(event);
3303
- if (selected.size >= nodeLimit) break;
2997
+ return this.maxEvents;
2998
+ }
2999
+ enforceEventLimitAtStartup() {
3000
+ if (this.maxEvents === void 0 || this.retainedEventCount <= this.maxEvents) return;
3001
+ try {
3002
+ this.db.exec("BEGIN IMMEDIATE");
3003
+ const retainedCount = this.enforceEventLimitWithinTransaction(this.retainedEventCount, 0);
3004
+ this.db.exec("COMMIT");
3005
+ this.retainedEventCount = retainedCount;
3006
+ } catch (error) {
3007
+ try {
3008
+ this.db.exec("ROLLBACK");
3009
+ } catch {
3304
3010
  }
3305
- frontier = next;
3011
+ throw error;
3306
3012
  }
3307
- const nodes = [...selected.values()].sort(compareEvents);
3308
- const byKey = /* @__PURE__ */ new Map();
3309
- for (const node of nodes) {
3310
- for (const relation of relationKeys(node)) {
3311
- const related = byKey.get(relation.key) ?? [];
3312
- related.push(node);
3313
- byKey.set(relation.key, related);
3013
+ }
3014
+ /**
3015
+ * Drop events older than the retention window.
3016
+ *
3017
+ * Retention is day-granular and chains are day-scoped, so a purge removes
3018
+ * whole chains rather than truncating one. That is why nothing needs to be
3019
+ * checkpointed here: there is no surviving suffix left dangling without an
3020
+ * anchor. Any checkpoint imported from a partially-purged legacy day family
3021
+ * is dropped alongside its events.
3022
+ */
3023
+ async purge(options) {
3024
+ const empty = {
3025
+ deletedCount: 0,
3026
+ deletedBytes: 0,
3027
+ skippedCount: 0,
3028
+ errors: []
3029
+ };
3030
+ if (!Number.isFinite(options.retentionDays) || options.retentionDays <= 0) return empty;
3031
+ const cutoff = new Date(this.now().getTime() - options.retentionDays * 864e5).toISOString().slice(0, 10);
3032
+ const count = this.db.prepare("SELECT COUNT(*) AS n FROM events WHERE day < ?").get(cutoff).n;
3033
+ if (count === 0) return empty;
3034
+ if (options.dryRun) {
3035
+ const days = this.db.prepare("SELECT DISTINCT day FROM events WHERE day < ? ORDER BY day").all(cutoff);
3036
+ return { ...empty, deletedCount: count, candidates: days.map((row) => row.day) };
3037
+ }
3038
+ try {
3039
+ this.db.exec("BEGIN IMMEDIATE");
3040
+ this.db.prepare("DELETE FROM events WHERE day < ?").run(cutoff);
3041
+ this.db.prepare("DELETE FROM chain_checkpoint WHERE day < ?").run(cutoff);
3042
+ this.db.exec("COMMIT");
3043
+ } catch (error) {
3044
+ try {
3045
+ this.db.exec("ROLLBACK");
3046
+ } catch {
3314
3047
  }
3048
+ return {
3049
+ ...empty,
3050
+ errors: [
3051
+ {
3052
+ file: this.dbPath,
3053
+ reason: error instanceof Error ? error.message : String(error)
3054
+ }
3055
+ ]
3056
+ };
3315
3057
  }
3316
- const edges = [];
3317
- const seen = /* @__PURE__ */ new Set();
3318
- for (const node of nodes) {
3319
- for (const relation of relationKeys(node)) {
3320
- for (const candidate of byKey.get(relation.key) ?? []) {
3321
- if (candidate.eventId === node.eventId) continue;
3322
- const [from, to] = compareEvents(node, candidate) <= 0 ? [node, candidate] : [candidate, node];
3323
- const id = `${from.eventId}:${to.eventId}:${relation.kind}`;
3324
- if (seen.has(id)) continue;
3325
- seen.add(id);
3326
- edges.push({
3327
- from: from.eventId,
3328
- to: to.eventId,
3329
- kind: relation.kind,
3330
- confidence: relation.confidence
3331
- });
3058
+ this.anchors.clear();
3059
+ this.retainedEventCount = this.countRows();
3060
+ this.invalidateQuotaEstimate();
3061
+ return { ...empty, deletedCount: count };
3062
+ }
3063
+ /**
3064
+ * Run one day family's legacy import inside its own transaction.
3065
+ *
3066
+ * Deliberately separate from `appendBatch`: the append path *computes*
3067
+ * `sequence`, `previousHash` and `hash`, while an import must carry them over
3068
+ * untouched. Fusing the two would put a code path one refactor away from
3069
+ * re-hashing historical events, which is the one change that silently
3070
+ * destroys their tamper evidence.
3071
+ *
3072
+ * The transaction is scoped to a single family because chains are: `sequence`
3073
+ * restarts at 1 each day, so one day's break says nothing about the next
3074
+ * day's integrity. A whole-journal transaction made every future day hostage
3075
+ * to the worst day on disk — one corrupt family and the daemon could never
3076
+ * open its store again. The family is still all-or-nothing: a break rolls
3077
+ * back that day entirely, so no partial chain is ever visible.
3078
+ */
3079
+ async runFamilyImport(load) {
3080
+ const insert = this.db.prepare(
3081
+ `INSERT INTO events (
3082
+ day, sequence, event_id, hash, previous_hash, occurred_at, event_type, outcome,
3083
+ project_id, session_id, agent_id, task_id, trace_id, logical_request_id,
3084
+ resource_kind, resource_id, resource_path, duration_ns, payload
3085
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
3086
+ );
3087
+ const checkpoint = this.db.prepare(
3088
+ `INSERT INTO chain_checkpoint (day, sequence, hash) VALUES (?, ?, ?)
3089
+ ON CONFLICT(day) DO UPDATE SET sequence = excluded.sequence, hash = excluded.hash`
3090
+ );
3091
+ this.db.exec("BEGIN IMMEDIATE");
3092
+ let retainedCountAfterCommit = this.retainedEventCount;
3093
+ try {
3094
+ this.invalidateQuotaEstimate();
3095
+ this.assertWithinByteQuota(0);
3096
+ await load({
3097
+ insert: (day, event) => {
3098
+ const row = projectEvent(event);
3099
+ insert.run(
3100
+ day,
3101
+ event.sequence,
3102
+ event.eventId,
3103
+ event.hash,
3104
+ event.previousHash,
3105
+ row.occurredAt,
3106
+ event.eventType,
3107
+ row.outcome,
3108
+ row.projectId,
3109
+ row.sessionId,
3110
+ row.agentId,
3111
+ row.taskId,
3112
+ row.traceId,
3113
+ row.logicalRequestId,
3114
+ row.resourceKind,
3115
+ row.resourceId,
3116
+ row.resourcePath,
3117
+ row.durationNs,
3118
+ JSON.stringify(event)
3119
+ );
3120
+ },
3121
+ checkpoint: (day, sequence, hash3) => {
3122
+ checkpoint.run(day, sequence, hash3);
3332
3123
  }
3124
+ });
3125
+ retainedCountAfterCommit = this.enforceEventLimitWithinTransaction(this.countRows(), 0);
3126
+ this.invalidateQuotaEstimate();
3127
+ this.assertActualAllocationWithinQuota();
3128
+ this.db.exec("COMMIT");
3129
+ } catch (error) {
3130
+ try {
3131
+ this.db.exec("ROLLBACK");
3132
+ } catch {
3333
3133
  }
3134
+ this.invalidateQuotaEstimate();
3135
+ throw this.normalizeQuotaError(error);
3136
+ } finally {
3137
+ this.anchors.clear();
3138
+ }
3139
+ this.retainedEventCount = retainedCountAfterCommit;
3140
+ }
3141
+ /**
3142
+ * A read engine over this journal's own connection.
3143
+ *
3144
+ * Sharing the connection rather than opening a second one keeps the
3145
+ * single-writer guarantee intact and means a reader can never observe a
3146
+ * half-applied batch: SQLite serialises statements on one handle.
3147
+ */
3148
+ queryEngine(options) {
3149
+ return new ChronicleSqliteQueryEngine(this.db, options);
3150
+ }
3151
+ /** Has the legacy JSONL import already run? */
3152
+ hasImportedLegacyJournal() {
3153
+ return this.readMeta(LEGACY_JSONL_MIGRATION_KEY) !== void 0;
3154
+ }
3155
+ markLegacyJournalImported() {
3156
+ this.db.prepare(
3157
+ `INSERT INTO chronicle_meta (key, value) VALUES (?, 'done')
3158
+ ON CONFLICT(key) DO UPDATE SET value = 'done'`
3159
+ ).run(LEGACY_JSONL_MIGRATION_KEY);
3160
+ }
3161
+ /**
3162
+ * Record the day families the import refused to move.
3163
+ *
3164
+ * Persisted rather than merely logged because the import runs once: after the
3165
+ * marker is set nothing re-reads the JSONL, so this row is the only surviving
3166
+ * evidence that a day was dropped. Health reports read it back to say
3167
+ * "degraded, and here is exactly what is missing" instead of quietly serving
3168
+ * a journal with a hole in it.
3169
+ */
3170
+ recordQuarantinedFamilies(families) {
3171
+ if (families.length === 0) return;
3172
+ this.db.prepare(
3173
+ `INSERT INTO chronicle_meta (key, value) VALUES (?, ?)
3174
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
3175
+ ).run(LEGACY_JSONL_QUARANTINE_KEY, JSON.stringify(families));
3176
+ }
3177
+ /**
3178
+ * Does this day already hold rows?
3179
+ *
3180
+ * Each family commits on its own, so an import interrupted between families
3181
+ * leaves a database that is complete for the days it reached. `(day,
3182
+ * sequence)` is the primary key, so re-inserting one of those days would
3183
+ * abort on a constraint violation rather than start over — this is what lets
3184
+ * the next run resume at the first day it never got to.
3185
+ */
3186
+ hasImportedDay(day) {
3187
+ const row = this.db.prepare("SELECT 1 AS present FROM events WHERE day = ? LIMIT 1").get(day);
3188
+ return row !== void 0;
3189
+ }
3190
+ /** Day families the legacy import refused to move, oldest first. */
3191
+ quarantinedFamilies() {
3192
+ const raw = this.readMeta(LEGACY_JSONL_QUARANTINE_KEY);
3193
+ if (!raw) return [];
3194
+ try {
3195
+ const parsed = JSON.parse(raw);
3196
+ return Array.isArray(parsed) ? parsed : [];
3197
+ } catch {
3198
+ return [];
3334
3199
  }
3335
- return {
3336
- nodes,
3337
- edges,
3338
- truncated: seedCount > nodeLimit || selected.size >= nodeLimit
3200
+ }
3201
+ // ─── internals ────────────────────────────────────────────────────────────
3202
+ preparedStatements() {
3203
+ this.statements ??= {
3204
+ insert: this.db.prepare(
3205
+ `INSERT INTO events (
3206
+ day, sequence, event_id, hash, previous_hash, occurred_at, event_type, outcome,
3207
+ project_id, session_id, agent_id, task_id, trace_id, logical_request_id,
3208
+ resource_kind, resource_id, resource_path, duration_ns, payload
3209
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
3210
+ ),
3211
+ trimBoundary: this.db.prepare(
3212
+ "SELECT day, sequence, hash FROM events ORDER BY day, sequence LIMIT 1 OFFSET ?"
3213
+ ),
3214
+ writeCheckpoint: this.db.prepare(
3215
+ `INSERT INTO chain_checkpoint (day, sequence, hash) VALUES (?, ?, ?)
3216
+ ON CONFLICT(day) DO UPDATE SET sequence = excluded.sequence, hash = excluded.hash`
3217
+ ),
3218
+ deletePrefix: this.db.prepare(
3219
+ "DELETE FROM events WHERE day < ? OR (day = ? AND sequence <= ?)"
3220
+ ),
3221
+ deleteCheckpoints: this.db.prepare("DELETE FROM chain_checkpoint WHERE day < ?")
3339
3222
  };
3223
+ return this.statements;
3340
3224
  }
3341
- /** Every event satisfying `query`, pulled in batches so a wide scan stays bounded. */
3342
- *eachMatch(query) {
3343
- const pushed = pushDown(query);
3344
- const sql = `SELECT payload FROM events WHERE ${pushed.clause} ORDER BY day, sequence LIMIT ? OFFSET ?`;
3345
- let offset = 0;
3346
- for (; ; ) {
3347
- const rows = this.db.prepare(sql).all(...pushed.params, this.batchSize, offset);
3348
- if (rows.length === 0) return;
3349
- offset += rows.length;
3350
- for (const row of rows) {
3351
- let event;
3352
- try {
3353
- event = JSON.parse(row.payload);
3354
- } catch {
3355
- this.diagnostics.invalidLines++;
3356
- continue;
3357
- }
3358
- if (matches(event, query)) yield event;
3359
- }
3360
- if (rows.length < this.batchSize) return;
3225
+ /** Rows currently in the table, straight from the database. */
3226
+ countRows() {
3227
+ return this.db.prepare("SELECT COUNT(*) AS count FROM events").get().count;
3228
+ }
3229
+ readMeta(key) {
3230
+ const row = this.db.prepare("SELECT value FROM chronicle_meta WHERE key = ?").get(key);
3231
+ return row?.value;
3232
+ }
3233
+ readCheckpoint(day) {
3234
+ return this.db.prepare("SELECT sequence, hash FROM chain_checkpoint WHERE day = ?").get(day);
3235
+ }
3236
+ /** Days that currently hold at least one event, oldest first. */
3237
+ days() {
3238
+ const rows = this.db.prepare("SELECT DISTINCT day FROM events ORDER BY day").all();
3239
+ return rows.map((row) => row.day);
3240
+ }
3241
+ /**
3242
+ * Resolve the chain head, in the same precedence the JSONL journal uses: the
3243
+ * newest event, else the retention checkpoint (every event before it was
3244
+ * purged), else genesis.
3245
+ */
3246
+ readAnchor(day) {
3247
+ const cached = this.anchors.get(day);
3248
+ if (cached) return cached;
3249
+ const last = this.db.prepare("SELECT sequence, hash FROM events WHERE day = ? ORDER BY sequence DESC LIMIT 1").get(day);
3250
+ const anchor = last ?? this.readCheckpoint(day) ?? { sequence: 0, hash: GENESIS_HASH };
3251
+ this.anchors.set(day, anchor);
3252
+ return anchor;
3253
+ }
3254
+ ensureSchema() {
3255
+ const version = this.db.prepare("PRAGMA user_version").get().user_version;
3256
+ this.db.exec(`
3257
+ CREATE TABLE IF NOT EXISTS events (
3258
+ day TEXT NOT NULL,
3259
+ sequence INTEGER NOT NULL,
3260
+ event_id TEXT NOT NULL UNIQUE,
3261
+ hash TEXT NOT NULL,
3262
+ previous_hash TEXT NOT NULL,
3263
+ occurred_at TEXT NOT NULL,
3264
+ event_type TEXT NOT NULL,
3265
+ outcome TEXT,
3266
+ project_id TEXT,
3267
+ session_id TEXT,
3268
+ agent_id TEXT,
3269
+ task_id TEXT,
3270
+ trace_id TEXT,
3271
+ logical_request_id TEXT,
3272
+ resource_kind TEXT,
3273
+ resource_id TEXT,
3274
+ resource_path TEXT,
3275
+ duration_ns TEXT,
3276
+ payload TEXT NOT NULL,
3277
+ PRIMARY KEY (day, sequence)
3278
+ );
3279
+ CREATE INDEX IF NOT EXISTS events_occurred_at ON events(occurred_at);
3280
+ CREATE INDEX IF NOT EXISTS events_type_outcome ON events(event_type, outcome);
3281
+ CREATE INDEX IF NOT EXISTS events_session ON events(session_id, day, sequence);
3282
+ CREATE INDEX IF NOT EXISTS events_trace ON events(trace_id);
3283
+ CREATE INDEX IF NOT EXISTS events_logical_request ON events(logical_request_id);
3284
+
3285
+ CREATE TABLE IF NOT EXISTS chain_checkpoint (
3286
+ day TEXT PRIMARY KEY,
3287
+ sequence INTEGER NOT NULL,
3288
+ hash TEXT NOT NULL
3289
+ );
3290
+
3291
+ CREATE TABLE IF NOT EXISTS chronicle_meta (
3292
+ key TEXT PRIMARY KEY,
3293
+ value TEXT NOT NULL
3294
+ );
3295
+ `);
3296
+ if (version < 2) {
3297
+ this.db.exec("DROP INDEX IF EXISTS events_resource_path");
3298
+ }
3299
+ if (version !== SCHEMA_VERSION) {
3300
+ this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
3361
3301
  }
3362
3302
  }
3363
3303
  };
3304
+ function projectEvent(event) {
3305
+ const occurredAt = event.occurredAt ?? event.observedAt;
3306
+ return {
3307
+ occurredAt,
3308
+ outcome: event.outcome ?? null,
3309
+ projectId: event.scope.projectId ?? null,
3310
+ sessionId: event.scope.sessionId ?? null,
3311
+ agentId: event.scope.agentId ?? null,
3312
+ taskId: event.scope.taskId ?? null,
3313
+ traceId: event.correlation?.traceId ?? null,
3314
+ logicalRequestId: event.correlation?.logicalRequestId ?? null,
3315
+ resourceKind: event.resource?.kind ?? null,
3316
+ resourceId: event.resource?.id ?? null,
3317
+ resourcePath: event.resource?.path ?? null,
3318
+ durationNs: event.durationNs ?? null
3319
+ };
3320
+ }
3364
3321
 
3365
- // src/chronicle/sqlite-journal.ts
3366
- var CHRONICLE_SQLITE_FILE = "chronicle.sqlite";
3367
- var LEGACY_JSONL_MIGRATION_KEY = "legacy-jsonl-v1";
3368
- var LEGACY_JSONL_QUARANTINE_KEY = "legacy-jsonl-v1:quarantine";
3369
- var SCHEMA_VERSION2 = 1;
3370
- var SQLITE_FIXED_OVERHEAD_BYTES = 16 * 1024 * 1024;
3371
- var MIN_SQLITE_PAGE_BUDGET_BYTES = 64 * 1024;
3372
- var MAX_ROLLBACK_JOURNAL_RESERVE_BYTES = 64 * 1024 * 1024;
3373
- var ChronicleStorageQuotaError = class extends Error {
3374
- currentBytes;
3375
- batchBytes;
3376
- maxBytes;
3377
- path;
3378
- constructor(details) {
3379
- super(
3380
- `Chronicle SQLite quota exceeded at ${details.path}: ${details.currentBytes} live bytes + ${details.batchBytes} batch bytes exceeds ${details.maxBytes}; lower chronicle retentionDays/maxEvents to shed data (run chronicle compact to return the freed pages to the filesystem)`
3381
- );
3382
- this.name = "ChronicleStorageQuotaError";
3383
- this.currentBytes = details.currentBytes;
3384
- this.batchBytes = details.batchBytes;
3385
- this.maxBytes = details.maxBytes;
3386
- this.path = details.path;
3387
- }
3322
+ // src/chronicle/metrics-store.ts
3323
+ var SCHEMA_VERSION2 = 3;
3324
+ var READ_CHUNK_BYTES = 1024 * 1024;
3325
+ var SQLITE_SOURCE_PREFIX = "sqlite:";
3326
+ var SQLITE_INGEST_BATCH = 2e3;
3327
+ var EMPTY_FAMILIES = {
3328
+ llm: 0,
3329
+ agent: 0,
3330
+ tool: 0,
3331
+ file: 0,
3332
+ memory: 0,
3333
+ task: 0,
3334
+ decision: 0,
3335
+ runtime: 0,
3336
+ finding: 0
3388
3337
  };
3389
3338
  var Ctor2;
3390
3339
  function loadDatabaseSync2() {
@@ -3398,657 +3347,889 @@ function loadDatabaseSync2() {
3398
3347
  } catch (error) {
3399
3348
  Ctor2 = null;
3400
3349
  throw new Error(
3401
- "The Chronicle journal needs Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
3350
+ "Chronicle metrics need Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
3402
3351
  );
3403
3352
  }
3404
3353
  }
3405
- var ChronicleSqliteJournal = class {
3354
+ function isChronicleMetricsAvailable() {
3355
+ try {
3356
+ loadDatabaseSync2();
3357
+ return true;
3358
+ } catch {
3359
+ return false;
3360
+ }
3361
+ }
3362
+ var ChronicleMetricsStore = class _ChronicleMetricsStore {
3406
3363
  db;
3364
+ directory;
3407
3365
  dbPath;
3408
- now;
3409
- monotonicNow;
3410
- idFactory;
3411
- retentionDays;
3412
- maxEvents;
3413
- maxBytes;
3414
- retentionCheckIntervalMs;
3415
- nextRetentionCheckAt = 0;
3416
- retainedEventCount = 0;
3417
- /** Resolved lazily by `pageSizeBytes()`; constant for an open database. */
3418
- cachedPageSize;
3419
- /**
3420
- * Cached chain head per day. Cleared wholesale on any write failure so the
3421
- * next append rebuilds from the database rather than trusting a counter that
3422
- * may have advanced past what actually committed.
3423
- */
3424
- anchors = /* @__PURE__ */ new Map();
3425
- counters = {
3426
- acceptedEvents: 0,
3427
- persistedEvents: 0,
3428
- rejectedEvents: 0,
3429
- failedEvents: 0,
3430
- batches: 0,
3431
- maxObservedPending: 0,
3432
- largestBatch: 0
3433
- };
3434
- lastBatchDurationMs;
3435
- constructor(options) {
3436
- this.dbPath = path11.join(path11.resolve(options.directory), CHRONICLE_SQLITE_FILE);
3437
- this.now = options.now ?? (() => /* @__PURE__ */ new Date());
3438
- this.monotonicNow = options.monotonicNow ?? (() => process.hrtime.bigint());
3439
- this.idFactory = options.idFactory ?? (() => randomUUID3());
3440
- if (options.retentionDays !== void 0 && (!Number.isFinite(options.retentionDays) || options.retentionDays <= 0)) {
3441
- throw new RangeError("retentionDays must be a positive finite number");
3442
- }
3443
- if (options.retentionCheckIntervalMs !== void 0 && (!Number.isFinite(options.retentionCheckIntervalMs) || options.retentionCheckIntervalMs <= 0)) {
3444
- throw new RangeError("retentionCheckIntervalMs must be a positive finite number");
3445
- }
3446
- if (options.maxEvents !== void 0 && (!Number.isInteger(options.maxEvents) || options.maxEvents < 1)) {
3447
- throw new RangeError("maxEvents must be a positive integer");
3448
- }
3449
- if (options.maxBytes !== void 0 && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1)) {
3450
- throw new RangeError("maxBytes must be a positive safe integer");
3451
- }
3452
- const minimumQuotaBytes = SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES;
3453
- if (options.maxBytes !== void 0 && options.maxBytes < minimumQuotaBytes) {
3454
- throw new RangeError(`maxBytes must be at least ${minimumQuotaBytes}`);
3455
- }
3456
- this.retentionDays = options.retentionDays;
3457
- this.maxEvents = options.maxEvents;
3458
- this.maxBytes = options.maxBytes;
3459
- this.retentionCheckIntervalMs = options.retentionCheckIntervalMs ?? 60 * 60 * 1e3;
3366
+ constructor(directory) {
3367
+ this.directory = path10.resolve(directory);
3368
+ this.dbPath = path10.join(this.directory, "metrics.db");
3460
3369
  const Database = loadDatabaseSync2();
3461
3370
  this.db = new Database(this.dbPath);
3462
- this.db.exec(`PRAGMA journal_mode = ${this.maxBytes === void 0 ? "WAL" : "DELETE"}`);
3371
+ this.db.exec("PRAGMA journal_mode = WAL");
3463
3372
  this.ensureSchema();
3464
- this.configureByteQuota();
3465
- if (this.maxEvents !== void 0) {
3466
- const row = this.db.prepare("SELECT COUNT(*) AS count FROM events").get();
3467
- this.retainedEventCount = row.count;
3468
- this.enforceEventLimitAtStartup();
3469
- }
3373
+ }
3374
+ static open(chronicleDirectory) {
3375
+ return new _ChronicleMetricsStore(chronicleDirectory);
3470
3376
  }
3471
3377
  close() {
3472
3378
  this.db.close();
3473
3379
  }
3474
- stats() {
3475
- return {
3476
- ...this.counters,
3477
- pendingEvents: 0,
3478
- // Retained at zero for wire compatibility: partitions do not exist here,
3479
- // but `ChronicleJournalStats` is part of the IPC health payload.
3480
- partitionRolls: 0,
3481
- ...this.lastBatchDurationMs !== void 0 ? { lastBatchDurationMs: this.lastBatchDurationMs } : {}
3380
+ /** Incrementally ingest journal bytes appended since the last refresh.
3381
+ * Safe across processes: guarded by a file lock on the database path. */
3382
+ async refresh() {
3383
+ const result = {
3384
+ ingestedEvents: 0,
3385
+ ingestedBytes: 0,
3386
+ sourceFiles: 0,
3387
+ invalidLines: 0
3482
3388
  };
3389
+ await withFileLock(this.dbPath, async () => {
3390
+ const files = await findChroniclePartitions(this.directory);
3391
+ const offsets = this.loadOffsets();
3392
+ for (const file of files) {
3393
+ const key = normalizeKey(path10.relative(this.directory, file));
3394
+ const consumed = offsets.get(key) ?? 0;
3395
+ const ingested = await this.ingestFile(file, key, consumed, result);
3396
+ if (ingested) result.sourceFiles++;
3397
+ }
3398
+ this.pruneOffsets(files);
3399
+ this.ingestSqliteJournal(offsets, result);
3400
+ });
3401
+ return result;
3483
3402
  }
3484
- /**
3485
- * Writes are synchronous and transactional, so there is nothing buffered to
3486
- * flush. Kept to satisfy `ChronicleEventSink`.
3487
- */
3488
- async flush() {
3489
- return Promise.resolve();
3403
+ providerDaily(options = {}) {
3404
+ const clauses = [];
3405
+ const params = [];
3406
+ if (options.from) {
3407
+ clauses.push("day >= ?");
3408
+ params.push(options.from.slice(0, 10));
3409
+ }
3410
+ if (options.to) {
3411
+ clauses.push("day <= ?");
3412
+ params.push(options.to.slice(0, 10));
3413
+ }
3414
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
3415
+ const rows = this.db.prepare(
3416
+ `SELECT day, provider_id, model_id, attempts, completed, failed, retries, fallbacks,
3417
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
3418
+ duration_ms_total, duration_ms_max, duration_count
3419
+ FROM provider_daily${where} ORDER BY day DESC, provider_id, model_id`
3420
+ ).all(...params);
3421
+ return rows.map((row) => ({
3422
+ day: String(row.day),
3423
+ providerId: String(row.provider_id),
3424
+ modelId: String(row.model_id),
3425
+ attempts: Number(row.attempts),
3426
+ completed: Number(row.completed),
3427
+ failed: Number(row.failed),
3428
+ retries: Number(row.retries),
3429
+ fallbacks: Number(row.fallbacks),
3430
+ inputTokens: Number(row.input_tokens),
3431
+ outputTokens: Number(row.output_tokens),
3432
+ cacheReadTokens: Number(row.cache_read_tokens),
3433
+ cacheWriteTokens: Number(row.cache_write_tokens),
3434
+ avgDurationMs: Number(row.duration_count) > 0 ? Number(row.duration_ms_total) / Number(row.duration_count) : 0,
3435
+ maxDurationMs: Number(row.duration_ms_max)
3436
+ }));
3490
3437
  }
3491
- async append(input) {
3492
- const [event] = await this.appendBatch([input]);
3493
- return event;
3438
+ taskOutcomes(options = {}) {
3439
+ const clauses = [];
3440
+ const params = [];
3441
+ if (options.runId) {
3442
+ clauses.push("t.run_id = ?");
3443
+ params.push(options.runId);
3444
+ }
3445
+ if (options.boardId) {
3446
+ clauses.push("t.board_id = ?");
3447
+ params.push(options.boardId);
3448
+ }
3449
+ if (options.sessionId) {
3450
+ clauses.push("t.session_id = ?");
3451
+ params.push(options.sessionId);
3452
+ }
3453
+ if (options.status) {
3454
+ clauses.push("t.status = ?");
3455
+ params.push(options.status);
3456
+ }
3457
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
3458
+ params.push(clampLimit(options.limit, 100));
3459
+ const rows = this.db.prepare(
3460
+ `SELECT t.*, (SELECT COUNT(*) FROM file_lineage f WHERE f.task_id = t.task_id) AS files_touched
3461
+ FROM task_outcomes t${where}
3462
+ ORDER BY COALESCE(t.started_at, '') DESC LIMIT ?`
3463
+ ).all(...params);
3464
+ return rows.map((row) => ({
3465
+ taskId: String(row.task_id),
3466
+ runId: String(row.run_id),
3467
+ boardId: String(row.board_id),
3468
+ sessionId: String(row.session_id),
3469
+ agentId: String(row.agent_id),
3470
+ status: String(row.status),
3471
+ startedAt: row.started_at === null ? null : String(row.started_at),
3472
+ endedAt: row.ended_at === null ? null : String(row.ended_at),
3473
+ durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
3474
+ retries: Number(row.retries),
3475
+ verificationFailures: Number(row.verification_failures),
3476
+ filesTouched: Number(row.files_touched)
3477
+ }));
3494
3478
  }
3495
- /**
3496
- * Append a batch as one transaction.
3497
- *
3498
- * The chain is computed in memory from a single anchor, so a partially
3499
- * applied batch would leave a hole in `sequence` that verification can never
3500
- * reconcile. Rollback plus anchor invalidation is what prevents that.
3501
- */
3502
- async appendBatch(inputs) {
3503
- if (inputs.length === 0) return [];
3504
- const started = performance.now();
3505
- this.counters.acceptedEvents += inputs.length;
3506
- this.counters.batches += 1;
3507
- this.counters.largestBatch = Math.max(this.counters.largestBatch, inputs.length);
3508
- const instant = this.now().toISOString();
3509
- const day = instant.slice(0, 10);
3510
- const events = [];
3511
- let previous = this.readAnchor(day);
3512
- for (const input of inputs) {
3513
- const unhashed = {
3514
- ...input,
3515
- occurredAt: input.occurredAt ?? instant,
3516
- monotonicNs: input.monotonicNs ?? this.monotonicNow().toString(),
3517
- schemaVersion: CHRONICLE_SCHEMA_VERSION,
3518
- eventId: this.idFactory(),
3519
- observedAt: instant,
3520
- persistedAt: instant,
3521
- sequence: previous.sequence + 1,
3522
- previousHash: previous.hash
3523
- };
3524
- const event = { ...unhashed, hash: hashValue(unhashed) };
3525
- events.push(event);
3526
- previous = { sequence: event.sequence, hash: event.hash };
3479
+ fileLineage(options = {}) {
3480
+ const clauses = [];
3481
+ const params = [];
3482
+ if (options.path) {
3483
+ clauses.push("path_key = ?");
3484
+ params.push(normalizePathKey(options.path));
3527
3485
  }
3528
- let retainedCountAfterCommit = this.retainedEventCount;
3529
- try {
3530
- this.db.exec("BEGIN IMMEDIATE");
3531
- this.assertWithinByteQuota(events);
3532
- const insert = this.db.prepare(
3533
- `INSERT INTO events (
3534
- day, sequence, event_id, hash, previous_hash, occurred_at, event_type, outcome,
3535
- project_id, session_id, agent_id, task_id, trace_id, logical_request_id,
3536
- resource_kind, resource_id, resource_path, duration_ns, payload
3537
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
3538
- );
3539
- for (const event of events) {
3540
- const row = projectEvent(event);
3541
- insert.run(
3542
- day,
3543
- event.sequence,
3544
- event.eventId,
3545
- event.hash,
3546
- event.previousHash,
3547
- row.occurredAt,
3548
- event.eventType,
3549
- row.outcome,
3550
- row.projectId,
3551
- row.sessionId,
3552
- row.agentId,
3553
- row.taskId,
3554
- row.traceId,
3555
- row.logicalRequestId,
3556
- row.resourceKind,
3557
- row.resourceId,
3558
- row.resourcePath,
3559
- row.durationNs,
3560
- JSON.stringify(event)
3561
- );
3562
- }
3563
- retainedCountAfterCommit = this.enforceEventLimitWithinTransaction();
3564
- this.assertActualAllocationWithinQuota();
3565
- this.db.exec("COMMIT");
3566
- } catch (error) {
3567
- try {
3568
- this.db.exec("ROLLBACK");
3569
- } catch {
3570
- }
3571
- this.anchors.clear();
3572
- this.counters.failedEvents += inputs.length;
3573
- this.lastBatchDurationMs = performance.now() - started;
3574
- throw this.normalizeQuotaError(error);
3486
+ if (options.paths) {
3487
+ const pathKeys = [...new Set(options.paths.map(normalizePathKey))];
3488
+ if (pathKeys.length === 0) return [];
3489
+ clauses.push(`path_key IN (${pathKeys.map(() => "?").join(",")})`);
3490
+ params.push(...pathKeys);
3575
3491
  }
3576
- this.anchors.set(day, previous);
3577
- this.counters.persistedEvents += events.length;
3578
- this.retainedEventCount = retainedCountAfterCommit;
3579
- this.lastBatchDurationMs = performance.now() - started;
3580
- await this.enforceRetentionIfDue();
3581
- return events;
3492
+ if (options.taskId) {
3493
+ clauses.push("task_id = ?");
3494
+ params.push(options.taskId);
3495
+ }
3496
+ if (options.boardId) {
3497
+ clauses.push("board_id = ?");
3498
+ params.push(options.boardId);
3499
+ }
3500
+ if (options.sessionId) {
3501
+ clauses.push("session_id = ?");
3502
+ params.push(options.sessionId);
3503
+ }
3504
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
3505
+ params.push(clampLimit(options.limit, 200));
3506
+ const projection = `path, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
3507
+ tool_name, provider_id, model_id, source`;
3508
+ const sql = options.latestPerPath ? `SELECT ${projection} FROM (
3509
+ SELECT ${projection}, ROW_NUMBER() OVER (
3510
+ PARTITION BY path_key ORDER BY occurred_at DESC, event_id DESC
3511
+ ) AS path_rank
3512
+ FROM file_lineage${where}
3513
+ ) WHERE path_rank = 1 ORDER BY occurred_at DESC LIMIT ?` : `SELECT ${projection}
3514
+ FROM file_lineage${where} ORDER BY occurred_at DESC LIMIT ?`;
3515
+ const rows = this.db.prepare(sql).all(...params);
3516
+ return rows.map((row) => ({
3517
+ path: row.path,
3518
+ operation: row.operation,
3519
+ occurredAt: row.occurred_at,
3520
+ sessionId: row.session_id,
3521
+ agentId: row.agent_id,
3522
+ taskId: row.task_id,
3523
+ boardId: row.board_id,
3524
+ runId: row.run_id,
3525
+ toolName: row.tool_name,
3526
+ providerId: row.provider_id,
3527
+ modelId: row.model_id,
3528
+ source: row.source
3529
+ }));
3582
3530
  }
3583
- async readAll() {
3584
- const rows = this.db.prepare("SELECT payload FROM events ORDER BY day, sequence").all();
3585
- return rows.map((row) => JSON.parse(row.payload));
3531
+ summary() {
3532
+ const provider = this.db.prepare(
3533
+ "SELECT COALESCE(SUM(attempts),0) a, COALESCE(SUM(completed),0) c, COALESCE(SUM(failed),0) f FROM provider_daily"
3534
+ ).get();
3535
+ const tasks = {};
3536
+ for (const row of this.db.prepare("SELECT status, COUNT(*) n FROM task_outcomes GROUP BY status").all()) {
3537
+ tasks[row.status] = Number(row.n);
3538
+ }
3539
+ const files = this.db.prepare("SELECT COUNT(*) n, COUNT(DISTINCT path) p FROM file_lineage").get();
3540
+ const cost = this.db.prepare("SELECT COALESCE(SUM(cost),0) c FROM token_cost").get();
3541
+ const terminal = Number(provider.c) + Number(provider.f);
3542
+ return {
3543
+ providers: {
3544
+ attempts: Number(provider.a),
3545
+ completed: Number(provider.c),
3546
+ failed: Number(provider.f),
3547
+ successRate: terminal > 0 ? Number(provider.c) / terminal : 0
3548
+ },
3549
+ tasks,
3550
+ files: { mutations: Number(files.n), uniquePaths: Number(files.p) },
3551
+ estimatedCostUsd: Number(cost.c)
3552
+ };
3586
3553
  }
3587
3554
  /**
3588
- * Walk every chain and prove none has been edited.
3589
- *
3590
- * Chronicle chains are scoped to a day, not to the journal: the JSONL writer
3591
- * anchors each `<day>.events.jsonl` family at `GENESIS_HASH` independently,
3592
- * so `sequence` restarts at 1 every day. Verification mirrors that — each day
3593
- * is validated on its own, and a break in one does not implicate the others.
3594
- *
3595
- * Three independent properties per day, because each catches a different
3596
- * failure: a dense `sequence` catches deletions from the middle, the
3597
- * `previousHash` link catches reordering, and re-hashing the payload catches
3598
- * an in-place edit that left the links intact.
3555
+ * A `ChronicleSummary` for the default/unfiltered dashboard view only
3556
+ * `from`/`to` (day-precision) narrow it. Any other ad hoc filter (text,
3557
+ * path, provider, model, session) can't be answered from these
3558
+ * fixed-dimension aggregates; callers must fall back to query.ts's
3559
+ * raw-scan summary for those.
3599
3560
  */
3600
- async verify() {
3601
- let entries = 0;
3602
- let lastSequence = 0;
3603
- let lastHash = GENESIS_HASH;
3604
- for (const day of this.days()) {
3605
- const checkpoint = this.readCheckpoint(day);
3606
- let expectedSequence = (checkpoint?.sequence ?? 0) + 1;
3607
- let previousHash = checkpoint?.hash ?? GENESIS_HASH;
3608
- const rows = this.db.prepare(
3609
- "SELECT sequence, hash, previous_hash, payload FROM events WHERE day = ? ORDER BY sequence"
3610
- ).all(day);
3611
- for (const row of rows) {
3612
- if (row.sequence !== expectedSequence) {
3613
- return {
3614
- ok: false,
3615
- entries,
3616
- brokenAt: entries,
3617
- reason: `sequence gap in ${day}: expected ${expectedSequence}, found ${row.sequence}`
3618
- };
3619
- }
3620
- if (row.previous_hash !== previousHash) {
3621
- return { ok: false, entries, brokenAt: entries, reason: "previous hash mismatch" };
3622
- }
3623
- let event;
3624
- try {
3625
- event = JSON.parse(row.payload);
3626
- } catch {
3627
- return { ok: false, entries, brokenAt: entries, reason: "invalid payload JSON" };
3628
- }
3629
- if (chronicleEventHash(event) !== row.hash) {
3630
- return { ok: false, entries, brokenAt: entries, reason: "entry hash mismatch" };
3631
- }
3632
- entries += 1;
3633
- expectedSequence = row.sequence + 1;
3634
- previousHash = row.hash;
3561
+ defaultSummary(options = {}) {
3562
+ const fromDay = options.from?.slice(0, 10);
3563
+ const toDay = options.to?.slice(0, 10);
3564
+ const dayFilter = (column) => {
3565
+ const clauses = [];
3566
+ const params = [];
3567
+ if (fromDay) {
3568
+ clauses.push(`${column} >= ?`);
3569
+ params.push(fromDay);
3635
3570
  }
3636
- lastSequence = expectedSequence - 1;
3637
- lastHash = previousHash;
3638
- }
3639
- return { ok: true, entries, lastSequence, lastHash };
3640
- }
3641
- async enforceRetentionIfDue() {
3642
- if (this.retentionDays === void 0) return;
3643
- const now = this.now();
3644
- if (now.getTime() < this.nextRetentionCheckAt) return;
3645
- this.nextRetentionCheckAt = now.getTime() + this.retentionCheckIntervalMs;
3646
- try {
3647
- await this.purge({ retentionDays: this.retentionDays });
3648
- } catch {
3571
+ if (toDay) {
3572
+ clauses.push(`${column} <= ?`);
3573
+ params.push(toDay);
3574
+ }
3575
+ return { where: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "", params };
3576
+ };
3577
+ const providerRange = dayFilter("day");
3578
+ const provider = this.db.prepare(
3579
+ `SELECT COALESCE(SUM(attempts),0) attempts, COALESCE(SUM(completed),0) completed, COALESCE(SUM(failed),0) failed,
3580
+ COALESCE(SUM(retries),0) retries, COALESCE(SUM(fallbacks),0) fallbacks,
3581
+ COUNT(DISTINCT provider_id) providers, COUNT(DISTINCT model_id) models,
3582
+ COALESCE(SUM(input_tokens),0) inputTokens, COALESCE(SUM(output_tokens),0) outputTokens,
3583
+ COALESCE(SUM(cache_read_tokens),0) cacheReadTokens, COALESCE(SUM(cache_write_tokens),0) cacheWriteTokens,
3584
+ COALESCE(SUM(duration_ms_total),0) durationTotal, COALESCE(MAX(duration_ms_max),0) durationMax,
3585
+ COALESCE(SUM(duration_count),0) durationCount
3586
+ FROM provider_daily${providerRange.where}`
3587
+ ).get(...providerRange.params);
3588
+ const counterRange = dayFilter("day");
3589
+ const counters = this.db.prepare(
3590
+ `SELECT COALESCE(SUM(tool_calls),0) toolCalls, COALESCE(SUM(completed_tools),0) completedTools,
3591
+ COALESCE(SUM(failed_tools),0) failedTools, COALESCE(SUM(tool_duration_ms_total),0) toolDurationTotal,
3592
+ COALESCE(SUM(tool_duration_count),0) toolDurationCount, COALESCE(SUM(processes),0) processes,
3593
+ COALESCE(SUM(failed_processes),0) failedProcesses, COALESCE(SUM(file_events_all),0) fileEvents,
3594
+ COALESCE(SUM(decisions),0) decisions, COALESCE(SUM(escalations),0) escalations,
3595
+ COALESCE(SUM(agent_events),0) agentEvents, COALESCE(SUM(failures),0) failures,
3596
+ COALESCE(SUM(cancellations),0) cancellations
3597
+ FROM daily_counters${counterRange.where}`
3598
+ ).get(...counterRange.params);
3599
+ const familyRange = dayFilter("day");
3600
+ const familyRows = this.db.prepare(`SELECT family, count, failure_count FROM family_daily${familyRange.where}`).all(...familyRange.params);
3601
+ const families = { ...EMPTY_FAMILIES };
3602
+ const failuresByFamily = { ...EMPTY_FAMILIES };
3603
+ for (const row of familyRows) {
3604
+ const family = row.family;
3605
+ families[family] = Number(row.count);
3606
+ failuresByFamily[family] = Number(row.failure_count);
3649
3607
  }
3608
+ const agentRange = dayFilter("day");
3609
+ const uniqueAgents = this.db.prepare(`SELECT COUNT(DISTINCT agent_id) n FROM agent_daily${agentRange.where}`).get(...agentRange.params).n;
3610
+ const requestRange = dayFilter("day");
3611
+ const logicalRequests = this.db.prepare(
3612
+ `SELECT COUNT(DISTINCT logical_request_id) n FROM logical_request_daily${requestRange.where}`
3613
+ ).get(...requestRange.params).n;
3614
+ const fileRange = dayFilter("day");
3615
+ const uniqueFiles = this.db.prepare(`SELECT COUNT(DISTINCT path_key) n FROM file_seen_daily${fileRange.where}`).get(...fileRange.params).n;
3616
+ const costRange = dayFilter("day");
3617
+ const cost = this.db.prepare(`SELECT COALESCE(SUM(cost),0) c FROM token_cost${costRange.where}`).get(...costRange.params).c;
3618
+ return {
3619
+ logicalRequests: Number(logicalRequests),
3620
+ modelAttempts: Number(provider.attempts),
3621
+ completedAttempts: Number(provider.completed),
3622
+ failedAttempts: Number(provider.failed),
3623
+ scheduledRetries: Number(provider.retries),
3624
+ fallbacks: Number(provider.fallbacks),
3625
+ providers: Number(provider.providers),
3626
+ models: Number(provider.models),
3627
+ inputTokens: Number(provider.inputTokens),
3628
+ outputTokens: Number(provider.outputTokens),
3629
+ cacheReadTokens: Number(provider.cacheReadTokens),
3630
+ cacheWriteTokens: Number(provider.cacheWriteTokens),
3631
+ estimatedCostUsd: Number(cost),
3632
+ providerAvgDurationMs: Number(provider.durationCount) > 0 ? Number(provider.durationTotal) / Number(provider.durationCount) : 0,
3633
+ // True p95 needs a retained distribution; this per-day aggregate only
3634
+ // keeps sum/max/count, so approximate with the observed max rather
3635
+ // than adding a per-attempt histogram write (would add the same kind
3636
+ // of per-event overhead this whole effort is trying to remove).
3637
+ providerP95DurationMs: Number(provider.durationMax),
3638
+ toolCalls: Number(counters.toolCalls),
3639
+ completedTools: Number(counters.completedTools),
3640
+ failedTools: Number(counters.failedTools),
3641
+ toolAvgDurationMs: Number(counters.toolDurationCount) > 0 ? Number(counters.toolDurationTotal) / Number(counters.toolDurationCount) : 0,
3642
+ processes: Number(counters.processes),
3643
+ failedProcesses: Number(counters.failedProcesses),
3644
+ fileEvents: Number(counters.fileEvents),
3645
+ uniqueFiles: Number(uniqueFiles),
3646
+ agentEvents: Number(counters.agentEvents),
3647
+ uniqueAgents: Number(uniqueAgents),
3648
+ decisions: Number(counters.decisions),
3649
+ escalations: Number(counters.escalations),
3650
+ failures: Number(counters.failures),
3651
+ cancellations: Number(counters.cancellations),
3652
+ families,
3653
+ failuresByFamily
3654
+ };
3650
3655
  }
3651
- configureByteQuota() {
3652
- if (this.maxBytes === void 0) {
3653
- this.db.exec("PRAGMA max_page_count = 2147483646");
3654
- return;
3656
+ // ─── Ingest internals ─────────────────────────────────────────────────────
3657
+ ensureSchema() {
3658
+ const version = this.db.prepare("PRAGMA user_version").get().user_version;
3659
+ if (version !== 0 && version !== SCHEMA_VERSION2) {
3660
+ this.db.exec(
3661
+ "DROP TABLE IF EXISTS ingest_state; DROP TABLE IF EXISTS provider_daily;DROP TABLE IF EXISTS task_outcomes; DROP TABLE IF EXISTS file_lineage;DROP TABLE IF EXISTS token_cost; DROP TABLE IF EXISTS daily_counters;DROP TABLE IF EXISTS family_daily; DROP TABLE IF EXISTS agent_daily;DROP TABLE IF EXISTS logical_request_daily; DROP TABLE IF EXISTS file_seen_daily;"
3662
+ );
3655
3663
  }
3656
- const halfSplit = Math.floor((this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES) / 2);
3657
- const journalReserve = Math.min(MAX_ROLLBACK_JOURNAL_RESERVE_BYTES, halfSplit);
3658
- const mainBudget = this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES - journalReserve;
3659
- if (mainBudget < MIN_SQLITE_PAGE_BUDGET_BYTES) {
3660
- throw new Error(
3661
- `maxBytes must be at least ${SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES}`
3664
+ this.db.exec(`
3665
+ CREATE TABLE IF NOT EXISTS ingest_state (
3666
+ file TEXT PRIMARY KEY,
3667
+ bytes INTEGER NOT NULL
3668
+ );
3669
+ CREATE TABLE IF NOT EXISTS provider_daily (
3670
+ day TEXT NOT NULL,
3671
+ provider_id TEXT NOT NULL,
3672
+ model_id TEXT NOT NULL,
3673
+ attempts INTEGER NOT NULL DEFAULT 0,
3674
+ completed INTEGER NOT NULL DEFAULT 0,
3675
+ failed INTEGER NOT NULL DEFAULT 0,
3676
+ retries INTEGER NOT NULL DEFAULT 0,
3677
+ fallbacks INTEGER NOT NULL DEFAULT 0,
3678
+ input_tokens INTEGER NOT NULL DEFAULT 0,
3679
+ output_tokens INTEGER NOT NULL DEFAULT 0,
3680
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3681
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
3682
+ duration_ms_total REAL NOT NULL DEFAULT 0,
3683
+ duration_ms_max REAL NOT NULL DEFAULT 0,
3684
+ duration_count INTEGER NOT NULL DEFAULT 0,
3685
+ PRIMARY KEY (day, provider_id, model_id)
3686
+ );
3687
+ CREATE TABLE IF NOT EXISTS task_outcomes (
3688
+ task_id TEXT PRIMARY KEY,
3689
+ run_id TEXT NOT NULL DEFAULT '',
3690
+ board_id TEXT NOT NULL DEFAULT '',
3691
+ session_id TEXT NOT NULL DEFAULT '',
3692
+ agent_id TEXT NOT NULL DEFAULT '',
3693
+ status TEXT NOT NULL DEFAULT 'started',
3694
+ started_at TEXT,
3695
+ ended_at TEXT,
3696
+ duration_ms REAL,
3697
+ retries INTEGER NOT NULL DEFAULT 0,
3698
+ verification_failures INTEGER NOT NULL DEFAULT 0
3699
+ );
3700
+ CREATE TABLE IF NOT EXISTS file_lineage (
3701
+ event_id TEXT PRIMARY KEY,
3702
+ path TEXT NOT NULL,
3703
+ path_key TEXT NOT NULL,
3704
+ operation TEXT NOT NULL,
3705
+ occurred_at TEXT NOT NULL,
3706
+ session_id TEXT NOT NULL DEFAULT '',
3707
+ agent_id TEXT NOT NULL DEFAULT '',
3708
+ task_id TEXT NOT NULL DEFAULT '',
3709
+ board_id TEXT NOT NULL DEFAULT '',
3710
+ run_id TEXT NOT NULL DEFAULT '',
3711
+ tool_name TEXT NOT NULL DEFAULT '',
3712
+ provider_id TEXT NOT NULL DEFAULT '',
3713
+ model_id TEXT NOT NULL DEFAULT '',
3714
+ source TEXT NOT NULL DEFAULT ''
3715
+ );
3716
+ -- Lookups filter on the case-normalized path_key (matching the query
3717
+ -- engine); the path column retains original casing for display.
3718
+ CREATE INDEX IF NOT EXISTS idx_file_lineage_path ON file_lineage(path_key, occurred_at);
3719
+ CREATE INDEX IF NOT EXISTS idx_file_lineage_task ON file_lineage(task_id);
3720
+ CREATE TABLE IF NOT EXISTS token_cost (
3721
+ scope_key TEXT PRIMARY KEY,
3722
+ day TEXT NOT NULL,
3723
+ occurred_at TEXT NOT NULL,
3724
+ sequence INTEGER NOT NULL,
3725
+ cost REAL NOT NULL
3726
+ );
3727
+ -- Backing store for defaultSummary(): per-day scalar counters plus
3728
+ -- dedup sets, populated for every ingested event (not just the
3729
+ -- provider/task/file families above).
3730
+ CREATE TABLE IF NOT EXISTS daily_counters (
3731
+ day TEXT PRIMARY KEY,
3732
+ tool_calls INTEGER NOT NULL DEFAULT 0,
3733
+ completed_tools INTEGER NOT NULL DEFAULT 0,
3734
+ failed_tools INTEGER NOT NULL DEFAULT 0,
3735
+ tool_duration_ms_total REAL NOT NULL DEFAULT 0,
3736
+ tool_duration_ms_max REAL NOT NULL DEFAULT 0,
3737
+ tool_duration_count INTEGER NOT NULL DEFAULT 0,
3738
+ processes INTEGER NOT NULL DEFAULT 0,
3739
+ failed_processes INTEGER NOT NULL DEFAULT 0,
3740
+ file_events_all INTEGER NOT NULL DEFAULT 0,
3741
+ decisions INTEGER NOT NULL DEFAULT 0,
3742
+ escalations INTEGER NOT NULL DEFAULT 0,
3743
+ agent_events INTEGER NOT NULL DEFAULT 0,
3744
+ failures INTEGER NOT NULL DEFAULT 0,
3745
+ cancellations INTEGER NOT NULL DEFAULT 0
3662
3746
  );
3663
- }
3664
- const maxPages = Math.max(1, Math.floor(mainBudget / this.pageSizeBytes()));
3665
- this.db.exec(`PRAGMA max_page_count = ${maxPages}`);
3666
- }
3667
- /**
3668
- * Bytes the journal actually occupies: live pages in the main database plus
3669
- * whatever the rollback/WAL sidecars currently hold.
3670
- *
3671
- * The quota MUST be measured this way rather than by `statSync` on the main
3672
- * database file. SQLite never returns freed pages to the filesystem — it
3673
- * parks them on the freelist and reuses them — so a database that once grew
3674
- * large keeps that size forever, even after retention and `maxEvents` have
3675
- * evicted almost everything. Measuring the file instead wedged the journal
3676
- * permanently: a 3.9 GB file whose live data was 243 MB failed a 512 MB
3677
- * quota on EVERY append, and the only escape (`chronicle compact`) refuses
3678
- * to run while the daemon is up — and the daemon respawns on demand. Live
3679
- * pages shrink when retention deletes rows, so the quota can recover on its
3680
- * own; allocated size cannot.
3681
- *
3682
- * The sidecars keep their on-disk size: they are transient and genuinely
3683
- * bounded by `configureByteQuota`'s half-of-budget split.
3684
- */
3685
- aggregateLiveBytes() {
3686
- let total = 0;
3687
- for (const file of [`${this.dbPath}-journal`, `${this.dbPath}-wal`, `${this.dbPath}-shm`]) {
3688
- try {
3689
- total += fs8.statSync(file).size;
3690
- } catch (error) {
3691
- if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
3692
- }
3693
- }
3694
- return total + this.mainDatabaseLiveBytes();
3695
- }
3696
- /** Live (non-freelist) pages of the main database, in bytes. */
3697
- mainDatabaseLiveBytes() {
3698
- const pageCount = Number(
3699
- this.db.prepare("PRAGMA page_count").get().page_count
3700
- );
3701
- const freelist = Number(
3702
- this.db.prepare("PRAGMA freelist_count").get().freelist_count
3703
- );
3704
- return Math.max(0, pageCount - freelist) * this.pageSizeBytes();
3705
- }
3706
- /** Page size never changes for an open database; resolve it once. */
3707
- pageSizeBytes() {
3708
- if (this.cachedPageSize === void 0) {
3709
- this.cachedPageSize = Number(
3710
- this.db.prepare("PRAGMA page_size").get().page_size
3747
+ CREATE TABLE IF NOT EXISTS family_daily (
3748
+ day TEXT NOT NULL,
3749
+ family TEXT NOT NULL,
3750
+ count INTEGER NOT NULL DEFAULT 0,
3751
+ failure_count INTEGER NOT NULL DEFAULT 0,
3752
+ PRIMARY KEY (day, family)
3711
3753
  );
3712
- }
3713
- return this.cachedPageSize;
3714
- }
3715
- normalizeQuotaError(error) {
3716
- if (this.maxBytes === void 0 || !(error instanceof Error)) return error;
3717
- const sqliteError = error;
3718
- const quotaExhausted = sqliteError.code === "SQLITE_FULL" || sqliteError.code === 13 || sqliteError.errcode === 13 || /database or disk is full/i.test(sqliteError.message);
3719
- if (!quotaExhausted) return error;
3720
- return new ChronicleStorageQuotaError({
3721
- currentBytes: this.aggregateLiveBytes(),
3722
- batchBytes: 0,
3723
- maxBytes: this.maxBytes,
3724
- path: this.dbPath
3725
- });
3726
- }
3727
- assertActualAllocationWithinQuota() {
3728
- if (this.maxBytes === void 0) return;
3729
- const currentBytes = this.aggregateLiveBytes();
3730
- if (currentBytes <= this.maxBytes) return;
3731
- throw new ChronicleStorageQuotaError({
3732
- currentBytes,
3733
- batchBytes: 0,
3734
- maxBytes: this.maxBytes,
3735
- path: this.dbPath
3736
- });
3737
- }
3738
- assertWithinByteQuota(events) {
3739
- if (this.maxBytes === void 0) return;
3740
- const currentBytes = this.aggregateLiveBytes();
3741
- const batchBytes = Buffer.byteLength(JSON.stringify(events), "utf8");
3742
- if (currentBytes + batchBytes <= this.maxBytes) return;
3743
- throw new ChronicleStorageQuotaError({
3744
- currentBytes,
3745
- batchBytes,
3746
- maxBytes: this.maxBytes,
3747
- path: this.dbPath
3748
- });
3754
+ CREATE TABLE IF NOT EXISTS agent_daily (day TEXT NOT NULL, agent_id TEXT NOT NULL, PRIMARY KEY (day, agent_id));
3755
+ CREATE TABLE IF NOT EXISTS logical_request_daily (day TEXT NOT NULL, logical_request_id TEXT NOT NULL, PRIMARY KEY (day, logical_request_id));
3756
+ CREATE TABLE IF NOT EXISTS file_seen_daily (day TEXT NOT NULL, path_key TEXT NOT NULL, PRIMARY KEY (day, path_key));
3757
+ PRAGMA user_version = ${SCHEMA_VERSION2};
3758
+ `);
3749
3759
  }
3750
- enforceEventLimitWithinTransaction() {
3751
- const row = this.db.prepare("SELECT COUNT(*) AS count FROM events").get();
3752
- if (this.maxEvents === void 0 || row.count <= this.maxEvents) return row.count;
3753
- const excess = row.count - this.maxEvents;
3754
- const boundary = this.db.prepare("SELECT day, sequence, hash FROM events ORDER BY day, sequence LIMIT 1 OFFSET ?").get(excess - 1);
3755
- if (!boundary) return row.count;
3756
- this.db.prepare(
3757
- `INSERT INTO chain_checkpoint (day, sequence, hash) VALUES (?, ?, ?)
3758
- ON CONFLICT(day) DO UPDATE SET sequence = excluded.sequence, hash = excluded.hash`
3759
- ).run(boundary.day, boundary.sequence, boundary.hash);
3760
- this.db.prepare("DELETE FROM events WHERE day < ? OR (day = ? AND sequence <= ?)").run(boundary.day, boundary.day, boundary.sequence);
3761
- this.db.prepare("DELETE FROM chain_checkpoint WHERE day < ?").run(boundary.day);
3762
- this.anchors.clear();
3763
- return this.maxEvents;
3760
+ loadOffsets() {
3761
+ const rows = this.db.prepare("SELECT file, bytes FROM ingest_state").all();
3762
+ return new Map(rows.map((row) => [row.file, Number(row.bytes)]));
3764
3763
  }
3765
- enforceEventLimitAtStartup() {
3766
- if (this.maxEvents === void 0 || this.retainedEventCount <= this.maxEvents) return;
3767
- try {
3768
- this.db.exec("BEGIN IMMEDIATE");
3769
- const retainedCount = this.enforceEventLimitWithinTransaction();
3770
- this.db.exec("COMMIT");
3771
- this.retainedEventCount = retainedCount;
3772
- } catch (error) {
3773
- try {
3774
- this.db.exec("ROLLBACK");
3775
- } catch {
3776
- }
3777
- throw error;
3764
+ pruneOffsets(existingFiles) {
3765
+ const keep = new Set(
3766
+ existingFiles.map((file) => normalizeKey(path10.relative(this.directory, file)))
3767
+ );
3768
+ for (const row of this.db.prepare("SELECT file FROM ingest_state").all()) {
3769
+ if (row.file.startsWith(SQLITE_SOURCE_PREFIX)) continue;
3770
+ if (!keep.has(row.file))
3771
+ this.db.prepare("DELETE FROM ingest_state WHERE file = ?").run(row.file);
3778
3772
  }
3779
3773
  }
3780
3774
  /**
3781
- * Drop events older than the retention window.
3775
+ * Fold everything the SQLite journal holds past this store's per-day cursor.
3782
3776
  *
3783
- * Retention is day-granular and chains are day-scoped, so a purge removes
3784
- * whole chains rather than truncating one. That is why nothing needs to be
3785
- * checkpointed here: there is no surviving suffix left dangling without an
3786
- * anchor. Any checkpoint imported from a partially-purged legacy day family
3787
- * is dropped alongside its events.
3777
+ * Opened read-only on its own connection: the journal runs in WAL, so this
3778
+ * never blocks the daemon writing to it, and metrics are best-effort a
3779
+ * journal that cannot be opened (mid-migration, absent, locked) leaves the
3780
+ * cursors untouched and the next refresh retries.
3781
+ *
3782
+ * Rows the journal has already evicted are simply not seen. That is the
3783
+ * intended split of responsibilities: the journal is a bounded ring, and this
3784
+ * store is where an aggregate outlives the raw event it came from — which
3785
+ * only holds if refresh runs more often than the ring turns over.
3788
3786
  */
3789
- async purge(options) {
3790
- const empty = {
3791
- deletedCount: 0,
3792
- deletedBytes: 0,
3793
- skippedCount: 0,
3794
- errors: []
3795
- };
3796
- if (!Number.isFinite(options.retentionDays) || options.retentionDays <= 0) return empty;
3797
- const cutoff = new Date(this.now().getTime() - options.retentionDays * 864e5).toISOString().slice(0, 10);
3798
- const count = this.db.prepare("SELECT COUNT(*) AS n FROM events WHERE day < ?").get(cutoff).n;
3799
- if (count === 0) return empty;
3800
- if (options.dryRun) {
3801
- const days = this.db.prepare("SELECT DISTINCT day FROM events WHERE day < ? ORDER BY day").all(cutoff);
3802
- return { ...empty, deletedCount: count, candidates: days.map((row) => row.day) };
3787
+ ingestSqliteJournal(offsets, result) {
3788
+ const journalPath = path10.join(this.directory, CHRONICLE_SQLITE_FILE);
3789
+ let source;
3790
+ try {
3791
+ source = new (loadDatabaseSync2())(journalPath, { readOnly: true });
3792
+ } catch {
3793
+ return;
3803
3794
  }
3804
3795
  try {
3805
- this.db.exec("BEGIN IMMEDIATE");
3806
- this.db.prepare("DELETE FROM events WHERE day < ?").run(cutoff);
3807
- this.db.prepare("DELETE FROM chain_checkpoint WHERE day < ?").run(cutoff);
3808
- this.db.exec("COMMIT");
3809
- } catch (error) {
3810
- try {
3811
- this.db.exec("ROLLBACK");
3812
- } catch {
3813
- }
3814
- return {
3815
- ...empty,
3816
- errors: [
3817
- {
3818
- file: this.dbPath,
3819
- reason: error instanceof Error ? error.message : String(error)
3796
+ const days = source.prepare("SELECT DISTINCT day FROM events ORDER BY day").all();
3797
+ const read = source.prepare(
3798
+ "SELECT sequence, payload FROM events WHERE day = ? AND sequence > ? ORDER BY sequence LIMIT ?"
3799
+ );
3800
+ const writeCursor = this.db.prepare(
3801
+ "INSERT INTO ingest_state (file, bytes) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET bytes = excluded.bytes"
3802
+ );
3803
+ for (const { day } of days) {
3804
+ const key = `${SQLITE_SOURCE_PREFIX}${day}`;
3805
+ const from = offsets.get(key) ?? 0;
3806
+ let cursor = from;
3807
+ this.db.exec("BEGIN");
3808
+ try {
3809
+ for (; ; ) {
3810
+ const rows = read.all(day, cursor, SQLITE_INGEST_BATCH);
3811
+ if (rows.length === 0) break;
3812
+ for (const row of rows) {
3813
+ try {
3814
+ this.ingestEvent(JSON.parse(row.payload));
3815
+ result.ingestedEvents++;
3816
+ } catch {
3817
+ result.invalidLines++;
3818
+ }
3819
+ result.ingestedBytes += row.payload.length;
3820
+ cursor = Number(row.sequence);
3821
+ }
3822
+ if (rows.length < SQLITE_INGEST_BATCH) break;
3820
3823
  }
3821
- ]
3822
- };
3824
+ if (cursor > from) writeCursor.run(key, cursor);
3825
+ this.db.exec("COMMIT");
3826
+ } catch (error) {
3827
+ this.db.exec("ROLLBACK");
3828
+ throw error;
3829
+ }
3830
+ if (cursor > from) result.sourceFiles++;
3831
+ }
3832
+ } catch {
3833
+ } finally {
3834
+ source.close();
3823
3835
  }
3824
- this.anchors.clear();
3825
- this.retainedEventCount = Math.max(0, this.retainedEventCount - count);
3826
- return { ...empty, deletedCount: count };
3827
3836
  }
3828
- /**
3829
- * Run one day family's legacy import inside its own transaction.
3830
- *
3831
- * Deliberately separate from `appendBatch`: the append path *computes*
3832
- * `sequence`, `previousHash` and `hash`, while an import must carry them over
3833
- * untouched. Fusing the two would put a code path one refactor away from
3834
- * re-hashing historical events, which is the one change that silently
3835
- * destroys their tamper evidence.
3836
- *
3837
- * The transaction is scoped to a single family because chains are: `sequence`
3838
- * restarts at 1 each day, so one day's break says nothing about the next
3839
- * day's integrity. A whole-journal transaction made every future day hostage
3840
- * to the worst day on disk — one corrupt family and the daemon could never
3841
- * open its store again. The family is still all-or-nothing: a break rolls
3842
- * back that day entirely, so no partial chain is ever visible.
3843
- */
3844
- async runFamilyImport(load) {
3845
- const insert = this.db.prepare(
3846
- `INSERT INTO events (
3847
- day, sequence, event_id, hash, previous_hash, occurred_at, event_type, outcome,
3848
- project_id, session_id, agent_id, task_id, trace_id, logical_request_id,
3849
- resource_kind, resource_id, resource_path, duration_ns, payload
3850
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
3851
- );
3852
- const checkpoint = this.db.prepare(
3853
- `INSERT INTO chain_checkpoint (day, sequence, hash) VALUES (?, ?, ?)
3854
- ON CONFLICT(day) DO UPDATE SET sequence = excluded.sequence, hash = excluded.hash`
3855
- );
3856
- this.db.exec("BEGIN IMMEDIATE");
3857
- let retainedCountAfterCommit = this.retainedEventCount;
3837
+ /** Read complete lines appended after `consumed` bytes. The trailing
3838
+ * partial line of an actively-written partition is left for the next
3839
+ * refresh — `ingest_state.bytes` only ever advances past full lines. */
3840
+ async ingestFile(file, key, consumed, result) {
3841
+ let handle;
3858
3842
  try {
3859
- this.assertWithinByteQuota([]);
3860
- await load({
3861
- insert: (day, event) => {
3862
- const row = projectEvent(event);
3863
- insert.run(
3864
- day,
3865
- event.sequence,
3866
- event.eventId,
3867
- event.hash,
3868
- event.previousHash,
3869
- row.occurredAt,
3870
- event.eventType,
3871
- row.outcome,
3872
- row.projectId,
3873
- row.sessionId,
3874
- row.agentId,
3875
- row.taskId,
3876
- row.traceId,
3877
- row.logicalRequestId,
3878
- row.resourceKind,
3879
- row.resourceId,
3880
- row.resourcePath,
3881
- row.durationNs,
3882
- JSON.stringify(event)
3883
- );
3884
- },
3885
- checkpoint: (day, sequence, hash3) => {
3886
- checkpoint.run(day, sequence, hash3);
3843
+ handle = await fs7.open(file, "r");
3844
+ } catch {
3845
+ return false;
3846
+ }
3847
+ try {
3848
+ const size = (await handle.stat()).size;
3849
+ if (size <= consumed) return false;
3850
+ let position = consumed;
3851
+ let remainder = Buffer.alloc(0);
3852
+ let advanced = consumed;
3853
+ this.db.exec("BEGIN");
3854
+ try {
3855
+ while (position < size) {
3856
+ const length = Math.min(READ_CHUNK_BYTES, size - position);
3857
+ const buffer = Buffer.allocUnsafe(length);
3858
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
3859
+ if (bytesRead <= 0) break;
3860
+ position += bytesRead;
3861
+ const data = remainder.length > 0 ? Buffer.concat([remainder, buffer.subarray(0, bytesRead)]) : buffer.subarray(0, bytesRead);
3862
+ const lastNewline = data.lastIndexOf(10);
3863
+ if (lastNewline < 0) {
3864
+ remainder = Buffer.from(data);
3865
+ continue;
3866
+ }
3867
+ for (const line of data.subarray(0, lastNewline).toString("utf8").split("\n")) {
3868
+ const trimmed = line.trim();
3869
+ if (!trimmed) continue;
3870
+ try {
3871
+ this.ingestEvent(JSON.parse(trimmed));
3872
+ result.ingestedEvents++;
3873
+ } catch {
3874
+ result.invalidLines++;
3875
+ }
3876
+ }
3877
+ advanced += lastNewline + 1;
3878
+ remainder = Buffer.from(data.subarray(lastNewline + 1));
3887
3879
  }
3888
- });
3889
- retainedCountAfterCommit = this.enforceEventLimitWithinTransaction();
3890
- this.assertActualAllocationWithinQuota();
3891
- this.db.exec("COMMIT");
3892
- } catch (error) {
3893
- try {
3880
+ this.db.prepare(
3881
+ "INSERT INTO ingest_state (file, bytes) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET bytes = excluded.bytes"
3882
+ ).run(key, advanced);
3883
+ this.db.exec("COMMIT");
3884
+ } catch (error) {
3894
3885
  this.db.exec("ROLLBACK");
3895
- } catch {
3886
+ throw error;
3896
3887
  }
3897
- throw this.normalizeQuotaError(error);
3888
+ result.ingestedBytes += advanced - consumed;
3889
+ return advanced > consumed;
3898
3890
  } finally {
3899
- this.anchors.clear();
3891
+ await handle.close();
3900
3892
  }
3901
- this.retainedEventCount = retainedCountAfterCommit;
3902
3893
  }
3903
- /**
3904
- * A read engine over this journal's own connection.
3905
- *
3906
- * Sharing the connection rather than opening a second one keeps the
3907
- * single-writer guarantee intact and means a reader can never observe a
3908
- * half-applied batch: SQLite serialises statements on one handle.
3909
- */
3910
- queryEngine(options) {
3911
- return new ChronicleSqliteQueryEngine(this.db, options);
3912
- }
3913
- /** Has the legacy JSONL import already run? */
3914
- hasImportedLegacyJournal() {
3915
- return this.readMeta(LEGACY_JSONL_MIGRATION_KEY) !== void 0;
3894
+ ingestEvent(event) {
3895
+ if (typeof event?.eventType !== "string" || !event.scope) return;
3896
+ this.ingestDailyCounters(event);
3897
+ const type = event.eventType;
3898
+ if (type.startsWith("provider.attempt.") || type === "provider.fallback") {
3899
+ this.ingestProvider(event);
3900
+ } else if (type === "token.accounted") {
3901
+ this.ingestTokenCost(event);
3902
+ } else if (/^(?:sdd|subagent|kanban)\.task[._]/.test(type)) {
3903
+ this.ingestTask(event);
3904
+ } else if (type === "file.event" || /^file\.(?:tool|external)\./.test(type)) {
3905
+ this.ingestFileEvent(event);
3906
+ }
3916
3907
  }
3917
- markLegacyJournalImported() {
3908
+ /** Runs for every ingested event (not just the type-specific branches
3909
+ * below) — mirrors query.ts's updateSummary() closely enough that
3910
+ * defaultSummary() matches what a raw scan of the same window would say. */
3911
+ ingestDailyCounters(event) {
3912
+ const day = eventDay(event);
3913
+ this.db.prepare("INSERT OR IGNORE INTO daily_counters (day) VALUES (?)").run(day);
3914
+ const bump = (sql, ...params) => this.db.prepare(`UPDATE daily_counters SET ${sql} WHERE day = ?`).run(...params, day);
3915
+ const family = signalFamily(event);
3916
+ const failed = isTerminalFailure(event) ? 1 : 0;
3918
3917
  this.db.prepare(
3919
- `INSERT INTO chronicle_meta (key, value) VALUES (?, 'done')
3920
- ON CONFLICT(key) DO UPDATE SET value = 'done'`
3921
- ).run(LEGACY_JSONL_MIGRATION_KEY);
3918
+ `INSERT INTO family_daily (day, family, count, failure_count) VALUES (?, ?, 1, ?)
3919
+ ON CONFLICT(day, family) DO UPDATE SET count = count + 1, failure_count = failure_count + excluded.failure_count`
3920
+ ).run(day, family, failed);
3921
+ if (failed) bump("failures = failures + 1");
3922
+ if (event.outcome === "cancelled" || event.outcome === "abandoned")
3923
+ bump("cancellations = cancellations + 1");
3924
+ if (family === "agent") bump("agent_events = agent_events + 1");
3925
+ if (event.correlation.logicalRequestId) {
3926
+ this.db.prepare(
3927
+ "INSERT OR IGNORE INTO logical_request_daily (day, logical_request_id) VALUES (?, ?)"
3928
+ ).run(day, event.correlation.logicalRequestId);
3929
+ }
3930
+ if (event.scope.agentId) {
3931
+ this.db.prepare("INSERT OR IGNORE INTO agent_daily (day, agent_id) VALUES (?, ?)").run(day, event.scope.agentId);
3932
+ }
3933
+ const type = event.eventType;
3934
+ if (type === "decision.requested") bump("decisions = decisions + 1");
3935
+ else if (type === "decision.escalated") bump("escalations = escalations + 1");
3936
+ else if (type === "tool.started") bump("tool_calls = tool_calls + 1");
3937
+ else if (type === "tool.executed" || type === "tool.failed") {
3938
+ const dur = durationMs2(event);
3939
+ const durationCount = dur > 0 ? 1 : 0;
3940
+ bump(
3941
+ `${type === "tool.executed" ? "completed_tools" : "failed_tools"} = ${type === "tool.executed" ? "completed_tools" : "failed_tools"} + 1,
3942
+ tool_duration_ms_total = tool_duration_ms_total + ?, tool_duration_ms_max = MAX(tool_duration_ms_max, ?), tool_duration_count = tool_duration_count + ?`,
3943
+ dur,
3944
+ dur,
3945
+ durationCount
3946
+ );
3947
+ } else if (type === "process.started") bump("processes = processes + 1");
3948
+ else if (type === "process.completed" && event.outcome === "failure")
3949
+ bump("failed_processes = failed_processes + 1");
3950
+ if (event.resource?.kind === "file" || type.startsWith("file.")) {
3951
+ bump("file_events_all = file_events_all + 1");
3952
+ if (event.resource?.path) {
3953
+ this.db.prepare("INSERT OR IGNORE INTO file_seen_daily (day, path_key) VALUES (?, ?)").run(day, normalizePathKey(event.resource.path));
3954
+ }
3955
+ }
3922
3956
  }
3923
- /**
3924
- * Record the day families the import refused to move.
3925
- *
3926
- * Persisted rather than merely logged because the import runs once: after the
3927
- * marker is set nothing re-reads the JSONL, so this row is the only surviving
3928
- * evidence that a day was dropped. Health reports read it back to say
3929
- * "degraded, and here is exactly what is missing" instead of quietly serving
3930
- * a journal with a hole in it.
3931
- */
3932
- recordQuarantinedFamilies(families) {
3933
- if (families.length === 0) return;
3934
- this.db.prepare(
3935
- `INSERT INTO chronicle_meta (key, value) VALUES (?, ?)
3936
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`
3937
- ).run(LEGACY_JSONL_QUARANTINE_KEY, JSON.stringify(families));
3957
+ ingestProvider(event) {
3958
+ const day = eventDay(event);
3959
+ const providerId = event.runtime?.providerId ?? asString(readPath2(event.attributes ?? {}, "from.providerId")) ?? "";
3960
+ const modelId = event.runtime?.modelId ?? asString(readPath2(event.attributes ?? {}, "from.model")) ?? "";
3961
+ if (!providerId && !modelId) return;
3962
+ this.db.prepare("INSERT OR IGNORE INTO provider_daily (day, provider_id, model_id) VALUES (?, ?, ?)").run(day, providerId, modelId);
3963
+ const update = (sql, ...params) => this.db.prepare(
3964
+ `UPDATE provider_daily SET ${sql} WHERE day = ? AND provider_id = ? AND model_id = ?`
3965
+ ).run(...params, day, providerId, modelId);
3966
+ const duration = durationMs2(event);
3967
+ switch (event.eventType) {
3968
+ case "provider.attempt.started":
3969
+ update("attempts = attempts + 1");
3970
+ break;
3971
+ case "provider.attempt.completed":
3972
+ update(
3973
+ "completed = completed + 1, input_tokens = input_tokens + ?, output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?, cache_write_tokens = cache_write_tokens + ?, duration_ms_total = duration_ms_total + ?, duration_ms_max = MAX(duration_ms_max, ?), duration_count = duration_count + ?",
3974
+ numberAt2(event, "usage.input"),
3975
+ numberAt2(event, "usage.output"),
3976
+ numberAt2(event, "usage.cacheRead"),
3977
+ numberAt2(event, "usage.cacheWrite"),
3978
+ duration,
3979
+ duration,
3980
+ duration > 0 ? 1 : 0
3981
+ );
3982
+ break;
3983
+ case "provider.attempt.failed":
3984
+ update(
3985
+ "failed = failed + 1, retries = retries + ?, duration_ms_total = duration_ms_total + ?, duration_ms_max = MAX(duration_ms_max, ?), duration_count = duration_count + ?",
3986
+ event.attributes?.retryScheduled === true ? 1 : 0,
3987
+ duration,
3988
+ duration,
3989
+ duration > 0 ? 1 : 0
3990
+ );
3991
+ break;
3992
+ case "provider.fallback":
3993
+ update("fallbacks = fallbacks + 1");
3994
+ break;
3995
+ default:
3996
+ break;
3997
+ }
3938
3998
  }
3939
- /**
3940
- * Does this day already hold rows?
3941
- *
3942
- * Each family commits on its own, so an import interrupted between families
3943
- * leaves a database that is complete for the days it reached. `(day,
3944
- * sequence)` is the primary key, so re-inserting one of those days would
3945
- * abort on a constraint violation rather than start over this is what lets
3946
- * the next run resume at the first day it never got to.
3947
- */
3948
- hasImportedDay(day) {
3949
- const row = this.db.prepare("SELECT 1 AS present FROM events WHERE day = ? LIMIT 1").get(day);
3950
- return row !== void 0;
3999
+ ingestTokenCost(event) {
4000
+ const cost = readPath2(event.attributes ?? {}, "cost.total");
4001
+ if (typeof cost !== "number" || !Number.isFinite(cost)) return;
4002
+ const scopeKey2 = `${event.scope.projectId ?? ""}\0${event.scope.sessionId ?? ""}\0${event.scope.agentId ?? ""}`;
4003
+ const occurredAt = event.occurredAt ?? event.observedAt;
4004
+ this.db.prepare(
4005
+ `INSERT INTO token_cost (scope_key, day, occurred_at, sequence, cost) VALUES (?, ?, ?, ?, ?)
4006
+ ON CONFLICT(scope_key) DO UPDATE SET
4007
+ day = excluded.day, occurred_at = excluded.occurred_at,
4008
+ sequence = excluded.sequence, cost = excluded.cost
4009
+ WHERE excluded.occurred_at > token_cost.occurred_at
4010
+ OR (excluded.occurred_at = token_cost.occurred_at AND excluded.sequence > token_cost.sequence)`
4011
+ ).run(scopeKey2, eventDay(event), occurredAt, event.sequence, cost);
3951
4012
  }
3952
- /** Day families the legacy import refused to move, oldest first. */
3953
- quarantinedFamilies() {
3954
- const raw = this.readMeta(LEGACY_JSONL_QUARANTINE_KEY);
3955
- if (!raw) return [];
3956
- try {
3957
- const parsed = JSON.parse(raw);
3958
- return Array.isArray(parsed) ? parsed : [];
3959
- } catch {
3960
- return [];
4013
+ ingestTask(event) {
4014
+ const attributes = event.attributes ?? {};
4015
+ const taskId = event.scope.taskId ?? stringAt(attributes, "taskId");
4016
+ if (!taskId) return;
4017
+ const occurredAt = event.occurredAt ?? event.observedAt;
4018
+ this.db.prepare("INSERT OR IGNORE INTO task_outcomes (task_id) VALUES (?)").run(taskId);
4019
+ const set = (sql, ...params) => this.db.prepare(`UPDATE task_outcomes SET ${sql} WHERE task_id = ?`).run(...params, taskId);
4020
+ const lineage = [
4021
+ ["run_id", stringAt(attributes, "runId")],
4022
+ ["board_id", event.scope.kanbanBoardId ?? stringAt(attributes, "boardId")],
4023
+ ["session_id", event.scope.sessionId],
4024
+ ["agent_id", event.scope.agentId ?? stringAt(attributes, "subagentId")]
4025
+ ];
4026
+ for (const [column, value] of lineage) {
4027
+ if (value) set(`${column} = ?`, value);
4028
+ }
4029
+ const base = event.eventType.replace(/^(?:sdd|subagent|kanban)\.task[._]/, "");
4030
+ switch (base) {
4031
+ case "started":
4032
+ set("status = 'started', started_at = COALESCE(started_at, ?)", occurredAt);
4033
+ break;
4034
+ case "completed":
4035
+ set(
4036
+ "status = 'completed', ended_at = ?, duration_ms = ?",
4037
+ occurredAt,
4038
+ numberOrDuration(event, attributes)
4039
+ );
4040
+ break;
4041
+ case "failed":
4042
+ set("status = 'failed', ended_at = ?", occurredAt);
4043
+ break;
4044
+ case "retrying":
4045
+ set("retries = retries + 1");
4046
+ break;
4047
+ case "verification_failed":
4048
+ set("verification_failures = verification_failures + 1");
4049
+ break;
4050
+ case "merged":
4051
+ set("status = 'merged'");
4052
+ break;
4053
+ case "conflict":
4054
+ set("status = 'conflict'");
4055
+ break;
4056
+ default:
4057
+ break;
3961
4058
  }
3962
4059
  }
3963
- // ─── internals ────────────────────────────────────────────────────────────
3964
- readMeta(key) {
3965
- const row = this.db.prepare("SELECT value FROM chronicle_meta WHERE key = ?").get(key);
3966
- return row?.value;
4060
+ ingestFileEvent(event) {
4061
+ const attributes = event.attributes ?? {};
4062
+ const operation = stringAt(attributes, "operation") ?? "";
4063
+ if (!operation || operation === "read") return;
4064
+ const filePath = event.resource?.path ?? stringAt(attributes, "filePath");
4065
+ if (!filePath) return;
4066
+ this.db.prepare(
4067
+ `INSERT OR IGNORE INTO file_lineage
4068
+ (event_id, path, path_key, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
4069
+ tool_name, provider_id, model_id, source)
4070
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
4071
+ ).run(
4072
+ event.eventId,
4073
+ normalizeKey(filePath),
4074
+ normalizePathKey(filePath),
4075
+ operation,
4076
+ event.occurredAt ?? event.observedAt,
4077
+ event.scope.sessionId ?? "",
4078
+ event.scope.agentId ?? "",
4079
+ event.scope.taskId ?? stringAt(attributes, "taskId") ?? "",
4080
+ event.scope.kanbanBoardId ?? stringAt(attributes, "boardId") ?? "",
4081
+ stringAt(attributes, "runId") ?? "",
4082
+ stringAt(attributes, "toolName") ?? "",
4083
+ event.runtime?.providerId ?? stringAt(attributes, "provider") ?? "",
4084
+ event.runtime?.modelId ?? stringAt(attributes, "model") ?? "",
4085
+ stringAt(attributes, "source") ?? (event.eventType === "file.event" ? "tool" : "external")
4086
+ );
4087
+ }
4088
+ };
4089
+ function eventDay(event) {
4090
+ return (event.occurredAt ?? event.observedAt).slice(0, 10);
4091
+ }
4092
+ function durationMs2(event) {
4093
+ const value = Number(event.durationNs ?? 0) / 1e6;
4094
+ return Number.isFinite(value) && value > 0 ? value : 0;
4095
+ }
4096
+ function numberOrDuration(event, attributes) {
4097
+ const explicit = attributes.durationMs;
4098
+ if (typeof explicit === "number" && Number.isFinite(explicit)) return explicit;
4099
+ return durationMs2(event);
4100
+ }
4101
+ function numberAt2(event, dotPath) {
4102
+ const value = readPath2(event.attributes ?? {}, dotPath);
4103
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
4104
+ }
4105
+ function readPath2(value, key) {
4106
+ return key.split(".").reduce(
4107
+ (current, part) => current && typeof current === "object" ? current[part] : void 0,
4108
+ value
4109
+ );
4110
+ }
4111
+ function stringAt(record, key) {
4112
+ const value = record[key];
4113
+ return typeof value === "string" && value.length > 0 ? value : void 0;
4114
+ }
4115
+ function asString(value) {
4116
+ return typeof value === "string" && value.length > 0 ? value : void 0;
4117
+ }
4118
+ function clampLimit(limit, fallback) {
4119
+ if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) return fallback;
4120
+ return Math.min(Math.floor(limit), 1e4);
4121
+ }
4122
+ function normalizeKey(value) {
4123
+ return value.replaceAll("\\", "/");
4124
+ }
4125
+ function normalizePathKey(value) {
4126
+ return value.replaceAll("\\", "/").replace(/^\.\//, "").toLowerCase();
4127
+ }
4128
+
4129
+ // src/chronicle/legacy-journal-import.ts
4130
+ import * as fs8 from "node:fs/promises";
4131
+ import * as path11 from "node:path";
4132
+ var ChronicleImportError = class extends Error {
4133
+ constructor(message, day, sequence) {
4134
+ super(message);
4135
+ this.day = day;
4136
+ this.sequence = sequence;
4137
+ this.name = "ChronicleImportError";
3967
4138
  }
3968
- readCheckpoint(day) {
3969
- return this.db.prepare("SELECT sequence, hash FROM chain_checkpoint WHERE day = ?").get(day);
4139
+ day;
4140
+ sequence;
4141
+ };
4142
+ async function discoverFamilies(directory) {
4143
+ let entries;
4144
+ try {
4145
+ entries = await fs8.readdir(directory);
4146
+ } catch {
4147
+ return [];
3970
4148
  }
3971
- /** Days that currently hold at least one event, oldest first. */
3972
- days() {
3973
- const rows = this.db.prepare("SELECT DISTINCT day FROM events ORDER BY day").all();
3974
- return rows.map((row) => row.day);
4149
+ const bases = /* @__PURE__ */ new Set();
4150
+ for (const entry of entries) {
4151
+ const match = PARTITION_FILE_PATTERN.exec(entry);
4152
+ if (match?.[1]) bases.add(match[1]);
3975
4153
  }
3976
- /**
3977
- * Resolve the chain head, in the same precedence the JSONL journal uses: the
3978
- * newest event, else the retention checkpoint (every event before it was
3979
- * purged), else genesis.
3980
- */
3981
- readAnchor(day) {
3982
- const cached = this.anchors.get(day);
3983
- if (cached) return cached;
3984
- const last = this.db.prepare("SELECT sequence, hash FROM events WHERE day = ? ORDER BY sequence DESC LIMIT 1").get(day);
3985
- const anchor = last ?? this.readCheckpoint(day) ?? { sequence: 0, hash: GENESIS_HASH };
3986
- this.anchors.set(day, anchor);
3987
- return anchor;
4154
+ return [...bases].sort();
4155
+ }
4156
+ function dayOfFamily(familyBase) {
4157
+ return familyBase.replace(/\.events$/u, "");
4158
+ }
4159
+ async function importLegacyChronicleJournal(journal, directory) {
4160
+ if (journal.hasImportedLegacyJournal()) {
4161
+ return {
4162
+ alreadyImported: true,
4163
+ families: 0,
4164
+ events: 0,
4165
+ quarantined: journal.quarantinedFamilies()
4166
+ };
3988
4167
  }
3989
- ensureSchema() {
3990
- const version = this.db.prepare("PRAGMA user_version").get().user_version;
3991
- this.db.exec(`
3992
- CREATE TABLE IF NOT EXISTS events (
3993
- day TEXT NOT NULL,
3994
- sequence INTEGER NOT NULL,
3995
- event_id TEXT NOT NULL UNIQUE,
3996
- hash TEXT NOT NULL,
3997
- previous_hash TEXT NOT NULL,
3998
- occurred_at TEXT NOT NULL,
3999
- event_type TEXT NOT NULL,
4000
- outcome TEXT,
4001
- project_id TEXT,
4002
- session_id TEXT,
4003
- agent_id TEXT,
4004
- task_id TEXT,
4005
- trace_id TEXT,
4006
- logical_request_id TEXT,
4007
- resource_kind TEXT,
4008
- resource_id TEXT,
4009
- resource_path TEXT,
4010
- duration_ns TEXT,
4011
- payload TEXT NOT NULL,
4012
- PRIMARY KEY (day, sequence)
4013
- );
4014
- CREATE INDEX IF NOT EXISTS events_occurred_at ON events(occurred_at);
4015
- CREATE INDEX IF NOT EXISTS events_type_outcome ON events(event_type, outcome);
4016
- CREATE INDEX IF NOT EXISTS events_session ON events(session_id, day, sequence);
4017
- CREATE INDEX IF NOT EXISTS events_trace ON events(trace_id);
4018
- CREATE INDEX IF NOT EXISTS events_logical_request ON events(logical_request_id);
4019
- CREATE INDEX IF NOT EXISTS events_resource_path ON events(resource_path);
4020
-
4021
- CREATE TABLE IF NOT EXISTS chain_checkpoint (
4022
- day TEXT PRIMARY KEY,
4023
- sequence INTEGER NOT NULL,
4024
- hash TEXT NOT NULL
4025
- );
4026
-
4027
- CREATE TABLE IF NOT EXISTS chronicle_meta (
4028
- key TEXT PRIMARY KEY,
4029
- value TEXT NOT NULL
4030
- );
4031
- `);
4032
- if (version !== SCHEMA_VERSION2) {
4033
- this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION2}`);
4168
+ const families = await discoverFamilies(directory);
4169
+ const quarantined = [];
4170
+ let importedEvents = 0;
4171
+ let importedFamilies = 0;
4172
+ for (const familyBase of families) {
4173
+ const day = dayOfFamily(familyBase);
4174
+ const basePath = path11.join(directory, `${familyBase}.jsonl`);
4175
+ let familyEvents = 0;
4176
+ if (journal.hasImportedDay(day)) continue;
4177
+ try {
4178
+ await journal.runFamilyImport(async (sink) => {
4179
+ familyEvents = 0;
4180
+ const checkpointResult = await readRetentionCheckpoint(basePath);
4181
+ if (checkpointResult.error) {
4182
+ throw new ChronicleImportError(checkpointResult.error, day, 0);
4183
+ }
4184
+ const checkpoint = checkpointResult.checkpoint;
4185
+ if (checkpoint) sink.checkpoint(day, checkpoint.sequence, checkpoint.hash);
4186
+ let expectedSequence = (checkpoint?.sequence ?? 0) + 1;
4187
+ let previousHash = checkpoint?.hash ?? GENESIS_HASH;
4188
+ for (const partition of await collectPartitions(basePath)) {
4189
+ for await (const event of streamEntriesStrict(partition)) {
4190
+ if (event.sequence !== expectedSequence) {
4191
+ throw new ChronicleImportError(
4192
+ `sequence gap in ${day}: expected ${expectedSequence}, found ${event.sequence}`,
4193
+ day,
4194
+ event.sequence
4195
+ );
4196
+ }
4197
+ if (event.previousHash !== previousHash) {
4198
+ throw new ChronicleImportError(
4199
+ `previous hash mismatch in ${day} at sequence ${event.sequence}`,
4200
+ day,
4201
+ event.sequence
4202
+ );
4203
+ }
4204
+ if (chronicleEventHash(event) !== event.hash) {
4205
+ throw new ChronicleImportError(
4206
+ `entry hash mismatch in ${day} at sequence ${event.sequence}`,
4207
+ day,
4208
+ event.sequence
4209
+ );
4210
+ }
4211
+ sink.insert(day, event);
4212
+ familyEvents += 1;
4213
+ expectedSequence = event.sequence + 1;
4214
+ previousHash = event.hash;
4215
+ }
4216
+ }
4217
+ });
4218
+ } catch (error) {
4219
+ if (!(error instanceof ChronicleImportError)) throw error;
4220
+ quarantined.push({ day, sequence: error.sequence, reason: error.message });
4221
+ continue;
4034
4222
  }
4223
+ if (familyEvents > 0) importedFamilies += 1;
4224
+ importedEvents += familyEvents;
4035
4225
  }
4036
- };
4037
- function projectEvent(event) {
4038
- const occurredAt = event.occurredAt ?? event.observedAt;
4226
+ journal.recordQuarantinedFamilies(quarantined);
4227
+ journal.markLegacyJournalImported();
4039
4228
  return {
4040
- occurredAt,
4041
- outcome: event.outcome ?? null,
4042
- projectId: event.scope.projectId ?? null,
4043
- sessionId: event.scope.sessionId ?? null,
4044
- agentId: event.scope.agentId ?? null,
4045
- taskId: event.scope.taskId ?? null,
4046
- traceId: event.correlation?.traceId ?? null,
4047
- logicalRequestId: event.correlation?.logicalRequestId ?? null,
4048
- resourceKind: event.resource?.kind ?? null,
4049
- resourceId: event.resource?.id ?? null,
4050
- resourcePath: event.resource?.path ?? null,
4051
- durationNs: event.durationNs ?? null
4229
+ alreadyImported: false,
4230
+ families: importedFamilies,
4231
+ events: importedEvents,
4232
+ quarantined
4052
4233
  };
4053
4234
  }
4054
4235
 
@@ -4067,6 +4248,7 @@ import * as path12 from "node:path";
4067
4248
  // src/chronicle/project-server-protocol.ts
4068
4249
  var CHRONICLE_PROJECT_SERVER_PROTOCOL_VERSION = 2;
4069
4250
  var CHRONICLE_PROJECT_SERVER_MAX_FRAME_CHARS = 64 * 1024 * 1024;
4251
+ var CHRONICLE_MAX_APPEND_BATCH = 1e4;
4070
4252
  function encodeChronicleProjectServerMessage(message) {
4071
4253
  return `${JSON.stringify(message)}
4072
4254
  `;
@@ -4498,7 +4680,7 @@ var ChronicleRemoteJournal = class {
4498
4680
  this.timer = void 0;
4499
4681
  }
4500
4682
  if (this.pending.length === 0) return;
4501
- const batch = this.pending.splice(0);
4683
+ const batch = this.pending.splice(0, CHRONICLE_MAX_APPEND_BATCH);
4502
4684
  const started = performance.now();
4503
4685
  this.counters.batches++;
4504
4686
  this.counters.largestBatch = Math.max(this.counters.largestBatch, batch.length);
@@ -5298,7 +5480,17 @@ var SPECIALIZED_RE = /^(?:provider\.attempt\.|tool\.|process\.|brain\.decision_|
5298
5480
  var CODING_SIGNAL_RE = /^(?:(?:agent|subagent|delegate|fleet|session|iteration|context|compaction|checkpoint|in_flight|memory|storage|trust|sdd|worktree|kanban|brain|token|budget|concurrency|provider|mcp|network)\.|file\.event$|error$)/;
5299
5481
  var SENSITIVE_KEY = /(content|text|prompt|question|rationale|reason|detail|summary|description|context|input|output|error|message|secret|token|password|key)$/i;
5300
5482
  var PRESERVE_STRING_KEY = /(^|_)(id|status|state|kind|type|source|model|provider|phase|risk|fallback|path|name|role|sha|branch|mode)$/i;
5301
- var TRUNCATED_ARRAYS = /* @__PURE__ */ new Set(["recentMail", "recentTools", "recentCommands", "activated", "injected"]);
5483
+ var TRUNCATED_ARRAYS = /* @__PURE__ */ new Set([
5484
+ "recentMail",
5485
+ "recentTools",
5486
+ "recentCommands",
5487
+ "activated",
5488
+ "injected",
5489
+ // SAGE per-memory rejection evidence — same cap (5) as activated/injected
5490
+ // so the journal stays bounded under burst conditions. Each entry is
5491
+ // ~120 bytes serialized; cap × entry-count = ~600B per injector_run.
5492
+ "rejectedDetail"
5493
+ ]);
5302
5494
  var TRUNCATED_ARRAY_MAX = 5;
5303
5495
  var DEFAULT_ARRAY_MAX = 20;
5304
5496
  function wireDomainEventsToChronicle(options) {
@@ -5364,14 +5556,19 @@ function stringField(value, key) {
5364
5556
  function eventTime(value) {
5365
5557
  const raw = value.at ?? value.ts ?? value.timestamp;
5366
5558
  if (typeof raw === "number" && Number.isFinite(raw)) return new Date(raw).toISOString();
5367
- if (typeof raw === "string" && Number.isFinite(Date.parse(raw))) return new Date(raw).toISOString();
5559
+ if (typeof raw === "string" && Number.isFinite(Date.parse(raw)))
5560
+ return new Date(raw).toISOString();
5368
5561
  return void 0;
5369
5562
  }
5370
5563
  function inferOutcome(name, payload) {
5371
- if (payload.ok === false || /(?:failed|error|damaged|conflict|deadlock|denied|rejected|blocked)$/.test(name)) return "failure";
5564
+ if (payload.ok === false || /(?:failed|error|damaged|conflict|deadlock|denied|rejected|blocked)$/.test(name))
5565
+ return "failure";
5372
5566
  if (/(?:started|starting|retrying|threshold_reached)$/.test(name)) return "started";
5373
5567
  if (/(?:cancelled|aborted)$/.test(name)) return "cancelled";
5374
- if (/(?:completed|finished|committed|merged|written|persisted|accepted|recovered|verified|connected|success)$/.test(name) || payload.ok === true) return "success";
5568
+ if (/(?:completed|finished|committed|merged|written|persisted|accepted|recovered|verified|connected|success)$/.test(
5569
+ name
5570
+ ) || payload.ok === true)
5571
+ return "success";
5375
5572
  return "unknown";
5376
5573
  }
5377
5574
  function inferResource(payload, eventName) {
@@ -5399,7 +5596,8 @@ function sanitize(value, key = "", depth = 0, seen = /* @__PURE__ */ new WeakSet
5399
5596
  if (typeof value === "function") return { type: "function" };
5400
5597
  if (typeof value === "string") {
5401
5598
  if (PRESERVE_STRING_KEY.test(key) && !SENSITIVE_KEY.test(key)) return value.slice(0, 512);
5402
- if (SENSITIVE_KEY.test(key)) return { hash: digest(value), length: value.length, redacted: true };
5599
+ if (SENSITIVE_KEY.test(key))
5600
+ return { hash: digest(value), length: value.length, redacted: true };
5403
5601
  return value.length <= 256 ? value : { hash: digest(value), length: value.length, truncated: true };
5404
5602
  }
5405
5603
  if (typeof value !== "object") return String(value);
@@ -5414,7 +5612,8 @@ function sanitize(value, key = "", depth = 0, seen = /* @__PURE__ */ new WeakSet
5414
5612
  const output = {};
5415
5613
  const entries = Object.entries(value);
5416
5614
  for (const [childKey, child] of entries.slice(0, 100)) {
5417
- if (childKey === "ctx" || childKey === "provider" || childKey === "resolve" || childKey === "extend" || childKey === "deny") continue;
5615
+ if (childKey === "ctx" || childKey === "provider" || childKey === "resolve" || childKey === "extend" || childKey === "deny")
5616
+ continue;
5418
5617
  output[childKey] = sanitize(child, childKey, depth + 1, seen);
5419
5618
  }
5420
5619
  if (entries.length > 100) output._truncatedKeys = entries.length - 100;
@@ -5829,9 +6028,11 @@ function aggregateSeverities(findings) {
5829
6028
  // src/chronicle/rollup-adapter.ts
5830
6029
  import { createHash as createHash14 } from "node:crypto";
5831
6030
  var MAX_ROLLUP_RESOURCES = 100;
6031
+ var DEFAULT_GAUGE_WINDOW_MS = 12e4;
5832
6032
  function wireRollupsToChronicle(options) {
5833
6033
  const buckets = /* @__PURE__ */ new Map();
5834
6034
  const windowMs = Math.max(1e3, options.windowMs ?? 1e4);
6035
+ const gaugeWindowMs = Math.max(windowMs, options.gaugeWindowMs ?? DEFAULT_GAUGE_WINDOW_MS);
5835
6036
  const bucket = (key, seed) => {
5836
6037
  let value = buckets.get(key);
5837
6038
  if (!value) {
@@ -5855,7 +6056,12 @@ function wireRollupsToChronicle(options) {
5855
6056
  target.updatedAt = Date.now();
5856
6057
  for (const [name, value] of Object.entries(values)) {
5857
6058
  const metric = target.metrics[name];
5858
- target.metrics[name] = metric ? { sum: metric.sum + value, min: Math.min(metric.min, value), max: Math.max(metric.max, value), last: value } : { sum: value, min: value, max: value, last: value };
6059
+ target.metrics[name] = metric ? {
6060
+ sum: metric.sum + value,
6061
+ min: Math.min(metric.min, value),
6062
+ max: Math.max(metric.max, value),
6063
+ last: value
6064
+ } : { sum: value, min: value, max: value, last: value };
5859
6065
  }
5860
6066
  if (category) target.categories[category] = (target.categories[category] ?? 0) + 1;
5861
6067
  if (digest2) target.digest.update(digest2);
@@ -5865,14 +6071,26 @@ function wireRollupsToChronicle(options) {
5865
6071
  if (!value || value.count === 0) return;
5866
6072
  buckets.delete(key);
5867
6073
  const context = typeof options.context === "function" ? options.context() : options.context;
5868
- const stats = Object.fromEntries(Object.entries(value.metrics).map(([name, metric]) => [name, { ...metric, avg: metric.sum / value.count }]));
6074
+ const stats = Object.fromEntries(
6075
+ Object.entries(value.metrics).map(([name, metric]) => [
6076
+ name,
6077
+ { ...metric, avg: metric.sum / value.count }
6078
+ ])
6079
+ );
5869
6080
  const resources = value.resources.size > 0 ? [...value.resources].slice(0, MAX_ROLLUP_RESOURCES).map(([id, r]) => ({ id, kind: r.kind, ...r.path ? { path: r.path } : {} })) : void 0;
5870
6081
  const input = {
5871
6082
  eventType: "metrics.rollup",
5872
6083
  outcome: "success",
5873
6084
  occurredAt: new Date(value.updatedAt).toISOString(),
5874
- scope: { ...context.scope, ...value.sessionId ? { sessionId: value.sessionId } : {}, ...value.agentId ? { agentId: value.agentId } : {} },
5875
- correlation: { ...context.correlation, ...value.toolCallId ? { toolCallId: value.toolCallId } : {} },
6085
+ scope: {
6086
+ ...context.scope,
6087
+ ...value.sessionId ? { sessionId: value.sessionId } : {},
6088
+ ...value.agentId ? { agentId: value.agentId } : {}
6089
+ },
6090
+ correlation: {
6091
+ ...context.correlation,
6092
+ ...value.toolCallId ? { toolCallId: value.toolCallId } : {}
6093
+ },
5876
6094
  durationNs: String(Math.max(0, value.updatedAt - value.startedAt) * 1e6),
5877
6095
  attributes: {
5878
6096
  signal: value.signal,
@@ -5893,8 +6111,19 @@ function wireRollupsToChronicle(options) {
5893
6111
  const sessionId = text(event.sessionId);
5894
6112
  const dimensionValue = dimension ? text(event[dimension]) : void 0;
5895
6113
  const key = `${signal}\0${sessionId ?? ""}\0${dimensionValue ?? ""}`;
5896
- const target = bucket(key, { signal, ...sessionId ? { sessionId } : {}, ...dimensionValue ? { agentId: dimensionValue } : {}, dimensions: dimensionValue && dimension ? { [dimension]: dimensionValue } : {} });
5897
- sample(target, Object.fromEntries(Object.entries(event).filter(([, value]) => typeof value === "number")));
6114
+ const target = bucket(key, {
6115
+ signal,
6116
+ aggregateMs: gaugeWindowMs,
6117
+ ...sessionId ? { sessionId } : {},
6118
+ ...dimensionValue ? { agentId: dimensionValue } : {},
6119
+ dimensions: dimensionValue && dimension ? { [dimension]: dimensionValue } : {}
6120
+ });
6121
+ sample(
6122
+ target,
6123
+ Object.fromEntries(
6124
+ Object.entries(event).filter(([, value]) => typeof value === "number")
6125
+ )
6126
+ );
5898
6127
  };
5899
6128
  const offs = [
5900
6129
  options.events.on("process.output", (event) => {
@@ -5904,23 +6133,40 @@ function wireRollupsToChronicle(options) {
5904
6133
  sessionId: event.sessionId,
5905
6134
  ...event.agentId ? { agentId: event.agentId } : {},
5906
6135
  toolCallId: event.toolCallId,
5907
- dimensions: { stream: event.stream, toolName: event.toolName, pid: String(event.pid ?? "") }
6136
+ dimensions: {
6137
+ stream: event.stream,
6138
+ toolName: event.toolName,
6139
+ pid: String(event.pid ?? "")
6140
+ }
5908
6141
  });
5909
6142
  sample(target, { bytes: event.bytes }, event.stream, event.chunkHash);
5910
6143
  }),
5911
6144
  options.events.on("process.completed", (event) => {
5912
- for (const key of [...buckets.keys()]) if (key.startsWith(`process.output\0${event.sessionId}\0${event.toolCallId}\0`)) flush(key);
6145
+ for (const key of [...buckets.keys()])
6146
+ if (key.startsWith(`process.output\0${event.sessionId}\0${event.toolCallId}\0`)) flush(key);
5913
6147
  }),
5914
6148
  options.events.on("tool.progress", (event) => {
5915
6149
  if (event.event.type === "file_changed") return;
5916
6150
  const key = `tool.progress\0${event.sessionId ?? ""}\0${event.id}`;
5917
- const target = bucket(key, { signal: "tool.progress", ...event.sessionId ? { sessionId: event.sessionId } : {}, ...event.agentId ? { agentId: event.agentId } : {}, toolCallId: event.id, dimensions: { toolName: event.name } });
5918
- sample(target, { textBytes: Buffer.byteLength(event.event.text ?? "") }, event.event.type, safeDigest(event.event));
6151
+ const target = bucket(key, {
6152
+ signal: "tool.progress",
6153
+ ...event.sessionId ? { sessionId: event.sessionId } : {},
6154
+ ...event.agentId ? { agentId: event.agentId } : {},
6155
+ toolCallId: event.id,
6156
+ dimensions: { toolName: event.name }
6157
+ });
6158
+ sample(
6159
+ target,
6160
+ { textBytes: Buffer.byteLength(event.event.text ?? "") },
6161
+ event.event.type,
6162
+ safeDigest(event.event)
6163
+ );
5919
6164
  }),
5920
6165
  options.events.on("tool.executed", (event) => {
5921
6166
  flush(`tool.progress\0${event.sessionId ?? ""}\0${event.id ?? ""}`);
5922
6167
  const metadata = event.metadata;
5923
- if (!metadata || metadata.files.length === 0 && metadata.symbols.length === 0 && metadata.commands.length === 0) return;
6168
+ if (!metadata || metadata.files.length === 0 && metadata.symbols.length === 0 && metadata.commands.length === 0)
6169
+ return;
5924
6170
  const key = `tool.resource\0${event.sessionId ?? ""}\0${event.id ?? ""}`;
5925
6171
  const target = bucket(key, {
5926
6172
  signal: "tool.resource.observed",
@@ -5943,16 +6189,27 @@ function wireRollupsToChronicle(options) {
5943
6189
  }
5944
6190
  flush(key);
5945
6191
  }),
5946
- options.events.on("tool.failed", (event) => flush(`tool.progress\0${event.sessionId}\0${event.id}`)),
6192
+ options.events.on(
6193
+ "tool.failed",
6194
+ (event) => flush(`tool.progress\0${event.sessionId}\0${event.id}`)
6195
+ ),
5947
6196
  options.events.on("ctx.pct", (event) => gauge("ctx.pct", event)),
5948
- options.events.on("subagent.ctx_pct", (event) => gauge("subagent.ctx_pct", event, "subagentId")),
6197
+ options.events.on(
6198
+ "subagent.ctx_pct",
6199
+ (event) => gauge("subagent.ctx_pct", event, "subagentId")
6200
+ ),
5949
6201
  options.events.on("countdown.tick", (event) => gauge("countdown.tick", event)),
5950
6202
  options.events.on("coordinator.stats", (event) => gauge("coordinator.stats", event)),
5951
6203
  // Periodic self-observation gauge (nested fields, so the flat gauge()
5952
6204
  // helper can't be reused directly — flatten before sampling).
5953
6205
  options.events.on("runtime.health.sampled", (event) => {
5954
6206
  const key = `runtime.health\0${event.sessionId ?? ""}`;
5955
- const target = bucket(key, { signal: "runtime.health", ...event.sessionId ? { sessionId: event.sessionId } : {}, dimensions: {} });
6207
+ const target = bucket(key, {
6208
+ signal: "runtime.health",
6209
+ aggregateMs: gaugeWindowMs,
6210
+ ...event.sessionId ? { sessionId: event.sessionId } : {},
6211
+ dimensions: {}
6212
+ });
5956
6213
  const chronicle = event.chronicle;
5957
6214
  sample(target, {
5958
6215
  "eventLoop.utilization": event.eventLoop.utilization,
@@ -5973,8 +6230,21 @@ function wireRollupsToChronicle(options) {
5973
6230
  // from the domain adapter. Sums preserve the fleet-level metric trail.
5974
6231
  options.events.on("session.agents_updated", (event) => {
5975
6232
  const key = `session.agents\0${event.sessionId ?? ""}`;
5976
- const target = bucket(key, { signal: "session.agents", ...event.sessionId ? { sessionId: event.sessionId } : {}, dimensions: {} });
5977
- const sums = { agents: 0, running: 0, iterations: 0, toolCalls: 0, costUsd: 0, tokensIn: 0, tokensOut: 0 };
6233
+ const target = bucket(key, {
6234
+ signal: "session.agents",
6235
+ aggregateMs: gaugeWindowMs,
6236
+ ...event.sessionId ? { sessionId: event.sessionId } : {},
6237
+ dimensions: {}
6238
+ });
6239
+ const sums = {
6240
+ agents: 0,
6241
+ running: 0,
6242
+ iterations: 0,
6243
+ toolCalls: 0,
6244
+ costUsd: 0,
6245
+ tokensIn: 0,
6246
+ tokensOut: 0
6247
+ };
5978
6248
  for (const agent of event.agents) {
5979
6249
  sums.agents++;
5980
6250
  if (agent.status === "running") sums.running++;
@@ -5996,16 +6266,24 @@ function wireRollupsToChronicle(options) {
5996
6266
  sessionId: event.sessionId,
5997
6267
  dimensions: { initiator: event.initiator, serverAddress: event.serverAddress }
5998
6268
  });
5999
- sample(target, {
6000
- durationMs: event.durationMs,
6001
- ...event.requestBytes !== void 0 ? { requestBytes: event.requestBytes } : {},
6002
- ...event.responseBytes !== void 0 ? { responseBytes: event.responseBytes } : {}
6003
- }, `${Math.floor(event.statusCode / 100)}xx`, event.requestId);
6269
+ sample(
6270
+ target,
6271
+ {
6272
+ durationMs: event.durationMs,
6273
+ ...event.requestBytes !== void 0 ? { requestBytes: event.requestBytes } : {},
6274
+ ...event.responseBytes !== void 0 ? { responseBytes: event.responseBytes } : {}
6275
+ },
6276
+ `${Math.floor(event.statusCode / 100)}xx`,
6277
+ event.requestId
6278
+ );
6004
6279
  })
6005
6280
  ];
6006
6281
  const timer = setInterval(() => {
6007
- const cutoff = Date.now() - windowMs;
6008
- for (const [key, value] of buckets) if (value.updatedAt <= cutoff) flush(key);
6282
+ const now = Date.now();
6283
+ for (const [key, value] of buckets) {
6284
+ const due = value.aggregateMs === void 0 ? value.updatedAt <= now - windowMs : value.startedAt <= now - value.aggregateMs;
6285
+ if (due) flush(key);
6286
+ }
6009
6287
  }, windowMs);
6010
6288
  timer.unref?.();
6011
6289
  return () => {