@wrongstack/core 0.306.3 → 0.307.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 (114) hide show
  1. package/dist/chronicle/index.js +957 -953
  2. package/dist/chronicle/metrics-ingest.d.ts +55 -0
  3. package/dist/chronicle/metrics-schema.d.ts +86 -0
  4. package/dist/chronicle/metrics-store.d.ts +5 -120
  5. package/dist/chronicle/project-server.js +1083 -1084
  6. package/dist/chronicle/sqlite-journal-quota.d.ts +39 -0
  7. package/dist/chronicle/sqlite-journal-schema.d.ts +25 -0
  8. package/dist/chronicle/sqlite-journal.d.ts +12 -177
  9. package/dist/coordination/agents/index.js +1738 -1690
  10. package/dist/coordination/agents/project-agent-capture.d.ts +48 -0
  11. package/dist/coordination/agents/project-agent-identity.d.ts +3 -129
  12. package/dist/coordination/agents/project-agent-roster.d.ts +17 -0
  13. package/dist/coordination/index.js +6103 -5890
  14. package/dist/coordination/mailbox-http-actor-query.d.ts +16 -0
  15. package/dist/coordination/mailbox-http-router.d.ts +0 -48
  16. package/dist/coordination/mailbox-http-sse.d.ts +12 -0
  17. package/dist/coordination/mailbox-message-types.d.ts +71 -0
  18. package/dist/coordination/mailbox-predicates.d.ts +13 -0
  19. package/dist/coordination/mailbox-project-server.js +1 -1
  20. package/dist/coordination/mailbox-session-sync.d.ts +8 -0
  21. package/dist/coordination/mailbox-types.d.ts +6 -793
  22. package/dist/core/agent-loop-context.d.ts +28 -0
  23. package/dist/core/agent-loop-detector.d.ts +25 -0
  24. package/dist/core/conversation-state.d.ts +1 -2
  25. package/dist/core/fallback-doctor.d.ts +72 -0
  26. package/dist/core/fallback-model.d.ts +19 -1
  27. package/dist/core/fallback-profile-manager.d.ts +21 -3
  28. package/dist/core/index.d.ts +2 -1
  29. package/dist/core/index.js +1143 -766
  30. package/dist/defaults/index.js +3833 -3595
  31. package/dist/execution/auto-compaction-middleware.d.ts +64 -0
  32. package/dist/execution/autonomy-brain-llm.d.ts +55 -0
  33. package/dist/execution/autonomy-brain.d.ts +3 -156
  34. package/dist/execution/compaction-budget.d.ts +38 -0
  35. package/dist/execution/compaction-core.d.ts +3 -166
  36. package/dist/execution/compaction-elision.d.ts +58 -0
  37. package/dist/execution/council-orchestrator-helpers.d.ts +39 -0
  38. package/dist/execution/council-orchestrator.d.ts +3 -42
  39. package/dist/execution/council-response-parser.d.ts +56 -0
  40. package/dist/execution/index.d.ts +1 -1
  41. package/dist/execution/index.js +10016 -9903
  42. package/dist/execution/prompt-enhancer.js +11 -3
  43. package/dist/execution/retry-policy.d.ts +27 -0
  44. package/dist/execution/tool-executor-guard.d.ts +18 -0
  45. package/dist/execution/tool-executor-runner.d.ts +12 -0
  46. package/dist/execution/tool-executor.d.ts +0 -75
  47. package/dist/goal/index.js +19 -6
  48. package/dist/hq/index.js +58 -17
  49. package/dist/index.d.ts +2 -1
  50. package/dist/index.js +8731 -7756
  51. package/dist/infrastructure/index.js +24 -1
  52. package/dist/kernel/events/agent-events.d.ts +2 -0
  53. package/dist/kernel/events/provider-events.d.ts +13 -0
  54. package/dist/plugin/index.js +2123 -1918
  55. package/dist/plugins/auto-review-config.d.ts +53 -0
  56. package/dist/plugins/auto-review-git.d.ts +19 -0
  57. package/dist/plugins/auto-review-plugin.d.ts +2 -140
  58. package/dist/plugins/review-finding-verification.d.ts +7 -0
  59. package/dist/prompts/index.d.ts +1 -0
  60. package/dist/prompts/prompt-journal.d.ts +94 -0
  61. package/dist/security/index.js +677 -667
  62. package/dist/security/permission-explain.d.ts +19 -0
  63. package/dist/security/permission-policy.d.ts +0 -101
  64. package/dist/security/yolo-risk.d.ts +5 -4
  65. package/dist/session-catalog/index.js +270 -227
  66. package/dist/session-catalog/project-server.js +277 -234
  67. package/dist/session-catalog/store-rebuild.d.ts +9 -0
  68. package/dist/session-catalog/store-schema.d.ts +48 -0
  69. package/dist/session-catalog/store.d.ts +0 -17
  70. package/dist/storage/cloud-config-sync/sanitize.d.ts +18 -0
  71. package/dist/storage/cloud-config-sync.d.ts +1 -1
  72. package/dist/storage/director-state.d.ts +6 -0
  73. package/dist/storage/file-session-writer.d.ts +2 -58
  74. package/dist/storage/index.d.ts +2 -1
  75. package/dist/storage/index.js +2121 -1736
  76. package/dist/storage/orphan-lock-cleaner.d.ts +15 -0
  77. package/dist/storage/provider-config-watcher.d.ts +9 -0
  78. package/dist/storage/session-recovery.d.ts +9 -0
  79. package/dist/storage/session-store/rename-session.d.ts +16 -0
  80. package/dist/storage/session-store/resume-session.d.ts +26 -0
  81. package/dist/storage/session-store/session-store-clear.d.ts +11 -0
  82. package/dist/storage/session-store/session-store-index.d.ts +13 -0
  83. package/dist/storage/session-store/strict-empty-check.d.ts +7 -0
  84. package/dist/storage/session-store.d.ts +2 -129
  85. package/dist/storage/session-summary-tracker.d.ts +39 -0
  86. package/dist/storage/session-write-buffer.d.ts +43 -0
  87. package/dist/storage/session-writer/session-writer-flush.d.ts +4 -0
  88. package/dist/storage/session-writer/session-writer-summary-tracker.d.ts +28 -0
  89. package/dist/tools/fallback-agent-model-assign-tool.d.ts +18 -0
  90. package/dist/tools/fallback-leader-model-set-tool.d.ts +18 -0
  91. package/dist/tools/fallback-manage-tools.d.ts +8 -45
  92. package/dist/tools/fallback-profile-manage-tool.d.ts +16 -0
  93. package/dist/tools/fallback-provider-key-set-tool.d.ts +17 -0
  94. package/dist/tools/fallback-provider-manage-tool.d.ts +26 -0
  95. package/dist/tools/index.d.ts +1 -1
  96. package/dist/tools/index.js +5150 -5090
  97. package/dist/types/config/root.d.ts +21 -2
  98. package/dist/types/default-config.d.ts +1 -1
  99. package/dist/types/index.d.ts +54 -55
  100. package/dist/types/index.js +953 -932
  101. package/dist/types/runtime-capability-manifest.d.ts +5 -5
  102. package/dist/types/session-markers.d.ts +12 -0
  103. package/dist/types/session-markers.js +19 -0
  104. package/dist/types/session.d.ts +7 -0
  105. package/dist/worktree/worktree-manager.d.ts +1 -1
  106. package/instructions/coordination/subagent-baseline.md +2 -2
  107. package/instructions/llm/prompt-enhancer.md +2 -1
  108. package/instructions/sections/tool/common-patterns.md +21 -0
  109. package/instructions/sections/tool/delegation-compact.md +3 -1
  110. package/instructions/sections/tool/delegation-full.md +5 -1
  111. package/instructions/system-lite.md +28 -3
  112. package/instructions/system-pro.md +37 -8
  113. package/instructions/system.md +4 -2
  114. package/package.json +5 -5
@@ -171,9 +171,9 @@ async function startChronicleFileObserver(options) {
171
171
  const recentToolMutations = /* @__PURE__ */ new Map();
172
172
  const noteToolMutation = (hint) => {
173
173
  const absolute = path3.isAbsolute(hint.path) ? path3.normalize(hint.path) : path3.resolve(root, hint.path);
174
- const relative4 = normalizeRelative(path3.relative(root, absolute));
175
- if (relative4.startsWith("../") || isExcluded(relative4, excluded, excludedPaths)) return;
176
- recentToolMutations.set(relative4, {
174
+ const relative5 = normalizeRelative(path3.relative(root, absolute));
175
+ if (relative5.startsWith("../") || isExcluded(relative5, excluded, excludedPaths)) return;
176
+ recentToolMutations.set(relative5, {
177
177
  at: hint.at ?? Date.now(),
178
178
  toolUseId: hint.toolUseId,
179
179
  toolName: hint.toolName,
@@ -235,9 +235,9 @@ async function startChronicleFileObserver(options) {
235
235
  requestFullRescan();
236
236
  return;
237
237
  }
238
- const relative4 = normalizeRelative(String(filename));
239
- if (!relative4 || isExcluded(relative4, excluded, excludedPaths)) return;
240
- pending.add(relative4);
238
+ const relative5 = normalizeRelative(String(filename));
239
+ if (!relative5 || isExcluded(relative5, excluded, excludedPaths)) return;
240
+ pending.add(relative5);
241
241
  bumpDebounce();
242
242
  };
243
243
  const reconcile = async (changedPaths) => {
@@ -245,15 +245,15 @@ async function startChronicleFileObserver(options) {
245
245
  if (wantsFullScan) lastFullScanAt = Date.now();
246
246
  const fullScan = wantsFullScan ? await scanProject(root, excluded, excludedPaths, maxHashBytes, options.onError, known) : void 0;
247
247
  const candidates = (fullScan ? unionKeys(known, fullScan.files) : changedPaths).filter(
248
- (relative4) => !isExcluded(relative4, excluded, excludedPaths)
248
+ (relative5) => !isExcluded(relative5, excluded, excludedPaths)
249
249
  );
250
250
  const changes = [];
251
- for (const relative4 of candidates) {
252
- const before = known.get(relative4);
253
- const cached = fullScan?.files.get(relative4);
254
- const after = cached ?? (fullScan?.complete ? void 0 : await fingerprint(path3.join(root, relative4), maxHashBytes));
251
+ for (const relative5 of candidates) {
252
+ const before = known.get(relative5);
253
+ const cached = fullScan?.files.get(relative5);
254
+ const after = cached ?? (fullScan?.complete ? void 0 : await fingerprint(path3.join(root, relative5), maxHashBytes));
255
255
  if (sameFingerprint(before, after)) continue;
256
- changes.push({ relative: relative4, before, after });
256
+ changes.push({ relative: relative5, before, after });
257
257
  }
258
258
  const deleted = changes.filter((change) => change.before && !change.after);
259
259
  const created = changes.filter((change) => !change.before && change.after);
@@ -447,25 +447,25 @@ async function scanProject(root, excluded, excludedPaths, maxHashBytes, onError,
447
447
  const entries = await fsp.readdir(path3.join(root, relativeDir), { withFileTypes: true });
448
448
  const filePaths = [];
449
449
  for (const entry of entries) {
450
- const relative4 = normalizeRelative(path3.join(relativeDir, entry.name));
451
- if (isExcluded(relative4, excluded, excludedPaths)) continue;
450
+ const relative5 = normalizeRelative(path3.join(relativeDir, entry.name));
451
+ if (isExcluded(relative5, excluded, excludedPaths)) continue;
452
452
  if (entry.isDirectory()) {
453
- dirs.push(relative4);
453
+ dirs.push(relative5);
454
454
  } else if (entry.isFile()) {
455
- filePaths.push(relative4);
455
+ filePaths.push(relative5);
456
456
  }
457
457
  }
458
458
  const fingerprints = await mapWithConcurrency(
459
459
  filePaths,
460
460
  SCAN_HASH_CONCURRENCY,
461
- async (relative4) => {
461
+ async (relative5) => {
462
462
  try {
463
463
  const value = await fingerprint(
464
- path3.join(root, relative4),
464
+ path3.join(root, relative5),
465
465
  maxHashBytes,
466
- previous?.get(relative4)
466
+ previous?.get(relative5)
467
467
  );
468
- return value ? [relative4, value] : null;
468
+ return value ? [relative5, value] : null;
469
469
  } catch (error) {
470
470
  complete = false;
471
471
  onError?.(error);
@@ -485,14 +485,14 @@ async function scanProject(root, excluded, excludedPaths, maxHashBytes, onError,
485
485
  }
486
486
  async function fingerprint(filePath, maxHashBytes, previous) {
487
487
  try {
488
- const stat4 = await fsp.stat(filePath);
489
- if (!stat4.isFile()) return void 0;
490
- const base = { size: stat4.size, mtimeMs: stat4.mtimeMs };
491
- if (previous?.hash !== void 0 && previous.size === stat4.size && previous.mtimeMs === stat4.mtimeMs) {
488
+ const stat5 = await fsp.stat(filePath);
489
+ if (!stat5.isFile()) return void 0;
490
+ const base = { size: stat5.size, mtimeMs: stat5.mtimeMs };
491
+ if (previous?.hash !== void 0 && previous.size === stat5.size && previous.mtimeMs === stat5.mtimeMs) {
492
492
  base.hash = previous.hash;
493
493
  return base;
494
494
  }
495
- if (stat4.size <= maxHashBytes) {
495
+ if (stat5.size <= maxHashBytes) {
496
496
  base.hash = createHash2("sha256").update(await fsp.readFile(filePath)).digest("hex");
497
497
  }
498
498
  return base;
@@ -507,8 +507,8 @@ function sameFingerprint(a, b) {
507
507
  if (a.hash !== void 0 && b.hash !== void 0) return a.hash === b.hash;
508
508
  return a.size === b.size && a.mtimeMs === b.mtimeMs;
509
509
  }
510
- function isExcluded(relative4, excluded, excludedPaths) {
511
- const normalized = normalizeRelative(relative4);
510
+ function isExcluded(relative5, excluded, excludedPaths) {
511
+ const normalized = normalizeRelative(relative5);
512
512
  if (normalized.split("/").some((segment) => excluded.has(segment))) return true;
513
513
  for (const prefix of excludedPaths) {
514
514
  if (normalized === prefix || normalized.startsWith(`${prefix}/`)) return true;
@@ -518,8 +518,8 @@ function isExcluded(relative4, excluded, excludedPaths) {
518
518
  function normalizeExcludedPaths(root, values) {
519
519
  const result = /* @__PURE__ */ new Set();
520
520
  for (const value of values) {
521
- const relative4 = path3.isAbsolute(value) ? path3.relative(root, path3.resolve(value)) : value;
522
- const normalized = normalizeRelative(relative4).replace(/\/+$/u, "");
521
+ const relative5 = path3.isAbsolute(value) ? path3.relative(root, path3.resolve(value)) : value;
522
+ const normalized = normalizeRelative(relative5).replace(/\/+$/u, "");
523
523
  if (!normalized || normalized === "." || normalized.startsWith("../")) continue;
524
524
  result.add(normalized);
525
525
  }
@@ -538,7 +538,7 @@ function unionKeys(a, b) {
538
538
  // src/chronicle/project-access.ts
539
539
  import * as fsPromises from "node:fs/promises";
540
540
  import * as os4 from "node:os";
541
- import * as path14 from "node:path";
541
+ import * as path15 from "node:path";
542
542
 
543
543
  // src/utils/wstack-paths.ts
544
544
  import { createHash as createHash3 } from "node:crypto";
@@ -1376,8 +1376,8 @@ async function readLastEntryState(filePath) {
1376
1376
  throw error;
1377
1377
  }
1378
1378
  try {
1379
- const stat4 = await handle.stat();
1380
- const size = stat4.size;
1379
+ const stat5 = await handle.stat();
1380
+ const size = stat5.size;
1381
1381
  let position = size, suffix = "";
1382
1382
  while (position > 0) {
1383
1383
  const length = Math.min(65536, position);
@@ -1394,23 +1394,25 @@ async function readLastEntryState(filePath) {
1394
1394
  return {
1395
1395
  entry: JSON.parse(trimmed),
1396
1396
  size,
1397
- birthtimeMs: stat4.birthtimeMs
1397
+ birthtimeMs: stat5.birthtimeMs
1398
1398
  };
1399
1399
  } catch {
1400
1400
  }
1401
1401
  }
1402
1402
  suffix = lines[0] ?? "";
1403
1403
  }
1404
- return { size, birthtimeMs: stat4.birthtimeMs };
1404
+ return { size, birthtimeMs: stat5.birthtimeMs };
1405
1405
  } finally {
1406
1406
  await handle.close();
1407
1407
  }
1408
1408
  }
1409
1409
  async function* streamEntriesStrict(filePath) {
1410
1410
  let lineNumber = 0;
1411
+ let input;
1412
+ let lines;
1411
1413
  try {
1412
- const input = createReadStream(filePath, { encoding: "utf8", highWaterMark: 256 * 1024 });
1413
- const lines = createInterface({ input, crlfDelay: Infinity });
1414
+ input = createReadStream(filePath, { encoding: "utf8", highWaterMark: 256 * 1024 });
1415
+ lines = createInterface({ input, crlfDelay: Infinity });
1414
1416
  for await (const line of lines) {
1415
1417
  lineNumber++;
1416
1418
  const trimmed = line.trim();
@@ -1426,6 +1428,9 @@ async function* streamEntriesStrict(filePath) {
1426
1428
  } catch (error) {
1427
1429
  if (isNotFound(error)) return;
1428
1430
  throw error;
1431
+ } finally {
1432
+ lines?.close();
1433
+ input?.destroy();
1429
1434
  }
1430
1435
  }
1431
1436
  async function readEntriesStrict(filePath) {
@@ -1441,33 +1446,12 @@ function errorMessage(error) {
1441
1446
  }
1442
1447
 
1443
1448
  // src/chronicle/metrics-store.ts
1449
+ import * as path11 from "node:path";
1450
+
1451
+ // src/chronicle/metrics-ingest.ts
1444
1452
  import * as fs7 from "node:fs/promises";
1445
- import { createRequire as createRequire2 } from "node:module";
1446
1453
  import * as path10 from "node:path";
1447
1454
 
1448
- // src/utils/sqlite-warning.ts
1449
- var SQLITE_EXPERIMENTAL_WARNING_RE = /sqlite is an experimental feature/i;
1450
- function isSqliteExperimentalWarning(warning, rest) {
1451
- const message = typeof warning === "string" ? warning : warning instanceof Error ? warning.message : "";
1452
- const typeOrOptions = rest[0];
1453
- const warningType = typeof warning === "string" ? typeof typeOrOptions === "string" ? typeOrOptions : typeof typeOrOptions === "object" && typeOrOptions !== null && "type" in typeOrOptions && typeof typeOrOptions.type === "string" ? typeOrOptions.type : "" : warning instanceof Error ? warning.name : "";
1454
- const warningCode = typeof warning === "string" ? typeof typeOrOptions === "object" && typeOrOptions !== null && "code" in typeOrOptions && typeof typeOrOptions.code === "string" ? typeOrOptions.code : typeof rest[1] === "string" ? rest[1] : "" : warning instanceof Error && "code" in warning && typeof warning.code === "string" ? warning.code : "";
1455
- return SQLITE_EXPERIMENTAL_WARNING_RE.test(message) && (warningType === "ExperimentalWarning" || warningCode === "ExperimentalWarning");
1456
- }
1457
- function withSqliteExperimentalWarningSuppressed(run) {
1458
- const originalEmitWarning = process.emitWarning;
1459
- const forwardWarning = originalEmitWarning.bind(process);
1460
- process.emitWarning = ((warning, ...rest) => {
1461
- if (isSqliteExperimentalWarning(warning, rest)) return;
1462
- forwardWarning(warning, ...rest);
1463
- });
1464
- try {
1465
- return run();
1466
- } finally {
1467
- process.emitWarning = originalEmitWarning;
1468
- }
1469
- }
1470
-
1471
1455
  // src/chronicle/query.ts
1472
1456
  import { createHash as createHash5 } from "node:crypto";
1473
1457
  import { createReadStream as createReadStream2 } from "node:fs";
@@ -1562,14 +1546,21 @@ var CHRONICLE_FACET_FIELDS = /* @__PURE__ */ new Set([
1562
1546
  "toolCallId"
1563
1547
  ]);
1564
1548
  var MAX_CURSOR_SNAPSHOT_ENTRIES = 1e4;
1565
- function streamLines(filePath, maxBytes) {
1549
+ async function* streamLines(filePath, maxBytes) {
1566
1550
  const stream = createReadStream2(filePath, {
1567
1551
  encoding: "utf8",
1568
1552
  highWaterMark: 256 * 1024,
1569
1553
  ...maxBytes !== void 0 ? { end: maxBytes - 1 } : {}
1570
1554
  });
1571
1555
  const rl = createInterface2({ input: stream, crlfDelay: Infinity });
1572
- return rl[Symbol.asyncIterator]();
1556
+ try {
1557
+ for await (const line of rl) {
1558
+ yield line;
1559
+ }
1560
+ } finally {
1561
+ rl.close();
1562
+ stream.destroy();
1563
+ }
1573
1564
  }
1574
1565
  async function computeOccurredAtRange(file, size) {
1575
1566
  let min;
@@ -2271,9 +2262,143 @@ function facetValue(event, field) {
2271
2262
 
2272
2263
  // src/chronicle/sqlite-journal.ts
2273
2264
  import { randomUUID as randomUUID3 } from "node:crypto";
2265
+ import * as path9 from "node:path";
2266
+
2267
+ // src/chronicle/sqlite-journal-quota.ts
2274
2268
  import * as fs6 from "node:fs";
2269
+ var SQLITE_FIXED_OVERHEAD_BYTES = 16 * 1024 * 1024;
2270
+ var MIN_SQLITE_PAGE_BUDGET_BYTES = 64 * 1024;
2271
+ var MAX_WAL_RESERVE_BYTES = 32 * 1024 * 1024;
2272
+ var WAL_AUTOCHECKPOINT_PAGES = 2e3;
2273
+ var WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024;
2274
+ var ChronicleStorageQuotaError = class extends Error {
2275
+ currentBytes;
2276
+ batchBytes;
2277
+ maxBytes;
2278
+ path;
2279
+ constructor(details) {
2280
+ super(
2281
+ `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)`
2282
+ );
2283
+ this.name = "ChronicleStorageQuotaError";
2284
+ this.currentBytes = details.currentBytes;
2285
+ this.batchBytes = details.batchBytes;
2286
+ this.maxBytes = details.maxBytes;
2287
+ this.path = details.path;
2288
+ }
2289
+ };
2290
+ var ChronicleQuotaManager = class {
2291
+ constructor(db, dbPath, maxBytes) {
2292
+ this.db = db;
2293
+ this.dbPath = dbPath;
2294
+ this.maxBytes = maxBytes;
2295
+ }
2296
+ db;
2297
+ dbPath;
2298
+ maxBytes;
2299
+ cachedPageSize;
2300
+ quotaHeadroomBytes = 0;
2301
+ bytesSinceQuotaCheck = Number.POSITIVE_INFINITY;
2302
+ configure() {
2303
+ if (this.maxBytes === void 0) {
2304
+ this.db.exec("PRAGMA max_page_count = 2147483646");
2305
+ return;
2306
+ }
2307
+ const halfSplit = Math.floor((this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES) / 2);
2308
+ const sidecarReserve = Math.min(MAX_WAL_RESERVE_BYTES, halfSplit);
2309
+ const mainBudget = this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES - sidecarReserve;
2310
+ if (mainBudget < MIN_SQLITE_PAGE_BUDGET_BYTES) {
2311
+ throw new Error(
2312
+ `maxBytes must be at least ${SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES}`
2313
+ );
2314
+ }
2315
+ const maxPages = Math.max(1, Math.floor(mainBudget / this.pageSizeBytes()));
2316
+ this.db.exec(`PRAGMA max_page_count = ${maxPages}`);
2317
+ }
2318
+ pageSizeBytes() {
2319
+ if (this.cachedPageSize === void 0) {
2320
+ this.cachedPageSize = Number(
2321
+ this.db.prepare("PRAGMA page_size").get().page_size
2322
+ );
2323
+ }
2324
+ return this.cachedPageSize;
2325
+ }
2326
+ aggregateLiveBytes() {
2327
+ let total = 0;
2328
+ for (const file of [`${this.dbPath}-journal`, `${this.dbPath}-wal`, `${this.dbPath}-shm`]) {
2329
+ try {
2330
+ total += fs6.statSync(file).size;
2331
+ } catch (error) {
2332
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
2333
+ }
2334
+ }
2335
+ return total + this.mainDatabaseLiveBytes();
2336
+ }
2337
+ mainDatabaseLiveBytes() {
2338
+ const pageCount = Number(
2339
+ this.db.prepare("PRAGMA page_count").get().page_count
2340
+ );
2341
+ const freelist = Number(
2342
+ this.db.prepare("PRAGMA freelist_count").get().freelist_count
2343
+ );
2344
+ return Math.max(0, pageCount - freelist) * this.pageSizeBytes();
2345
+ }
2346
+ invalidateEstimate() {
2347
+ this.quotaHeadroomBytes = 0;
2348
+ this.bytesSinceQuotaCheck = Number.POSITIVE_INFINITY;
2349
+ }
2350
+ recordAppendedBytes(bytes) {
2351
+ this.bytesSinceQuotaCheck += bytes;
2352
+ }
2353
+ assertWithinByteQuota(batchBytes) {
2354
+ if (this.maxBytes === void 0) return;
2355
+ if (!this.shouldMeasureQuota(batchBytes)) return;
2356
+ const currentBytes = this.aggregateLiveBytes();
2357
+ this.recordQuotaHeadroom(currentBytes);
2358
+ if (currentBytes + batchBytes <= this.maxBytes) return;
2359
+ throw new ChronicleStorageQuotaError({
2360
+ currentBytes,
2361
+ batchBytes,
2362
+ maxBytes: this.maxBytes,
2363
+ path: this.dbPath
2364
+ });
2365
+ }
2366
+ assertActualAllocationWithinQuota() {
2367
+ if (this.maxBytes === void 0) return;
2368
+ if (!this.shouldMeasureQuota()) return;
2369
+ const currentBytes = this.aggregateLiveBytes();
2370
+ this.recordQuotaHeadroom(currentBytes);
2371
+ if (currentBytes <= this.maxBytes) return;
2372
+ throw new ChronicleStorageQuotaError({
2373
+ currentBytes,
2374
+ batchBytes: 0,
2375
+ maxBytes: this.maxBytes,
2376
+ path: this.dbPath
2377
+ });
2378
+ }
2379
+ normalizeQuotaError(error) {
2380
+ if (this.maxBytes === void 0 || !(error instanceof Error)) return error;
2381
+ const sqliteError = error;
2382
+ const quotaExhausted = sqliteError.code === "SQLITE_FULL" || sqliteError.code === 13 || sqliteError.errcode === 13 || /database or disk is full/i.test(sqliteError.message);
2383
+ if (!quotaExhausted) return error;
2384
+ return new ChronicleStorageQuotaError({
2385
+ currentBytes: this.aggregateLiveBytes(),
2386
+ batchBytes: 0,
2387
+ maxBytes: this.maxBytes,
2388
+ path: this.dbPath
2389
+ });
2390
+ }
2391
+ shouldMeasureQuota(batchBytes = 0) {
2392
+ return this.bytesSinceQuotaCheck + batchBytes >= this.quotaHeadroomBytes / 2;
2393
+ }
2394
+ recordQuotaHeadroom(currentBytes) {
2395
+ this.quotaHeadroomBytes = Math.max(0, (this.maxBytes ?? 0) - currentBytes);
2396
+ this.bytesSinceQuotaCheck = 0;
2397
+ }
2398
+ };
2399
+
2400
+ // src/chronicle/sqlite-journal-schema.ts
2275
2401
  import { createRequire } from "node:module";
2276
- import * as path9 from "node:path";
2277
2402
 
2278
2403
  // src/utils/pid.ts
2279
2404
  function isPidAlive(pid) {
@@ -2289,6 +2414,118 @@ function isPidAlive(pid) {
2289
2414
  }
2290
2415
  }
2291
2416
 
2417
+ // src/utils/sqlite-warning.ts
2418
+ var SQLITE_EXPERIMENTAL_WARNING_RE = /sqlite is an experimental feature/i;
2419
+ function isSqliteExperimentalWarning(warning, rest) {
2420
+ const message = typeof warning === "string" ? warning : warning instanceof Error ? warning.message : "";
2421
+ const typeOrOptions = rest[0];
2422
+ const warningType = typeof warning === "string" ? typeof typeOrOptions === "string" ? typeOrOptions : typeof typeOrOptions === "object" && typeOrOptions !== null && "type" in typeOrOptions && typeof typeOrOptions.type === "string" ? typeOrOptions.type : "" : warning instanceof Error ? warning.name : "";
2423
+ const warningCode = typeof warning === "string" ? typeof typeOrOptions === "object" && typeOrOptions !== null && "code" in typeOrOptions && typeof typeOrOptions.code === "string" ? typeOrOptions.code : typeof rest[1] === "string" ? rest[1] : "" : warning instanceof Error && "code" in warning && typeof warning.code === "string" ? warning.code : "";
2424
+ return SQLITE_EXPERIMENTAL_WARNING_RE.test(message) && (warningType === "ExperimentalWarning" || warningCode === "ExperimentalWarning");
2425
+ }
2426
+ function withSqliteExperimentalWarningSuppressed(run) {
2427
+ const originalEmitWarning = process.emitWarning;
2428
+ const forwardWarning = originalEmitWarning.bind(process);
2429
+ process.emitWarning = ((warning, ...rest) => {
2430
+ if (isSqliteExperimentalWarning(warning, rest)) return;
2431
+ forwardWarning(warning, ...rest);
2432
+ });
2433
+ try {
2434
+ return run();
2435
+ } finally {
2436
+ process.emitWarning = originalEmitWarning;
2437
+ }
2438
+ }
2439
+
2440
+ // src/chronicle/sqlite-journal-schema.ts
2441
+ var CHRONICLE_SQLITE_FILE = "chronicle.sqlite";
2442
+ var LEGACY_JSONL_MIGRATION_KEY = "legacy-jsonl-v1";
2443
+ var LEGACY_JSONL_QUARANTINE_KEY = "legacy-jsonl-v1:quarantine";
2444
+ var LEGACY_JSONL_BOUNDARY_KEY = "legacy-jsonl-v1:boundary";
2445
+ var SCHEMA_VERSION = 2;
2446
+ var Ctor;
2447
+ function loadDatabaseSync() {
2448
+ if (Ctor) return Ctor;
2449
+ if (Ctor === null) throw new Error("node:sqlite is unavailable in this runtime");
2450
+ try {
2451
+ Ctor = withSqliteExperimentalWarningSuppressed(
2452
+ () => createRequire(import.meta.url)("node:sqlite").DatabaseSync
2453
+ );
2454
+ return Ctor;
2455
+ } catch (error) {
2456
+ Ctor = null;
2457
+ throw new Error(
2458
+ "The Chronicle journal needs Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
2459
+ );
2460
+ }
2461
+ }
2462
+ function projectEvent(event) {
2463
+ const occurredAt = event.occurredAt ?? event.observedAt;
2464
+ return {
2465
+ occurredAt,
2466
+ outcome: event.outcome ?? null,
2467
+ projectId: event.scope.projectId ?? null,
2468
+ sessionId: event.scope.sessionId ?? null,
2469
+ agentId: event.scope.agentId ?? null,
2470
+ taskId: event.scope.taskId ?? null,
2471
+ traceId: event.correlation?.traceId ?? null,
2472
+ logicalRequestId: event.correlation?.logicalRequestId ?? null,
2473
+ resourceKind: event.resource?.kind ?? null,
2474
+ resourceId: event.resource?.id ?? null,
2475
+ resourcePath: event.resource?.path ?? null,
2476
+ durationNs: event.durationNs ?? null
2477
+ };
2478
+ }
2479
+ function ensureChronicleSchema(db) {
2480
+ const version = db.prepare("PRAGMA user_version").get().user_version;
2481
+ db.exec(`
2482
+ CREATE TABLE IF NOT EXISTS events (
2483
+ day TEXT NOT NULL,
2484
+ sequence INTEGER NOT NULL,
2485
+ event_id TEXT NOT NULL UNIQUE,
2486
+ hash TEXT NOT NULL,
2487
+ previous_hash TEXT NOT NULL,
2488
+ occurred_at TEXT NOT NULL,
2489
+ event_type TEXT NOT NULL,
2490
+ outcome TEXT,
2491
+ project_id TEXT,
2492
+ session_id TEXT,
2493
+ agent_id TEXT,
2494
+ task_id TEXT,
2495
+ trace_id TEXT,
2496
+ logical_request_id TEXT,
2497
+ resource_kind TEXT,
2498
+ resource_id TEXT,
2499
+ resource_path TEXT,
2500
+ duration_ns TEXT,
2501
+ payload TEXT NOT NULL,
2502
+ PRIMARY KEY (day, sequence)
2503
+ );
2504
+ CREATE INDEX IF NOT EXISTS events_occurred_at ON events(occurred_at);
2505
+ CREATE INDEX IF NOT EXISTS events_type_outcome ON events(event_type, outcome);
2506
+ CREATE INDEX IF NOT EXISTS events_session ON events(session_id, day, sequence);
2507
+ CREATE INDEX IF NOT EXISTS events_trace ON events(trace_id);
2508
+ CREATE INDEX IF NOT EXISTS events_logical_request ON events(logical_request_id);
2509
+
2510
+ CREATE TABLE IF NOT EXISTS chain_checkpoint (
2511
+ day TEXT PRIMARY KEY,
2512
+ sequence INTEGER NOT NULL,
2513
+ hash TEXT NOT NULL
2514
+ );
2515
+
2516
+ CREATE TABLE IF NOT EXISTS chronicle_meta (
2517
+ key TEXT PRIMARY KEY,
2518
+ value TEXT NOT NULL
2519
+ );
2520
+ `);
2521
+ if (version < 2) {
2522
+ db.exec("DROP INDEX IF EXISTS events_resource_path");
2523
+ }
2524
+ if (version !== SCHEMA_VERSION) {
2525
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
2526
+ }
2527
+ }
2528
+
2292
2529
  // src/chronicle/sqlite-query.ts
2293
2530
  var MAX_LIMIT = 1e4;
2294
2531
  function encodeCursor2(cursor) {
@@ -2527,49 +2764,8 @@ var ChronicleSqliteQueryEngine = class {
2527
2764
  };
2528
2765
 
2529
2766
  // src/chronicle/sqlite-journal.ts
2530
- var CHRONICLE_SQLITE_FILE = "chronicle.sqlite";
2531
- var LEGACY_JSONL_MIGRATION_KEY = "legacy-jsonl-v1";
2532
- var LEGACY_JSONL_QUARANTINE_KEY = "legacy-jsonl-v1:quarantine";
2533
- var SCHEMA_VERSION = 2;
2534
- var SQLITE_FIXED_OVERHEAD_BYTES = 16 * 1024 * 1024;
2535
- var MIN_SQLITE_PAGE_BUDGET_BYTES = 64 * 1024;
2536
- var MAX_WAL_RESERVE_BYTES = 32 * 1024 * 1024;
2537
- var WAL_AUTOCHECKPOINT_PAGES = 2e3;
2538
- var WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024;
2539
2767
  var TRIM_SLACK_RATIO = 0.02;
2540
2768
  var MAX_TRIM_SLACK_EVENTS = 2e3;
2541
- var ChronicleStorageQuotaError = class extends Error {
2542
- currentBytes;
2543
- batchBytes;
2544
- maxBytes;
2545
- path;
2546
- constructor(details) {
2547
- super(
2548
- `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)`
2549
- );
2550
- this.name = "ChronicleStorageQuotaError";
2551
- this.currentBytes = details.currentBytes;
2552
- this.batchBytes = details.batchBytes;
2553
- this.maxBytes = details.maxBytes;
2554
- this.path = details.path;
2555
- }
2556
- };
2557
- var Ctor;
2558
- function loadDatabaseSync() {
2559
- if (Ctor) return Ctor;
2560
- if (Ctor === null) throw new Error("node:sqlite is unavailable in this runtime");
2561
- try {
2562
- Ctor = withSqliteExperimentalWarningSuppressed(
2563
- () => createRequire(import.meta.url)("node:sqlite").DatabaseSync
2564
- );
2565
- return Ctor;
2566
- } catch (error) {
2567
- Ctor = null;
2568
- throw new Error(
2569
- "The Chronicle journal needs Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
2570
- );
2571
- }
2572
- }
2573
2769
  var ChronicleSqliteJournal = class {
2574
2770
  db;
2575
2771
  dbPath;
@@ -2578,21 +2774,12 @@ var ChronicleSqliteJournal = class {
2578
2774
  idFactory;
2579
2775
  retentionDays;
2580
2776
  maxEvents;
2581
- maxBytes;
2582
2777
  retentionCheckIntervalMs;
2583
2778
  /** Overshoot allowed above `maxEvents` before eviction runs; see TRIM_SLACK_RATIO. */
2584
2779
  trimSlack;
2780
+ quotaManager;
2585
2781
  nextRetentionCheckAt = 0;
2586
2782
  retainedEventCount = 0;
2587
- /** Resolved lazily by `pageSizeBytes()`; constant for an open database. */
2588
- cachedPageSize;
2589
- /**
2590
- * Bytes the quota is known to have had spare at the last real measurement,
2591
- * and the bytes appended since. `assertWithinByteQuota` re-measures only once
2592
- * the second could plausibly have consumed the first — see its comment.
2593
- */
2594
- quotaHeadroomBytes = 0;
2595
- bytesSinceQuotaCheck = Number.POSITIVE_INFINITY;
2596
2783
  /**
2597
2784
  * Statements reused across appends.
2598
2785
  *
@@ -2640,7 +2827,6 @@ var ChronicleSqliteJournal = class {
2640
2827
  }
2641
2828
  this.retentionDays = options.retentionDays;
2642
2829
  this.maxEvents = options.maxEvents;
2643
- this.maxBytes = options.maxBytes;
2644
2830
  this.retentionCheckIntervalMs = options.retentionCheckIntervalMs ?? 60 * 60 * 1e3;
2645
2831
  this.trimSlack = this.maxEvents === void 0 ? 0 : Math.min(MAX_TRIM_SLACK_EVENTS, Math.floor(this.maxEvents * TRIM_SLACK_RATIO));
2646
2832
  const Database = loadDatabaseSync();
@@ -2649,8 +2835,9 @@ var ChronicleSqliteJournal = class {
2649
2835
  this.db.exec("PRAGMA synchronous = FULL");
2650
2836
  this.db.exec(`PRAGMA wal_autocheckpoint = ${WAL_AUTOCHECKPOINT_PAGES}`);
2651
2837
  this.db.exec(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES}`);
2652
- this.ensureSchema();
2653
- this.configureByteQuota();
2838
+ ensureChronicleSchema(this.db);
2839
+ this.quotaManager = new ChronicleQuotaManager(this.db, this.dbPath, options.maxBytes);
2840
+ this.quotaManager.configure();
2654
2841
  if (this.maxEvents !== void 0) {
2655
2842
  const row = this.db.prepare("SELECT COUNT(*) AS count FROM events").get();
2656
2843
  this.retainedEventCount = row.count;
@@ -2664,16 +2851,10 @@ var ChronicleSqliteJournal = class {
2664
2851
  return {
2665
2852
  ...this.counters,
2666
2853
  pendingEvents: 0,
2667
- // Retained at zero for wire compatibility: partitions do not exist here,
2668
- // but `ChronicleJournalStats` is part of the IPC health payload.
2669
2854
  partitionRolls: 0,
2670
2855
  ...this.lastBatchDurationMs !== void 0 ? { lastBatchDurationMs: this.lastBatchDurationMs } : {}
2671
2856
  };
2672
2857
  }
2673
- /**
2674
- * Writes are synchronous and transactional, so there is nothing buffered to
2675
- * flush. Kept to satisfy `ChronicleEventSink`.
2676
- */
2677
2858
  async flush() {
2678
2859
  return Promise.resolve();
2679
2860
  }
@@ -2681,13 +2862,6 @@ var ChronicleSqliteJournal = class {
2681
2862
  const [event] = await this.appendBatch([input]);
2682
2863
  return event;
2683
2864
  }
2684
- /**
2685
- * Append a batch as one transaction.
2686
- *
2687
- * The chain is computed in memory from a single anchor, so a partially
2688
- * applied batch would leave a hole in `sequence` that verification can never
2689
- * reconcile. Rollback plus anchor invalidation is what prevents that.
2690
- */
2691
2865
  async appendBatch(inputs) {
2692
2866
  if (inputs.length === 0) return [];
2693
2867
  const started = performance.now();
@@ -2720,7 +2894,7 @@ var ChronicleSqliteJournal = class {
2720
2894
  let retainedCountAfterCommit = this.retainedEventCount;
2721
2895
  try {
2722
2896
  this.db.exec("BEGIN IMMEDIATE");
2723
- this.assertWithinByteQuota(batchBytes);
2897
+ this.quotaManager.assertWithinByteQuota(batchBytes);
2724
2898
  const { insert } = this.preparedStatements();
2725
2899
  for (const [index, event] of events.entries()) {
2726
2900
  const row = projectEvent(event);
@@ -2749,7 +2923,7 @@ var ChronicleSqliteJournal = class {
2749
2923
  retainedCountAfterCommit = this.enforceEventLimitWithinTransaction(
2750
2924
  this.retainedEventCount + events.length
2751
2925
  );
2752
- this.assertActualAllocationWithinQuota();
2926
+ this.quotaManager.assertActualAllocationWithinQuota();
2753
2927
  this.db.exec("COMMIT");
2754
2928
  } catch (error) {
2755
2929
  try {
@@ -2757,15 +2931,15 @@ var ChronicleSqliteJournal = class {
2757
2931
  } catch {
2758
2932
  }
2759
2933
  this.anchors.clear();
2760
- this.invalidateQuotaEstimate();
2934
+ this.quotaManager.invalidateEstimate();
2761
2935
  this.counters.failedEvents += inputs.length;
2762
2936
  this.lastBatchDurationMs = performance.now() - started;
2763
- throw this.normalizeQuotaError(error);
2937
+ throw this.quotaManager.normalizeQuotaError(error);
2764
2938
  }
2765
2939
  this.anchors.set(day, previous);
2766
2940
  this.counters.persistedEvents += events.length;
2767
2941
  this.retainedEventCount = retainedCountAfterCommit;
2768
- this.bytesSinceQuotaCheck += batchBytes;
2942
+ this.quotaManager.recordAppendedBytes(batchBytes);
2769
2943
  this.lastBatchDurationMs = performance.now() - started;
2770
2944
  await this.enforceRetentionIfDue();
2771
2945
  return events;
@@ -2774,19 +2948,6 @@ var ChronicleSqliteJournal = class {
2774
2948
  const rows = this.db.prepare("SELECT payload FROM events ORDER BY day, sequence").all();
2775
2949
  return rows.map((row) => JSON.parse(row.payload));
2776
2950
  }
2777
- /**
2778
- * Walk every chain and prove none has been edited.
2779
- *
2780
- * Chronicle chains are scoped to a day, not to the journal: the JSONL writer
2781
- * anchors each `<day>.events.jsonl` family at `GENESIS_HASH` independently,
2782
- * so `sequence` restarts at 1 every day. Verification mirrors that — each day
2783
- * is validated on its own, and a break in one does not implicate the others.
2784
- *
2785
- * Three independent properties per day, because each catches a different
2786
- * failure: a dense `sequence` catches deletions from the middle, the
2787
- * `previousHash` link catches reordering, and re-hashing the payload catches
2788
- * an in-place edit that left the links intact.
2789
- */
2790
2951
  async verify() {
2791
2952
  let entries = 0;
2792
2953
  let lastSequence = 0;
@@ -2838,155 +2999,6 @@ var ChronicleSqliteJournal = class {
2838
2999
  } catch {
2839
3000
  }
2840
3001
  }
2841
- configureByteQuota() {
2842
- if (this.maxBytes === void 0) {
2843
- this.db.exec("PRAGMA max_page_count = 2147483646");
2844
- return;
2845
- }
2846
- const halfSplit = Math.floor((this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES) / 2);
2847
- const sidecarReserve = Math.min(MAX_WAL_RESERVE_BYTES, halfSplit);
2848
- const mainBudget = this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES - sidecarReserve;
2849
- if (mainBudget < MIN_SQLITE_PAGE_BUDGET_BYTES) {
2850
- throw new Error(
2851
- `maxBytes must be at least ${SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES}`
2852
- );
2853
- }
2854
- const maxPages = Math.max(1, Math.floor(mainBudget / this.pageSizeBytes()));
2855
- this.db.exec(`PRAGMA max_page_count = ${maxPages}`);
2856
- }
2857
- /**
2858
- * Bytes the journal actually occupies: live pages in the main database plus
2859
- * whatever the rollback/WAL sidecars currently hold.
2860
- *
2861
- * The quota MUST be measured this way rather than by `statSync` on the main
2862
- * database file. SQLite never returns freed pages to the filesystem — it
2863
- * parks them on the freelist and reuses them — so a database that once grew
2864
- * large keeps that size forever, even after retention and `maxEvents` have
2865
- * evicted almost everything. Measuring the file instead wedged the journal
2866
- * permanently: a 3.9 GB file whose live data was 243 MB failed a 512 MB
2867
- * quota on EVERY append, and the only escape (`chronicle compact`) refuses
2868
- * to run while the daemon is up — and the daemon respawns on demand. Live
2869
- * pages shrink when retention deletes rows, so the quota can recover on its
2870
- * own; allocated size cannot.
2871
- *
2872
- * The sidecars keep their on-disk size: they are transient and genuinely
2873
- * bounded by `configureByteQuota`'s half-of-budget split.
2874
- */
2875
- aggregateLiveBytes() {
2876
- let total = 0;
2877
- for (const file of [`${this.dbPath}-journal`, `${this.dbPath}-wal`, `${this.dbPath}-shm`]) {
2878
- try {
2879
- total += fs6.statSync(file).size;
2880
- } catch (error) {
2881
- if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
2882
- }
2883
- }
2884
- return total + this.mainDatabaseLiveBytes();
2885
- }
2886
- /** Live (non-freelist) pages of the main database, in bytes. */
2887
- mainDatabaseLiveBytes() {
2888
- const pageCount = Number(
2889
- this.db.prepare("PRAGMA page_count").get().page_count
2890
- );
2891
- const freelist = Number(
2892
- this.db.prepare("PRAGMA freelist_count").get().freelist_count
2893
- );
2894
- return Math.max(0, pageCount - freelist) * this.pageSizeBytes();
2895
- }
2896
- /** Page size never changes for an open database; resolve it once. */
2897
- pageSizeBytes() {
2898
- if (this.cachedPageSize === void 0) {
2899
- this.cachedPageSize = Number(
2900
- this.db.prepare("PRAGMA page_size").get().page_size
2901
- );
2902
- }
2903
- return this.cachedPageSize;
2904
- }
2905
- normalizeQuotaError(error) {
2906
- if (this.maxBytes === void 0 || !(error instanceof Error)) return error;
2907
- const sqliteError = error;
2908
- const quotaExhausted = sqliteError.code === "SQLITE_FULL" || sqliteError.code === 13 || sqliteError.errcode === 13 || /database or disk is full/i.test(sqliteError.message);
2909
- if (!quotaExhausted) return error;
2910
- return new ChronicleStorageQuotaError({
2911
- currentBytes: this.aggregateLiveBytes(),
2912
- batchBytes: 0,
2913
- maxBytes: this.maxBytes,
2914
- path: this.dbPath
2915
- });
2916
- }
2917
- /** Drop the cached headroom so the next quota check measures for real. */
2918
- invalidateQuotaEstimate() {
2919
- this.quotaHeadroomBytes = 0;
2920
- this.bytesSinceQuotaCheck = Number.POSITIVE_INFINITY;
2921
- }
2922
- assertActualAllocationWithinQuota() {
2923
- if (this.maxBytes === void 0) return;
2924
- if (!this.shouldMeasureQuota()) return;
2925
- const currentBytes = this.aggregateLiveBytes();
2926
- this.recordQuotaHeadroom(currentBytes);
2927
- if (currentBytes <= this.maxBytes) return;
2928
- throw new ChronicleStorageQuotaError({
2929
- currentBytes,
2930
- batchBytes: 0,
2931
- maxBytes: this.maxBytes,
2932
- path: this.dbPath
2933
- });
2934
- }
2935
- /**
2936
- * Refuse a batch that would take the journal past its byte quota.
2937
- *
2938
- * This is the *courteous* bound, not the enforced one: `max_page_count` makes
2939
- * SQLite itself fail the write with `SQLITE_FULL`, which `normalizeQuotaError`
2940
- * turns into the same {@link ChronicleStorageQuotaError}. Its only job is to
2941
- * produce that error before a doomed batch has been written, so it does not
2942
- * have to run on every append — and it should not, because measuring costs two
2943
- * PRAGMA round trips and three `statSync` calls, twice per transaction.
2944
- *
2945
- * So it measures adaptively instead of on a fixed cadence: after a real
2946
- * measurement it knows the spare bytes, and it may skip until the bytes
2947
- * appended since then could plausibly have consumed half of them. Far from the
2948
- * ceiling that is thousands of appends; close to it, every one. The bound
2949
- * tightens exactly where being wrong would matter.
2950
- */
2951
- assertWithinByteQuota(batchBytes) {
2952
- if (this.maxBytes === void 0) return;
2953
- if (!this.shouldMeasureQuota(batchBytes)) return;
2954
- const currentBytes = this.aggregateLiveBytes();
2955
- this.recordQuotaHeadroom(currentBytes);
2956
- if (currentBytes + batchBytes <= this.maxBytes) return;
2957
- throw new ChronicleStorageQuotaError({
2958
- currentBytes,
2959
- batchBytes,
2960
- maxBytes: this.maxBytes,
2961
- path: this.dbPath
2962
- });
2963
- }
2964
- /**
2965
- * Is the cached headroom still large enough to vouch for this batch?
2966
- *
2967
- * Half the headroom is the budget deliberately: rows carry index and page
2968
- * overhead beyond their payload bytes, so `bytesSinceQuotaCheck` understates
2969
- * real growth, and the factor absorbs that without needing to model it.
2970
- */
2971
- shouldMeasureQuota(batchBytes = 0) {
2972
- return this.bytesSinceQuotaCheck + batchBytes >= this.quotaHeadroomBytes / 2;
2973
- }
2974
- recordQuotaHeadroom(currentBytes) {
2975
- this.quotaHeadroomBytes = Math.max(0, (this.maxBytes ?? 0) - currentBytes);
2976
- this.bytesSinceQuotaCheck = 0;
2977
- }
2978
- /**
2979
- * Evict the oldest events once the row ceiling has been overshot by `trimSlack`.
2980
- *
2981
- * @param count Rows now in the table. The caller tracks this rather than the
2982
- * method querying it: `SELECT COUNT(*)` walks the whole table, and at the
2983
- * ceiling — where every long-lived journal lives — that was a full scan of
2984
- * `maxEvents` rows on every append, to learn a number the append path already
2985
- * knew. The count is re-derived from the database whenever it could have
2986
- * drifted (open, import, purge), never accumulated blindly across those.
2987
- * @param slack Overshoot tolerated before evicting; callers that run once,
2988
- * rather than per append, pass 0 to land on the exact ceiling.
2989
- */
2990
3002
  enforceEventLimitWithinTransaction(count, slack = this.trimSlack) {
2991
3003
  if (this.maxEvents === void 0 || count <= this.maxEvents + slack) return count;
2992
3004
  const excess = count - this.maxEvents;
@@ -3016,15 +3028,6 @@ var ChronicleSqliteJournal = class {
3016
3028
  throw error;
3017
3029
  }
3018
3030
  }
3019
- /**
3020
- * Drop events older than the retention window.
3021
- *
3022
- * Retention is day-granular and chains are day-scoped, so a purge removes
3023
- * whole chains rather than truncating one. That is why nothing needs to be
3024
- * checkpointed here: there is no surviving suffix left dangling without an
3025
- * anchor. Any checkpoint imported from a partially-purged legacy day family
3026
- * is dropped alongside its events.
3027
- */
3028
3031
  async purge(options) {
3029
3032
  const empty = {
3030
3033
  deletedCount: 0,
@@ -3062,25 +3065,9 @@ var ChronicleSqliteJournal = class {
3062
3065
  }
3063
3066
  this.anchors.clear();
3064
3067
  this.retainedEventCount = this.countRows();
3065
- this.invalidateQuotaEstimate();
3068
+ this.quotaManager.invalidateEstimate();
3066
3069
  return { ...empty, deletedCount: count };
3067
3070
  }
3068
- /**
3069
- * Run one day family's legacy import inside its own transaction.
3070
- *
3071
- * Deliberately separate from `appendBatch`: the append path *computes*
3072
- * `sequence`, `previousHash` and `hash`, while an import must carry them over
3073
- * untouched. Fusing the two would put a code path one refactor away from
3074
- * re-hashing historical events, which is the one change that silently
3075
- * destroys their tamper evidence.
3076
- *
3077
- * The transaction is scoped to a single family because chains are: `sequence`
3078
- * restarts at 1 each day, so one day's break says nothing about the next
3079
- * day's integrity. A whole-journal transaction made every future day hostage
3080
- * to the worst day on disk — one corrupt family and the daemon could never
3081
- * open its store again. The family is still all-or-nothing: a break rolls
3082
- * back that day entirely, so no partial chain is ever visible.
3083
- */
3084
3071
  async runFamilyImport(load) {
3085
3072
  const insert = this.db.prepare(
3086
3073
  `INSERT INTO events (
@@ -3096,8 +3083,8 @@ var ChronicleSqliteJournal = class {
3096
3083
  this.db.exec("BEGIN IMMEDIATE");
3097
3084
  let retainedCountAfterCommit = this.retainedEventCount;
3098
3085
  try {
3099
- this.invalidateQuotaEstimate();
3100
- this.assertWithinByteQuota(0);
3086
+ this.quotaManager.invalidateEstimate();
3087
+ this.quotaManager.assertWithinByteQuota(0);
3101
3088
  await load({
3102
3089
  insert: (day, event) => {
3103
3090
  const row = projectEvent(event);
@@ -3128,32 +3115,24 @@ var ChronicleSqliteJournal = class {
3128
3115
  }
3129
3116
  });
3130
3117
  retainedCountAfterCommit = this.enforceEventLimitWithinTransaction(this.countRows(), 0);
3131
- this.invalidateQuotaEstimate();
3132
- this.assertActualAllocationWithinQuota();
3118
+ this.quotaManager.invalidateEstimate();
3119
+ this.quotaManager.assertActualAllocationWithinQuota();
3133
3120
  this.db.exec("COMMIT");
3134
3121
  } catch (error) {
3135
3122
  try {
3136
3123
  this.db.exec("ROLLBACK");
3137
3124
  } catch {
3138
3125
  }
3139
- this.invalidateQuotaEstimate();
3140
- throw this.normalizeQuotaError(error);
3126
+ this.quotaManager.invalidateEstimate();
3127
+ throw this.quotaManager.normalizeQuotaError(error);
3141
3128
  } finally {
3142
3129
  this.anchors.clear();
3143
3130
  }
3144
3131
  this.retainedEventCount = retainedCountAfterCommit;
3145
3132
  }
3146
- /**
3147
- * A read engine over this journal's own connection.
3148
- *
3149
- * Sharing the connection rather than opening a second one keeps the
3150
- * single-writer guarantee intact and means a reader can never observe a
3151
- * half-applied batch: SQLite serialises statements on one handle.
3152
- */
3153
3133
  queryEngine(options) {
3154
3134
  return new ChronicleSqliteQueryEngine(this.db, options);
3155
3135
  }
3156
- /** Has the legacy JSONL import already run? */
3157
3136
  hasImportedLegacyJournal() {
3158
3137
  return this.readMeta(LEGACY_JSONL_MIGRATION_KEY) !== void 0;
3159
3138
  }
@@ -3163,15 +3142,6 @@ var ChronicleSqliteJournal = class {
3163
3142
  ON CONFLICT(key) DO UPDATE SET value = 'done'`
3164
3143
  ).run(LEGACY_JSONL_MIGRATION_KEY);
3165
3144
  }
3166
- /**
3167
- * Record the day families the import refused to move.
3168
- *
3169
- * Persisted rather than merely logged because the import runs once: after the
3170
- * marker is set nothing re-reads the JSONL, so this row is the only surviving
3171
- * evidence that a day was dropped. Health reports read it back to say
3172
- * "degraded, and here is exactly what is missing" instead of quietly serving
3173
- * a journal with a hole in it.
3174
- */
3175
3145
  recordQuarantinedFamilies(families) {
3176
3146
  if (families.length === 0) return;
3177
3147
  this.db.prepare(
@@ -3179,20 +3149,31 @@ var ChronicleSqliteJournal = class {
3179
3149
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`
3180
3150
  ).run(LEGACY_JSONL_QUARANTINE_KEY, JSON.stringify(families));
3181
3151
  }
3182
- /**
3183
- * Does this day already hold rows?
3184
- *
3185
- * Each family commits on its own, so an import interrupted between families
3186
- * leaves a database that is complete for the days it reached. `(day,
3187
- * sequence)` is the primary key, so re-inserting one of those days would
3188
- * abort on a constraint violation rather than start over — this is what lets
3189
- * the next run resume at the first day it never got to.
3190
- */
3152
+ /** Record per-partition-file JSONL byte offsets at import time (merged — the
3153
+ * import persists per family, so a crash mid-run keeps earlier boundaries).
3154
+ * The metrics ingester folds only bytes beyond these boundaries after the
3155
+ * migration, so post-migration appends (the jsonl-store fallback) are
3156
+ * ingested while the migrated bytes are never re-counted. Files absent from
3157
+ * the map were quarantined (or created post-migration) their events live
3158
+ * only in JSONL. */
3159
+ recordLegacyJsonlBoundary(boundary) {
3160
+ let merged = boundary;
3161
+ try {
3162
+ const row = this.db.prepare("SELECT value FROM chronicle_meta WHERE key = ?").get(LEGACY_JSONL_BOUNDARY_KEY);
3163
+ if (row?.value) {
3164
+ merged = { ...JSON.parse(row.value), ...boundary };
3165
+ }
3166
+ } catch {
3167
+ }
3168
+ this.db.prepare(
3169
+ `INSERT INTO chronicle_meta (key, value) VALUES (?, ?)
3170
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
3171
+ ).run(LEGACY_JSONL_BOUNDARY_KEY, JSON.stringify(merged));
3172
+ }
3191
3173
  hasImportedDay(day) {
3192
3174
  const row = this.db.prepare("SELECT 1 AS present FROM events WHERE day = ? LIMIT 1").get(day);
3193
3175
  return row !== void 0;
3194
3176
  }
3195
- /** Day families the legacy import refused to move, oldest first. */
3196
3177
  quarantinedFamilies() {
3197
3178
  const raw = this.readMeta(LEGACY_JSONL_QUARANTINE_KEY);
3198
3179
  if (!raw) return [];
@@ -3203,7 +3184,6 @@ var ChronicleSqliteJournal = class {
3203
3184
  return [];
3204
3185
  }
3205
3186
  }
3206
- // ─── internals ────────────────────────────────────────────────────────────
3207
3187
  preparedStatements() {
3208
3188
  this.statements ??= {
3209
3189
  insert: this.db.prepare(
@@ -3227,7 +3207,6 @@ var ChronicleSqliteJournal = class {
3227
3207
  };
3228
3208
  return this.statements;
3229
3209
  }
3230
- /** Rows currently in the table, straight from the database. */
3231
3210
  countRows() {
3232
3211
  return this.db.prepare("SELECT COUNT(*) AS count FROM events").get().count;
3233
3212
  }
@@ -3238,16 +3217,10 @@ var ChronicleSqliteJournal = class {
3238
3217
  readCheckpoint(day) {
3239
3218
  return this.db.prepare("SELECT sequence, hash FROM chain_checkpoint WHERE day = ?").get(day);
3240
3219
  }
3241
- /** Days that currently hold at least one event, oldest first. */
3242
3220
  days() {
3243
3221
  const rows = this.db.prepare("SELECT DISTINCT day FROM events ORDER BY day").all();
3244
3222
  return rows.map((row) => row.day);
3245
3223
  }
3246
- /**
3247
- * Resolve the chain head, in the same precedence the JSONL journal uses: the
3248
- * newest event, else the retention checkpoint (every event before it was
3249
- * purged), else genesis.
3250
- */
3251
3224
  readAnchor(day) {
3252
3225
  const cached = this.anchors.get(day);
3253
3226
  if (cached) return cached;
@@ -3256,75 +3229,10 @@ var ChronicleSqliteJournal = class {
3256
3229
  this.anchors.set(day, anchor);
3257
3230
  return anchor;
3258
3231
  }
3259
- ensureSchema() {
3260
- const version = this.db.prepare("PRAGMA user_version").get().user_version;
3261
- this.db.exec(`
3262
- CREATE TABLE IF NOT EXISTS events (
3263
- day TEXT NOT NULL,
3264
- sequence INTEGER NOT NULL,
3265
- event_id TEXT NOT NULL UNIQUE,
3266
- hash TEXT NOT NULL,
3267
- previous_hash TEXT NOT NULL,
3268
- occurred_at TEXT NOT NULL,
3269
- event_type TEXT NOT NULL,
3270
- outcome TEXT,
3271
- project_id TEXT,
3272
- session_id TEXT,
3273
- agent_id TEXT,
3274
- task_id TEXT,
3275
- trace_id TEXT,
3276
- logical_request_id TEXT,
3277
- resource_kind TEXT,
3278
- resource_id TEXT,
3279
- resource_path TEXT,
3280
- duration_ns TEXT,
3281
- payload TEXT NOT NULL,
3282
- PRIMARY KEY (day, sequence)
3283
- );
3284
- CREATE INDEX IF NOT EXISTS events_occurred_at ON events(occurred_at);
3285
- CREATE INDEX IF NOT EXISTS events_type_outcome ON events(event_type, outcome);
3286
- CREATE INDEX IF NOT EXISTS events_session ON events(session_id, day, sequence);
3287
- CREATE INDEX IF NOT EXISTS events_trace ON events(trace_id);
3288
- CREATE INDEX IF NOT EXISTS events_logical_request ON events(logical_request_id);
3289
-
3290
- CREATE TABLE IF NOT EXISTS chain_checkpoint (
3291
- day TEXT PRIMARY KEY,
3292
- sequence INTEGER NOT NULL,
3293
- hash TEXT NOT NULL
3294
- );
3295
-
3296
- CREATE TABLE IF NOT EXISTS chronicle_meta (
3297
- key TEXT PRIMARY KEY,
3298
- value TEXT NOT NULL
3299
- );
3300
- `);
3301
- if (version < 2) {
3302
- this.db.exec("DROP INDEX IF EXISTS events_resource_path");
3303
- }
3304
- if (version !== SCHEMA_VERSION) {
3305
- this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
3306
- }
3307
- }
3308
3232
  };
3309
- function projectEvent(event) {
3310
- const occurredAt = event.occurredAt ?? event.observedAt;
3311
- return {
3312
- occurredAt,
3313
- outcome: event.outcome ?? null,
3314
- projectId: event.scope.projectId ?? null,
3315
- sessionId: event.scope.sessionId ?? null,
3316
- agentId: event.scope.agentId ?? null,
3317
- taskId: event.scope.taskId ?? null,
3318
- traceId: event.correlation?.traceId ?? null,
3319
- logicalRequestId: event.correlation?.logicalRequestId ?? null,
3320
- resourceKind: event.resource?.kind ?? null,
3321
- resourceId: event.resource?.id ?? null,
3322
- resourcePath: event.resource?.path ?? null,
3323
- durationNs: event.durationNs ?? null
3324
- };
3325
- }
3326
3233
 
3327
- // src/chronicle/metrics-store.ts
3234
+ // src/chronicle/metrics-schema.ts
3235
+ import { createRequire as createRequire2 } from "node:module";
3328
3236
  var SCHEMA_VERSION2 = 3;
3329
3237
  var READ_CHUNK_BYTES = 1024 * 1024;
3330
3238
  var SQLITE_SOURCE_PREFIX = "sqlite:";
@@ -3364,26 +3272,155 @@ function isChronicleMetricsAvailable() {
3364
3272
  return false;
3365
3273
  }
3366
3274
  }
3367
- var ChronicleMetricsStore = class _ChronicleMetricsStore {
3275
+ function ensureMetricsSchema(db) {
3276
+ const version = db.prepare("PRAGMA user_version").get().user_version;
3277
+ if (version !== 0 && version !== SCHEMA_VERSION2) {
3278
+ db.exec(
3279
+ "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;"
3280
+ );
3281
+ }
3282
+ db.exec(`
3283
+ CREATE TABLE IF NOT EXISTS ingest_state (
3284
+ file TEXT PRIMARY KEY,
3285
+ bytes INTEGER NOT NULL
3286
+ );
3287
+ CREATE TABLE IF NOT EXISTS provider_daily (
3288
+ day TEXT NOT NULL,
3289
+ provider_id TEXT NOT NULL,
3290
+ model_id TEXT NOT NULL,
3291
+ attempts INTEGER NOT NULL DEFAULT 0,
3292
+ completed INTEGER NOT NULL DEFAULT 0,
3293
+ failed INTEGER NOT NULL DEFAULT 0,
3294
+ retries INTEGER NOT NULL DEFAULT 0,
3295
+ fallbacks INTEGER NOT NULL DEFAULT 0,
3296
+ input_tokens INTEGER NOT NULL DEFAULT 0,
3297
+ output_tokens INTEGER NOT NULL DEFAULT 0,
3298
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3299
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
3300
+ duration_ms_total REAL NOT NULL DEFAULT 0,
3301
+ duration_ms_max REAL NOT NULL DEFAULT 0,
3302
+ duration_count INTEGER NOT NULL DEFAULT 0,
3303
+ PRIMARY KEY (day, provider_id, model_id)
3304
+ );
3305
+ CREATE TABLE IF NOT EXISTS task_outcomes (
3306
+ task_id TEXT PRIMARY KEY,
3307
+ run_id TEXT NOT NULL DEFAULT '',
3308
+ board_id TEXT NOT NULL DEFAULT '',
3309
+ session_id TEXT NOT NULL DEFAULT '',
3310
+ agent_id TEXT NOT NULL DEFAULT '',
3311
+ status TEXT NOT NULL DEFAULT 'started',
3312
+ started_at TEXT,
3313
+ ended_at TEXT,
3314
+ duration_ms REAL,
3315
+ retries INTEGER NOT NULL DEFAULT 0,
3316
+ verification_failures INTEGER NOT NULL DEFAULT 0
3317
+ );
3318
+ CREATE TABLE IF NOT EXISTS file_lineage (
3319
+ event_id TEXT PRIMARY KEY,
3320
+ path TEXT NOT NULL,
3321
+ path_key TEXT NOT NULL,
3322
+ operation TEXT NOT NULL,
3323
+ occurred_at TEXT NOT NULL,
3324
+ session_id TEXT NOT NULL DEFAULT '',
3325
+ agent_id TEXT NOT NULL DEFAULT '',
3326
+ task_id TEXT NOT NULL DEFAULT '',
3327
+ board_id TEXT NOT NULL DEFAULT '',
3328
+ run_id TEXT NOT NULL DEFAULT '',
3329
+ tool_name TEXT NOT NULL DEFAULT '',
3330
+ provider_id TEXT NOT NULL DEFAULT '',
3331
+ model_id TEXT NOT NULL DEFAULT '',
3332
+ source TEXT NOT NULL DEFAULT ''
3333
+ );
3334
+ CREATE INDEX IF NOT EXISTS idx_file_lineage_path ON file_lineage(path_key, occurred_at);
3335
+ CREATE INDEX IF NOT EXISTS idx_file_lineage_task ON file_lineage(task_id);
3336
+ CREATE TABLE IF NOT EXISTS token_cost (
3337
+ scope_key TEXT PRIMARY KEY,
3338
+ day TEXT NOT NULL,
3339
+ occurred_at TEXT NOT NULL,
3340
+ sequence INTEGER NOT NULL,
3341
+ cost REAL NOT NULL
3342
+ );
3343
+ CREATE TABLE IF NOT EXISTS daily_counters (
3344
+ day TEXT PRIMARY KEY,
3345
+ tool_calls INTEGER NOT NULL DEFAULT 0,
3346
+ completed_tools INTEGER NOT NULL DEFAULT 0,
3347
+ failed_tools INTEGER NOT NULL DEFAULT 0,
3348
+ tool_duration_ms_total REAL NOT NULL DEFAULT 0,
3349
+ tool_duration_ms_max REAL NOT NULL DEFAULT 0,
3350
+ tool_duration_count INTEGER NOT NULL DEFAULT 0,
3351
+ processes INTEGER NOT NULL DEFAULT 0,
3352
+ failed_processes INTEGER NOT NULL DEFAULT 0,
3353
+ file_events_all INTEGER NOT NULL DEFAULT 0,
3354
+ decisions INTEGER NOT NULL DEFAULT 0,
3355
+ escalations INTEGER NOT NULL DEFAULT 0,
3356
+ agent_events INTEGER NOT NULL DEFAULT 0,
3357
+ failures INTEGER NOT NULL DEFAULT 0,
3358
+ cancellations INTEGER NOT NULL DEFAULT 0
3359
+ );
3360
+ CREATE TABLE IF NOT EXISTS family_daily (
3361
+ day TEXT NOT NULL,
3362
+ family TEXT NOT NULL,
3363
+ count INTEGER NOT NULL DEFAULT 0,
3364
+ failure_count INTEGER NOT NULL DEFAULT 0,
3365
+ PRIMARY KEY (day, family)
3366
+ );
3367
+ CREATE TABLE IF NOT EXISTS agent_daily (day TEXT NOT NULL, agent_id TEXT NOT NULL, PRIMARY KEY (day, agent_id));
3368
+ CREATE TABLE IF NOT EXISTS logical_request_daily (day TEXT NOT NULL, logical_request_id TEXT NOT NULL, PRIMARY KEY (day, logical_request_id));
3369
+ CREATE TABLE IF NOT EXISTS file_seen_daily (day TEXT NOT NULL, path_key TEXT NOT NULL, PRIMARY KEY (day, path_key));
3370
+ PRAGMA user_version = ${SCHEMA_VERSION2};
3371
+ `);
3372
+ }
3373
+ function eventDay(event) {
3374
+ return (event.occurredAt ?? event.observedAt).slice(0, 10);
3375
+ }
3376
+ function durationMs2(event) {
3377
+ const value = Number(event.durationNs ?? 0) / 1e6;
3378
+ return Number.isFinite(value) && value > 0 ? value : 0;
3379
+ }
3380
+ function numberOrDuration(event, attributes) {
3381
+ const explicit = attributes.durationMs;
3382
+ if (typeof explicit === "number" && Number.isFinite(explicit)) return explicit;
3383
+ return durationMs2(event);
3384
+ }
3385
+ function numberAt2(event, dotPath) {
3386
+ const value = readPath2(event.attributes ?? {}, dotPath);
3387
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
3388
+ }
3389
+ function readPath2(value, key) {
3390
+ return key.split(".").reduce(
3391
+ (current, part) => current && typeof current === "object" ? current[part] : void 0,
3392
+ value
3393
+ );
3394
+ }
3395
+ function stringAt(record, key) {
3396
+ const value = record[key];
3397
+ return typeof value === "string" && value.length > 0 ? value : void 0;
3398
+ }
3399
+ function asString(value) {
3400
+ return typeof value === "string" && value.length > 0 ? value : void 0;
3401
+ }
3402
+ function clampLimit(limit, fallback) {
3403
+ if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) return fallback;
3404
+ return Math.min(Math.floor(limit), 1e4);
3405
+ }
3406
+ function normalizeKey(value) {
3407
+ return value.replaceAll("\\", "/");
3408
+ }
3409
+ function normalizePathKey(value) {
3410
+ return value.replaceAll("\\", "/").replace(/^\.\//, "").toLowerCase();
3411
+ }
3412
+
3413
+ // src/chronicle/metrics-ingest.ts
3414
+ var REBUILD_MARKER_KEY = `${SQLITE_SOURCE_PREFIX}rebuild_done`;
3415
+ var ChronicleMetricsIngester = class {
3416
+ constructor(db, directory, dbPath) {
3417
+ this.db = db;
3418
+ this.directory = directory;
3419
+ this.dbPath = dbPath;
3420
+ }
3368
3421
  db;
3369
3422
  directory;
3370
3423
  dbPath;
3371
- constructor(directory) {
3372
- this.directory = path10.resolve(directory);
3373
- this.dbPath = path10.join(this.directory, "metrics.db");
3374
- const Database = loadDatabaseSync2();
3375
- this.db = new Database(this.dbPath);
3376
- this.db.exec("PRAGMA journal_mode = WAL");
3377
- this.ensureSchema();
3378
- }
3379
- static open(chronicleDirectory) {
3380
- return new _ChronicleMetricsStore(chronicleDirectory);
3381
- }
3382
- close() {
3383
- this.db.close();
3384
- }
3385
- /** Incrementally ingest journal bytes appended since the last refresh.
3386
- * Safe across processes: guarded by a file lock on the database path. */
3387
3424
  async refresh() {
3388
3425
  const result = {
3389
3426
  ingestedEvents: 0,
@@ -3394,377 +3431,106 @@ var ChronicleMetricsStore = class _ChronicleMetricsStore {
3394
3431
  await withFileLock(this.dbPath, async () => {
3395
3432
  const files = await findChroniclePartitions(this.directory);
3396
3433
  const offsets = this.loadOffsets();
3397
- for (const file of files) {
3398
- const key = normalizeKey(path10.relative(this.directory, file));
3399
- const consumed = offsets.get(key) ?? 0;
3400
- const ingested = await this.ingestFile(file, key, consumed, result);
3401
- if (ingested) result.sourceFiles++;
3434
+ const journalPath = path10.join(this.directory, CHRONICLE_SQLITE_FILE);
3435
+ let source = null;
3436
+ try {
3437
+ source = new (loadDatabaseSync2())(journalPath, { readOnly: true });
3438
+ } catch {
3402
3439
  }
3403
- this.pruneOffsets(files);
3404
- this.ingestSqliteJournal(offsets, result);
3405
- });
3406
- return result;
3407
- }
3408
- providerDaily(options = {}) {
3409
- const clauses = [];
3410
- const params = [];
3411
- if (options.from) {
3412
- clauses.push("day >= ?");
3413
- params.push(options.from.slice(0, 10));
3414
- }
3415
- if (options.to) {
3416
- clauses.push("day <= ?");
3417
- params.push(options.to.slice(0, 10));
3418
- }
3419
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
3420
- const rows = this.db.prepare(
3421
- `SELECT day, provider_id, model_id, attempts, completed, failed, retries, fallbacks,
3422
- input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
3423
- duration_ms_total, duration_ms_max, duration_count
3424
- FROM provider_daily${where} ORDER BY day DESC, provider_id, model_id`
3425
- ).all(...params);
3426
- return rows.map((row) => ({
3427
- day: String(row.day),
3428
- providerId: String(row.provider_id),
3429
- modelId: String(row.model_id),
3430
- attempts: Number(row.attempts),
3431
- completed: Number(row.completed),
3432
- failed: Number(row.failed),
3433
- retries: Number(row.retries),
3434
- fallbacks: Number(row.fallbacks),
3435
- inputTokens: Number(row.input_tokens),
3436
- outputTokens: Number(row.output_tokens),
3437
- cacheReadTokens: Number(row.cache_read_tokens),
3438
- cacheWriteTokens: Number(row.cache_write_tokens),
3439
- avgDurationMs: Number(row.duration_count) > 0 ? Number(row.duration_ms_total) / Number(row.duration_count) : 0,
3440
- maxDurationMs: Number(row.duration_ms_max)
3441
- }));
3440
+ try {
3441
+ const migrated = source !== null && this.legacyJsonlConsumed(source);
3442
+ const boundary = migrated && source !== null ? this.loadJsonlBoundary(source) : null;
3443
+ const rebuilt = migrated && this.needsSqliteRebuild(offsets);
3444
+ if (rebuilt && source !== null) {
3445
+ this.rebuildFromSqliteJournal(source, result);
3446
+ }
3447
+ if (!migrated || boundary !== null) {
3448
+ for (const file of files) {
3449
+ const key = normalizeKey(path10.relative(this.directory, file));
3450
+ let base;
3451
+ if (!migrated) {
3452
+ base = offsets.get(key) ?? 0;
3453
+ } else if (boundary !== null && boundary.has(key)) {
3454
+ base = Math.max(offsets.get(key) ?? 0, boundary.get(key));
3455
+ } else {
3456
+ base = rebuilt ? 0 : offsets.get(key) ?? 0;
3457
+ }
3458
+ const ingested = await this.ingestFile(file, key, base, result);
3459
+ if (ingested) result.sourceFiles++;
3460
+ }
3461
+ }
3462
+ this.pruneOffsets(files);
3463
+ if (source !== null && !rebuilt) {
3464
+ this.ingestSqliteJournal(source, offsets, result, false);
3465
+ if (migrated) this.ensureRebuildMarker();
3466
+ }
3467
+ } finally {
3468
+ if (source !== null) source.close();
3469
+ }
3470
+ });
3471
+ return result;
3442
3472
  }
3443
- taskOutcomes(options = {}) {
3444
- const clauses = [];
3445
- const params = [];
3446
- if (options.runId) {
3447
- clauses.push("t.run_id = ?");
3448
- params.push(options.runId);
3449
- }
3450
- if (options.boardId) {
3451
- clauses.push("t.board_id = ?");
3452
- params.push(options.boardId);
3453
- }
3454
- if (options.sessionId) {
3455
- clauses.push("t.session_id = ?");
3456
- params.push(options.sessionId);
3457
- }
3458
- if (options.status) {
3459
- clauses.push("t.status = ?");
3460
- params.push(options.status);
3461
- }
3462
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
3463
- params.push(clampLimit(options.limit, 100));
3464
- const rows = this.db.prepare(
3465
- `SELECT t.*, (SELECT COUNT(*) FROM file_lineage f WHERE f.task_id = t.task_id) AS files_touched
3466
- FROM task_outcomes t${where}
3467
- ORDER BY COALESCE(t.started_at, '') DESC LIMIT ?`
3468
- ).all(...params);
3469
- return rows.map((row) => ({
3470
- taskId: String(row.task_id),
3471
- runId: String(row.run_id),
3472
- boardId: String(row.board_id),
3473
- sessionId: String(row.session_id),
3474
- agentId: String(row.agent_id),
3475
- status: String(row.status),
3476
- startedAt: row.started_at === null ? null : String(row.started_at),
3477
- endedAt: row.ended_at === null ? null : String(row.ended_at),
3478
- durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
3479
- retries: Number(row.retries),
3480
- verificationFailures: Number(row.verification_failures),
3481
- filesTouched: Number(row.files_touched)
3482
- }));
3473
+ loadOffsets() {
3474
+ const rows = this.db.prepare("SELECT file, bytes FROM ingest_state").all();
3475
+ return new Map(rows.map((row) => [row.file, Number(row.bytes)]));
3483
3476
  }
3484
- fileLineage(options = {}) {
3485
- const clauses = [];
3486
- const params = [];
3487
- if (options.path) {
3488
- clauses.push("path_key = ?");
3489
- params.push(normalizePathKey(options.path));
3490
- }
3491
- if (options.paths) {
3492
- const pathKeys = [...new Set(options.paths.map(normalizePathKey))];
3493
- if (pathKeys.length === 0) return [];
3494
- clauses.push(`path_key IN (${pathKeys.map(() => "?").join(",")})`);
3495
- params.push(...pathKeys);
3496
- }
3497
- if (options.taskId) {
3498
- clauses.push("task_id = ?");
3499
- params.push(options.taskId);
3500
- }
3501
- if (options.boardId) {
3502
- clauses.push("board_id = ?");
3503
- params.push(options.boardId);
3504
- }
3505
- if (options.sessionId) {
3506
- clauses.push("session_id = ?");
3507
- params.push(options.sessionId);
3477
+ /** True once the legacy JSONL journal was imported into the SQLite journal
3478
+ * (`importLegacyChronicleJournal` records the marker in `chronicle_meta`).
3479
+ * After that point the SQLite `events` table already holds every JSONL
3480
+ * event, so folding the leftover partition files again would double-count
3481
+ * every migrated event in the aggregates. `source` is the already-opened
3482
+ * read-only journal handle (null when no SQLite journal exists yet). */
3483
+ legacyJsonlConsumed(source) {
3484
+ if (source === null) return false;
3485
+ try {
3486
+ const row = source.prepare("SELECT value FROM chronicle_meta WHERE key = ?").get(LEGACY_JSONL_MIGRATION_KEY);
3487
+ return row?.value !== void 0;
3488
+ } catch {
3489
+ return false;
3508
3490
  }
3509
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
3510
- params.push(clampLimit(options.limit, 200));
3511
- const projection = `path, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
3512
- tool_name, provider_id, model_id, source`;
3513
- const sql = options.latestPerPath ? `SELECT ${projection} FROM (
3514
- SELECT ${projection}, ROW_NUMBER() OVER (
3515
- PARTITION BY path_key ORDER BY occurred_at DESC, event_id DESC
3516
- ) AS path_rank
3517
- FROM file_lineage${where}
3518
- ) WHERE path_rank = 1 ORDER BY occurred_at DESC LIMIT ?` : `SELECT ${projection}
3519
- FROM file_lineage${where} ORDER BY occurred_at DESC LIMIT ?`;
3520
- const rows = this.db.prepare(sql).all(...params);
3521
- return rows.map((row) => ({
3522
- path: row.path,
3523
- operation: row.operation,
3524
- occurredAt: row.occurred_at,
3525
- sessionId: row.session_id,
3526
- agentId: row.agent_id,
3527
- taskId: row.task_id,
3528
- boardId: row.board_id,
3529
- runId: row.run_id,
3530
- toolName: row.tool_name,
3531
- providerId: row.provider_id,
3532
- modelId: row.model_id,
3533
- source: row.source
3534
- }));
3535
3491
  }
3536
- summary() {
3537
- const provider = this.db.prepare(
3538
- "SELECT COALESCE(SUM(attempts),0) a, COALESCE(SUM(completed),0) c, COALESCE(SUM(failed),0) f FROM provider_daily"
3539
- ).get();
3540
- const tasks = {};
3541
- for (const row of this.db.prepare("SELECT status, COUNT(*) n FROM task_outcomes GROUP BY status").all()) {
3542
- tasks[row.status] = Number(row.n);
3492
+ /** Per-partition-file JSONL byte offsets recorded by
3493
+ * `importLegacyChronicleJournal` at import time (keyed by the same relative
3494
+ * path the fold uses). Files absent from the map were quarantined (or
3495
+ * created after the migration) — their events live only in JSONL. */
3496
+ loadJsonlBoundary(source) {
3497
+ try {
3498
+ const row = source.prepare("SELECT value FROM chronicle_meta WHERE key = ?").get(LEGACY_JSONL_BOUNDARY_KEY);
3499
+ if (!row?.value) return null;
3500
+ const parsed = JSON.parse(row.value);
3501
+ return new Map(Object.entries(parsed));
3502
+ } catch {
3503
+ return null;
3543
3504
  }
3544
- const files = this.db.prepare("SELECT COUNT(*) n, COUNT(DISTINCT path) p FROM file_lineage").get();
3545
- const cost = this.db.prepare("SELECT COALESCE(SUM(cost),0) c FROM token_cost").get();
3546
- const terminal = Number(provider.c) + Number(provider.f);
3547
- return {
3548
- providers: {
3549
- attempts: Number(provider.a),
3550
- completed: Number(provider.c),
3551
- failed: Number(provider.f),
3552
- successRate: terminal > 0 ? Number(provider.c) / terminal : 0
3553
- },
3554
- tasks,
3555
- files: { mutations: Number(files.n), uniquePaths: Number(files.p) },
3556
- estimatedCostUsd: Number(cost.c)
3557
- };
3558
3505
  }
3559
- /**
3560
- * A `ChronicleSummary` for the default/unfiltered dashboard view — only
3561
- * `from`/`to` (day-precision) narrow it. Any other ad hoc filter (text,
3562
- * path, provider, model, session) can't be answered from these
3563
- * fixed-dimension aggregates; callers must fall back to query.ts's
3564
- * raw-scan summary for those.
3565
- */
3566
- defaultSummary(options = {}) {
3567
- const fromDay = options.from?.slice(0, 10);
3568
- const toDay = options.to?.slice(0, 10);
3569
- const dayFilter = (column) => {
3570
- const clauses = [];
3571
- const params = [];
3572
- if (fromDay) {
3573
- clauses.push(`${column} >= ?`);
3574
- params.push(fromDay);
3575
- }
3576
- if (toDay) {
3577
- clauses.push(`${column} <= ?`);
3578
- params.push(toDay);
3579
- }
3580
- return { where: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "", params };
3581
- };
3582
- const providerRange = dayFilter("day");
3583
- const provider = this.db.prepare(
3584
- `SELECT COALESCE(SUM(attempts),0) attempts, COALESCE(SUM(completed),0) completed, COALESCE(SUM(failed),0) failed,
3585
- COALESCE(SUM(retries),0) retries, COALESCE(SUM(fallbacks),0) fallbacks,
3586
- COUNT(DISTINCT provider_id) providers, COUNT(DISTINCT model_id) models,
3587
- COALESCE(SUM(input_tokens),0) inputTokens, COALESCE(SUM(output_tokens),0) outputTokens,
3588
- COALESCE(SUM(cache_read_tokens),0) cacheReadTokens, COALESCE(SUM(cache_write_tokens),0) cacheWriteTokens,
3589
- COALESCE(SUM(duration_ms_total),0) durationTotal, COALESCE(MAX(duration_ms_max),0) durationMax,
3590
- COALESCE(SUM(duration_count),0) durationCount
3591
- FROM provider_daily${providerRange.where}`
3592
- ).get(...providerRange.params);
3593
- const counterRange = dayFilter("day");
3594
- const counters = this.db.prepare(
3595
- `SELECT COALESCE(SUM(tool_calls),0) toolCalls, COALESCE(SUM(completed_tools),0) completedTools,
3596
- COALESCE(SUM(failed_tools),0) failedTools, COALESCE(SUM(tool_duration_ms_total),0) toolDurationTotal,
3597
- COALESCE(SUM(tool_duration_count),0) toolDurationCount, COALESCE(SUM(processes),0) processes,
3598
- COALESCE(SUM(failed_processes),0) failedProcesses, COALESCE(SUM(file_events_all),0) fileEvents,
3599
- COALESCE(SUM(decisions),0) decisions, COALESCE(SUM(escalations),0) escalations,
3600
- COALESCE(SUM(agent_events),0) agentEvents, COALESCE(SUM(failures),0) failures,
3601
- COALESCE(SUM(cancellations),0) cancellations
3602
- FROM daily_counters${counterRange.where}`
3603
- ).get(...counterRange.params);
3604
- const familyRange = dayFilter("day");
3605
- const familyRows = this.db.prepare(`SELECT family, count, failure_count FROM family_daily${familyRange.where}`).all(...familyRange.params);
3606
- const families = { ...EMPTY_FAMILIES };
3607
- const failuresByFamily = { ...EMPTY_FAMILIES };
3608
- for (const row of familyRows) {
3609
- const family = row.family;
3610
- families[family] = Number(row.count);
3611
- failuresByFamily[family] = Number(row.failure_count);
3612
- }
3613
- const agentRange = dayFilter("day");
3614
- const uniqueAgents = this.db.prepare(`SELECT COUNT(DISTINCT agent_id) n FROM agent_daily${agentRange.where}`).get(...agentRange.params).n;
3615
- const requestRange = dayFilter("day");
3616
- const logicalRequests = this.db.prepare(
3617
- `SELECT COUNT(DISTINCT logical_request_id) n FROM logical_request_daily${requestRange.where}`
3618
- ).get(...requestRange.params).n;
3619
- const fileRange = dayFilter("day");
3620
- const uniqueFiles = this.db.prepare(`SELECT COUNT(DISTINCT path_key) n FROM file_seen_daily${fileRange.where}`).get(...fileRange.params).n;
3621
- const costRange = dayFilter("day");
3622
- const cost = this.db.prepare(`SELECT COALESCE(SUM(cost),0) c FROM token_cost${costRange.where}`).get(...costRange.params).c;
3623
- return {
3624
- logicalRequests: Number(logicalRequests),
3625
- modelAttempts: Number(provider.attempts),
3626
- completedAttempts: Number(provider.completed),
3627
- failedAttempts: Number(provider.failed),
3628
- scheduledRetries: Number(provider.retries),
3629
- fallbacks: Number(provider.fallbacks),
3630
- providers: Number(provider.providers),
3631
- models: Number(provider.models),
3632
- inputTokens: Number(provider.inputTokens),
3633
- outputTokens: Number(provider.outputTokens),
3634
- cacheReadTokens: Number(provider.cacheReadTokens),
3635
- cacheWriteTokens: Number(provider.cacheWriteTokens),
3636
- estimatedCostUsd: Number(cost),
3637
- providerAvgDurationMs: Number(provider.durationCount) > 0 ? Number(provider.durationTotal) / Number(provider.durationCount) : 0,
3638
- // True p95 needs a retained distribution; this per-day aggregate only
3639
- // keeps sum/max/count, so approximate with the observed max rather
3640
- // than adding a per-attempt histogram write (would add the same kind
3641
- // of per-event overhead this whole effort is trying to remove).
3642
- providerP95DurationMs: Number(provider.durationMax),
3643
- toolCalls: Number(counters.toolCalls),
3644
- completedTools: Number(counters.completedTools),
3645
- failedTools: Number(counters.failedTools),
3646
- toolAvgDurationMs: Number(counters.toolDurationCount) > 0 ? Number(counters.toolDurationTotal) / Number(counters.toolDurationCount) : 0,
3647
- processes: Number(counters.processes),
3648
- failedProcesses: Number(counters.failedProcesses),
3649
- fileEvents: Number(counters.fileEvents),
3650
- uniqueFiles: Number(uniqueFiles),
3651
- agentEvents: Number(counters.agentEvents),
3652
- uniqueAgents: Number(uniqueAgents),
3653
- decisions: Number(counters.decisions),
3654
- escalations: Number(counters.escalations),
3655
- failures: Number(counters.failures),
3656
- cancellations: Number(counters.cancellations),
3657
- families,
3658
- failuresByFamily
3659
- };
3660
- }
3661
- // ─── Ingest internals ─────────────────────────────────────────────────────
3662
- ensureSchema() {
3663
- const version = this.db.prepare("PRAGMA user_version").get().user_version;
3664
- if (version !== 0 && version !== SCHEMA_VERSION2) {
3665
- this.db.exec(
3666
- "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;"
3667
- );
3506
+ /** Persist the rebuild sentinel idempotently. Its presence makes
3507
+ * `needsSqliteRebuild` return false, so post-migration jsonl-store offsets
3508
+ * are never mistaken for pre-migration progress. */
3509
+ ensureRebuildMarker() {
3510
+ this.db.prepare(
3511
+ "INSERT INTO ingest_state (file, bytes) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET bytes = excluded.bytes"
3512
+ ).run(REBUILD_MARKER_KEY, 1);
3513
+ }
3514
+ /** Upgrade path: `metrics.db` already aggregated the legacy JSONL, then the
3515
+ * one-shot import copied those same events into the SQLite journal. There
3516
+ * is no `sqlite:` cursor yet, so a plain incremental fold would replay the
3517
+ * entire migrated history on top of the existing aggregates. The metrics
3518
+ * projection is disposable (fully derived from the journal), so rebuild it
3519
+ * from the SQLite source exactly once — the rebuild writes the cursors and
3520
+ * subsequent refreshes go back to incremental folding. */
3521
+ needsSqliteRebuild(offsets) {
3522
+ if (offsets.has(REBUILD_MARKER_KEY)) return false;
3523
+ for (const [key, bytes] of offsets) {
3524
+ if (!key.startsWith(SQLITE_SOURCE_PREFIX) && bytes > 0) return true;
3668
3525
  }
3669
- this.db.exec(`
3670
- CREATE TABLE IF NOT EXISTS ingest_state (
3671
- file TEXT PRIMARY KEY,
3672
- bytes INTEGER NOT NULL
3673
- );
3674
- CREATE TABLE IF NOT EXISTS provider_daily (
3675
- day TEXT NOT NULL,
3676
- provider_id TEXT NOT NULL,
3677
- model_id TEXT NOT NULL,
3678
- attempts INTEGER NOT NULL DEFAULT 0,
3679
- completed INTEGER NOT NULL DEFAULT 0,
3680
- failed INTEGER NOT NULL DEFAULT 0,
3681
- retries INTEGER NOT NULL DEFAULT 0,
3682
- fallbacks INTEGER NOT NULL DEFAULT 0,
3683
- input_tokens INTEGER NOT NULL DEFAULT 0,
3684
- output_tokens INTEGER NOT NULL DEFAULT 0,
3685
- cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3686
- cache_write_tokens INTEGER NOT NULL DEFAULT 0,
3687
- duration_ms_total REAL NOT NULL DEFAULT 0,
3688
- duration_ms_max REAL NOT NULL DEFAULT 0,
3689
- duration_count INTEGER NOT NULL DEFAULT 0,
3690
- PRIMARY KEY (day, provider_id, model_id)
3691
- );
3692
- CREATE TABLE IF NOT EXISTS task_outcomes (
3693
- task_id TEXT PRIMARY KEY,
3694
- run_id TEXT NOT NULL DEFAULT '',
3695
- board_id TEXT NOT NULL DEFAULT '',
3696
- session_id TEXT NOT NULL DEFAULT '',
3697
- agent_id TEXT NOT NULL DEFAULT '',
3698
- status TEXT NOT NULL DEFAULT 'started',
3699
- started_at TEXT,
3700
- ended_at TEXT,
3701
- duration_ms REAL,
3702
- retries INTEGER NOT NULL DEFAULT 0,
3703
- verification_failures INTEGER NOT NULL DEFAULT 0
3704
- );
3705
- CREATE TABLE IF NOT EXISTS file_lineage (
3706
- event_id TEXT PRIMARY KEY,
3707
- path TEXT NOT NULL,
3708
- path_key TEXT NOT NULL,
3709
- operation TEXT NOT NULL,
3710
- occurred_at TEXT NOT NULL,
3711
- session_id TEXT NOT NULL DEFAULT '',
3712
- agent_id TEXT NOT NULL DEFAULT '',
3713
- task_id TEXT NOT NULL DEFAULT '',
3714
- board_id TEXT NOT NULL DEFAULT '',
3715
- run_id TEXT NOT NULL DEFAULT '',
3716
- tool_name TEXT NOT NULL DEFAULT '',
3717
- provider_id TEXT NOT NULL DEFAULT '',
3718
- model_id TEXT NOT NULL DEFAULT '',
3719
- source TEXT NOT NULL DEFAULT ''
3720
- );
3721
- -- Lookups filter on the case-normalized path_key (matching the query
3722
- -- engine); the path column retains original casing for display.
3723
- CREATE INDEX IF NOT EXISTS idx_file_lineage_path ON file_lineage(path_key, occurred_at);
3724
- CREATE INDEX IF NOT EXISTS idx_file_lineage_task ON file_lineage(task_id);
3725
- CREATE TABLE IF NOT EXISTS token_cost (
3726
- scope_key TEXT PRIMARY KEY,
3727
- day TEXT NOT NULL,
3728
- occurred_at TEXT NOT NULL,
3729
- sequence INTEGER NOT NULL,
3730
- cost REAL NOT NULL
3731
- );
3732
- -- Backing store for defaultSummary(): per-day scalar counters plus
3733
- -- dedup sets, populated for every ingested event (not just the
3734
- -- provider/task/file families above).
3735
- CREATE TABLE IF NOT EXISTS daily_counters (
3736
- day TEXT PRIMARY KEY,
3737
- tool_calls INTEGER NOT NULL DEFAULT 0,
3738
- completed_tools INTEGER NOT NULL DEFAULT 0,
3739
- failed_tools INTEGER NOT NULL DEFAULT 0,
3740
- tool_duration_ms_total REAL NOT NULL DEFAULT 0,
3741
- tool_duration_ms_max REAL NOT NULL DEFAULT 0,
3742
- tool_duration_count INTEGER NOT NULL DEFAULT 0,
3743
- processes INTEGER NOT NULL DEFAULT 0,
3744
- failed_processes INTEGER NOT NULL DEFAULT 0,
3745
- file_events_all INTEGER NOT NULL DEFAULT 0,
3746
- decisions INTEGER NOT NULL DEFAULT 0,
3747
- escalations INTEGER NOT NULL DEFAULT 0,
3748
- agent_events INTEGER NOT NULL DEFAULT 0,
3749
- failures INTEGER NOT NULL DEFAULT 0,
3750
- cancellations INTEGER NOT NULL DEFAULT 0
3751
- );
3752
- CREATE TABLE IF NOT EXISTS family_daily (
3753
- day TEXT NOT NULL,
3754
- family TEXT NOT NULL,
3755
- count INTEGER NOT NULL DEFAULT 0,
3756
- failure_count INTEGER NOT NULL DEFAULT 0,
3757
- PRIMARY KEY (day, family)
3758
- );
3759
- CREATE TABLE IF NOT EXISTS agent_daily (day TEXT NOT NULL, agent_id TEXT NOT NULL, PRIMARY KEY (day, agent_id));
3760
- CREATE TABLE IF NOT EXISTS logical_request_daily (day TEXT NOT NULL, logical_request_id TEXT NOT NULL, PRIMARY KEY (day, logical_request_id));
3761
- CREATE TABLE IF NOT EXISTS file_seen_daily (day TEXT NOT NULL, path_key TEXT NOT NULL, PRIMARY KEY (day, path_key));
3762
- PRAGMA user_version = ${SCHEMA_VERSION2};
3763
- `);
3526
+ return false;
3764
3527
  }
3765
- loadOffsets() {
3766
- const rows = this.db.prepare("SELECT file, bytes FROM ingest_state").all();
3767
- return new Map(rows.map((row) => [row.file, Number(row.bytes)]));
3528
+ rebuildFromSqliteJournal(source, result) {
3529
+ this.db.exec(
3530
+ "DELETE FROM ingest_state; DELETE FROM provider_daily; DELETE FROM task_outcomes; DELETE FROM file_lineage; DELETE FROM token_cost; DELETE FROM daily_counters; DELETE FROM family_daily; DELETE FROM agent_daily; DELETE FROM logical_request_daily; DELETE FROM file_seen_daily;"
3531
+ );
3532
+ this.ingestSqliteJournal(source, /* @__PURE__ */ new Map(), result, true);
3533
+ this.ensureRebuildMarker();
3768
3534
  }
3769
3535
  pruneOffsets(existingFiles) {
3770
3536
  const keep = new Set(
@@ -3776,27 +3542,7 @@ var ChronicleMetricsStore = class _ChronicleMetricsStore {
3776
3542
  this.db.prepare("DELETE FROM ingest_state WHERE file = ?").run(row.file);
3777
3543
  }
3778
3544
  }
3779
- /**
3780
- * Fold everything the SQLite journal holds past this store's per-day cursor.
3781
- *
3782
- * Opened read-only on its own connection: the journal runs in WAL, so this
3783
- * never blocks the daemon writing to it, and metrics are best-effort — a
3784
- * journal that cannot be opened (mid-migration, absent, locked) leaves the
3785
- * cursors untouched and the next refresh retries.
3786
- *
3787
- * Rows the journal has already evicted are simply not seen. That is the
3788
- * intended split of responsibilities: the journal is a bounded ring, and this
3789
- * store is where an aggregate outlives the raw event it came from — which
3790
- * only holds if refresh runs more often than the ring turns over.
3791
- */
3792
- ingestSqliteJournal(offsets, result) {
3793
- const journalPath = path10.join(this.directory, CHRONICLE_SQLITE_FILE);
3794
- let source;
3795
- try {
3796
- source = new (loadDatabaseSync2())(journalPath, { readOnly: true });
3797
- } catch {
3798
- return;
3799
- }
3545
+ ingestSqliteJournal(source, offsets, result, propagateErrors) {
3800
3546
  try {
3801
3547
  const days = source.prepare("SELECT DISTINCT day FROM events ORDER BY day").all();
3802
3548
  const read = source.prepare(
@@ -3816,8 +3562,7 @@ var ChronicleMetricsStore = class _ChronicleMetricsStore {
3816
3562
  if (rows.length === 0) break;
3817
3563
  for (const row of rows) {
3818
3564
  try {
3819
- this.ingestEvent(JSON.parse(row.payload));
3820
- result.ingestedEvents++;
3565
+ this.ingestEventAtomically(JSON.parse(row.payload), result);
3821
3566
  } catch {
3822
3567
  result.invalidLines++;
3823
3568
  }
@@ -3834,14 +3579,10 @@ var ChronicleMetricsStore = class _ChronicleMetricsStore {
3834
3579
  }
3835
3580
  if (cursor > from) result.sourceFiles++;
3836
3581
  }
3837
- } catch {
3838
- } finally {
3839
- source.close();
3582
+ } catch (error) {
3583
+ if (propagateErrors) throw error;
3840
3584
  }
3841
3585
  }
3842
- /** Read complete lines appended after `consumed` bytes. The trailing
3843
- * partial line of an actively-written partition is left for the next
3844
- * refresh — `ingest_state.bytes` only ever advances past full lines. */
3845
3586
  async ingestFile(file, key, consumed, result) {
3846
3587
  let handle;
3847
3588
  try {
@@ -3873,8 +3614,7 @@ var ChronicleMetricsStore = class _ChronicleMetricsStore {
3873
3614
  const trimmed = line.trim();
3874
3615
  if (!trimmed) continue;
3875
3616
  try {
3876
- this.ingestEvent(JSON.parse(trimmed));
3877
- result.ingestedEvents++;
3617
+ this.ingestEventAtomically(JSON.parse(trimmed), result);
3878
3618
  } catch {
3879
3619
  result.invalidLines++;
3880
3620
  }
@@ -3896,6 +3636,27 @@ var ChronicleMetricsStore = class _ChronicleMetricsStore {
3896
3636
  await handle.close();
3897
3637
  }
3898
3638
  }
3639
+ /** Fold one event into the aggregates atomically. `ingestEvent` runs several
3640
+ * statements; when one throws mid-way (e.g. a malformed resource path), the
3641
+ * statements before it already mutated the aggregates. The savepoint rolls
3642
+ * those partial updates back: `ROLLBACK TO` rewinds, `RELEASE` (reached
3643
+ * before the re-throw) pops the savepoint so the next event in the batch
3644
+ * starts from a clean stack, and the batch commits only fully-succeeded
3645
+ * events. Dropping the `RELEASE` would leak the savepoint into the next
3646
+ * event's stack — the outer transaction ROLLBACK is never reached because
3647
+ * the per-event catch swallows the error and continues the batch. */
3648
+ ingestEventAtomically(event, result) {
3649
+ this.db.exec("SAVEPOINT ingest_event");
3650
+ try {
3651
+ this.ingestEvent(event);
3652
+ this.db.exec("RELEASE ingest_event");
3653
+ result.ingestedEvents++;
3654
+ } catch (error) {
3655
+ this.db.exec("ROLLBACK TO ingest_event");
3656
+ this.db.exec("RELEASE ingest_event");
3657
+ throw error;
3658
+ }
3659
+ }
3899
3660
  ingestEvent(event) {
3900
3661
  if (typeof event?.eventType !== "string" || !event.scope) return;
3901
3662
  this.ingestDailyCounters(event);
@@ -3910,9 +3671,6 @@ var ChronicleMetricsStore = class _ChronicleMetricsStore {
3910
3671
  this.ingestFileEvent(event);
3911
3672
  }
3912
3673
  }
3913
- /** Runs for every ingested event (not just the type-specific branches
3914
- * below) — mirrors query.ts's updateSummary() closely enough that
3915
- * defaultSummary() matches what a raw scan of the same window would say. */
3916
3674
  ingestDailyCounters(event) {
3917
3675
  const day = eventDay(event);
3918
3676
  this.db.prepare("INSERT OR IGNORE INTO daily_counters (day) VALUES (?)").run(day);
@@ -4031,109 +3789,344 @@ var ChronicleMetricsStore = class _ChronicleMetricsStore {
4031
3789
  for (const [column, value] of lineage) {
4032
3790
  if (value) set(`${column} = ?`, value);
4033
3791
  }
4034
- const base = event.eventType.replace(/^(?:sdd|subagent|kanban)\.task[._]/, "");
4035
- switch (base) {
4036
- case "started":
4037
- set("status = 'started', started_at = COALESCE(started_at, ?)", occurredAt);
4038
- break;
4039
- case "completed":
4040
- set(
4041
- "status = 'completed', ended_at = ?, duration_ms = ?",
4042
- occurredAt,
4043
- numberOrDuration(event, attributes)
4044
- );
4045
- break;
4046
- case "failed":
4047
- set("status = 'failed', ended_at = ?", occurredAt);
4048
- break;
4049
- case "retrying":
4050
- set("retries = retries + 1");
4051
- break;
4052
- case "verification_failed":
4053
- set("verification_failures = verification_failures + 1");
4054
- break;
4055
- case "merged":
4056
- set("status = 'merged'");
4057
- break;
4058
- case "conflict":
4059
- set("status = 'conflict'");
4060
- break;
4061
- default:
4062
- break;
3792
+ const base = event.eventType.replace(/^(?:sdd|subagent|kanban)\.task[._]/, "");
3793
+ switch (base) {
3794
+ case "started":
3795
+ set("status = 'started', started_at = COALESCE(started_at, ?)", occurredAt);
3796
+ break;
3797
+ case "completed":
3798
+ set(
3799
+ "status = 'completed', ended_at = ?, duration_ms = ?",
3800
+ occurredAt,
3801
+ numberOrDuration(event, attributes)
3802
+ );
3803
+ break;
3804
+ case "failed":
3805
+ set("status = 'failed', ended_at = ?", occurredAt);
3806
+ break;
3807
+ case "retrying":
3808
+ set("retries = retries + 1");
3809
+ break;
3810
+ case "verification_failed":
3811
+ set("verification_failures = verification_failures + 1");
3812
+ break;
3813
+ case "merged":
3814
+ set("status = 'merged'");
3815
+ break;
3816
+ case "conflict":
3817
+ set("status = 'conflict'");
3818
+ break;
3819
+ default:
3820
+ break;
3821
+ }
3822
+ }
3823
+ ingestFileEvent(event) {
3824
+ const attributes = event.attributes ?? {};
3825
+ const operation = stringAt(attributes, "operation") ?? "";
3826
+ if (!operation || operation === "read") return;
3827
+ const filePath = event.resource?.path ?? stringAt(attributes, "filePath");
3828
+ if (!filePath) return;
3829
+ this.db.prepare(
3830
+ `INSERT OR IGNORE INTO file_lineage
3831
+ (event_id, path, path_key, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
3832
+ tool_name, provider_id, model_id, source)
3833
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
3834
+ ).run(
3835
+ event.eventId,
3836
+ normalizeKey(filePath),
3837
+ normalizePathKey(filePath),
3838
+ operation,
3839
+ event.occurredAt ?? event.observedAt,
3840
+ event.scope.sessionId ?? "",
3841
+ event.scope.agentId ?? "",
3842
+ event.scope.taskId ?? stringAt(attributes, "taskId") ?? "",
3843
+ event.scope.kanbanBoardId ?? stringAt(attributes, "boardId") ?? "",
3844
+ stringAt(attributes, "runId") ?? "",
3845
+ stringAt(attributes, "toolName") ?? "",
3846
+ event.runtime?.providerId ?? stringAt(attributes, "provider") ?? "",
3847
+ event.runtime?.modelId ?? stringAt(attributes, "model") ?? "",
3848
+ stringAt(attributes, "source") ?? (event.eventType === "file.event" ? "tool" : "external")
3849
+ );
3850
+ }
3851
+ };
3852
+
3853
+ // src/chronicle/metrics-store.ts
3854
+ var ChronicleMetricsStore = class _ChronicleMetricsStore {
3855
+ db;
3856
+ directory;
3857
+ dbPath;
3858
+ ingester;
3859
+ constructor(directory) {
3860
+ this.directory = path11.resolve(directory);
3861
+ this.dbPath = path11.join(this.directory, "metrics.db");
3862
+ const Database = loadDatabaseSync2();
3863
+ this.db = new Database(this.dbPath);
3864
+ this.db.exec("PRAGMA journal_mode = WAL");
3865
+ ensureMetricsSchema(this.db);
3866
+ this.ingester = new ChronicleMetricsIngester(this.db, this.directory, this.dbPath);
3867
+ }
3868
+ static open(chronicleDirectory) {
3869
+ return new _ChronicleMetricsStore(chronicleDirectory);
3870
+ }
3871
+ close() {
3872
+ this.db.close();
3873
+ }
3874
+ /** Incrementally ingest journal bytes appended since the last refresh.
3875
+ * Safe across processes: guarded by a file lock on the database path. */
3876
+ async refresh() {
3877
+ return this.ingester.refresh();
3878
+ }
3879
+ providerDaily(options = {}) {
3880
+ const clauses = [];
3881
+ const params = [];
3882
+ if (options.from) {
3883
+ clauses.push("day >= ?");
3884
+ params.push(options.from.slice(0, 10));
3885
+ }
3886
+ if (options.to) {
3887
+ clauses.push("day <= ?");
3888
+ params.push(options.to.slice(0, 10));
3889
+ }
3890
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
3891
+ const rows = this.db.prepare(
3892
+ `SELECT day, provider_id, model_id, attempts, completed, failed, retries, fallbacks,
3893
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
3894
+ duration_ms_total, duration_ms_max, duration_count
3895
+ FROM provider_daily${where} ORDER BY day DESC, provider_id, model_id`
3896
+ ).all(...params);
3897
+ return rows.map((row) => ({
3898
+ day: String(row.day),
3899
+ providerId: String(row.provider_id),
3900
+ modelId: String(row.model_id),
3901
+ attempts: Number(row.attempts),
3902
+ completed: Number(row.completed),
3903
+ failed: Number(row.failed),
3904
+ retries: Number(row.retries),
3905
+ fallbacks: Number(row.fallbacks),
3906
+ inputTokens: Number(row.input_tokens),
3907
+ outputTokens: Number(row.output_tokens),
3908
+ cacheReadTokens: Number(row.cache_read_tokens),
3909
+ cacheWriteTokens: Number(row.cache_write_tokens),
3910
+ avgDurationMs: Number(row.duration_count) > 0 ? Number(row.duration_ms_total) / Number(row.duration_count) : 0,
3911
+ maxDurationMs: Number(row.duration_ms_max)
3912
+ }));
3913
+ }
3914
+ taskOutcomes(options = {}) {
3915
+ const clauses = [];
3916
+ const params = [];
3917
+ if (options.runId) {
3918
+ clauses.push("t.run_id = ?");
3919
+ params.push(options.runId);
3920
+ }
3921
+ if (options.boardId) {
3922
+ clauses.push("t.board_id = ?");
3923
+ params.push(options.boardId);
3924
+ }
3925
+ if (options.sessionId) {
3926
+ clauses.push("t.session_id = ?");
3927
+ params.push(options.sessionId);
3928
+ }
3929
+ if (options.status) {
3930
+ clauses.push("t.status = ?");
3931
+ params.push(options.status);
3932
+ }
3933
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
3934
+ params.push(clampLimit(options.limit, 100));
3935
+ const rows = this.db.prepare(
3936
+ `SELECT t.*, (SELECT COUNT(*) FROM file_lineage f WHERE f.task_id = t.task_id) AS files_touched
3937
+ FROM task_outcomes t${where}
3938
+ ORDER BY COALESCE(t.started_at, '') DESC LIMIT ?`
3939
+ ).all(...params);
3940
+ return rows.map((row) => ({
3941
+ taskId: String(row.task_id),
3942
+ runId: String(row.run_id),
3943
+ boardId: String(row.board_id),
3944
+ sessionId: String(row.session_id),
3945
+ agentId: String(row.agent_id),
3946
+ status: String(row.status),
3947
+ startedAt: row.started_at === null ? null : String(row.started_at),
3948
+ endedAt: row.ended_at === null ? null : String(row.ended_at),
3949
+ durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
3950
+ retries: Number(row.retries),
3951
+ verificationFailures: Number(row.verification_failures),
3952
+ filesTouched: Number(row.files_touched)
3953
+ }));
3954
+ }
3955
+ fileLineage(options = {}) {
3956
+ const clauses = [];
3957
+ const params = [];
3958
+ if (options.path) {
3959
+ clauses.push("path_key = ?");
3960
+ params.push(normalizePathKey(options.path));
3961
+ }
3962
+ if (options.paths) {
3963
+ const pathKeys = [...new Set(options.paths.map(normalizePathKey))];
3964
+ if (pathKeys.length === 0) return [];
3965
+ clauses.push(`path_key IN (${pathKeys.map(() => "?").join(",")})`);
3966
+ params.push(...pathKeys);
3967
+ }
3968
+ if (options.taskId) {
3969
+ clauses.push("task_id = ?");
3970
+ params.push(options.taskId);
3971
+ }
3972
+ if (options.boardId) {
3973
+ clauses.push("board_id = ?");
3974
+ params.push(options.boardId);
3975
+ }
3976
+ if (options.sessionId) {
3977
+ clauses.push("session_id = ?");
3978
+ params.push(options.sessionId);
3979
+ }
3980
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
3981
+ params.push(clampLimit(options.limit, 200));
3982
+ const projection = `path, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
3983
+ tool_name, provider_id, model_id, source`;
3984
+ const sql = options.latestPerPath ? `SELECT ${projection} FROM (
3985
+ SELECT ${projection}, ROW_NUMBER() OVER (
3986
+ PARTITION BY path_key ORDER BY occurred_at DESC, event_id DESC
3987
+ ) AS path_rank
3988
+ FROM file_lineage${where}
3989
+ ) WHERE path_rank = 1 ORDER BY occurred_at DESC LIMIT ?` : `SELECT ${projection}
3990
+ FROM file_lineage${where} ORDER BY occurred_at DESC LIMIT ?`;
3991
+ const rows = this.db.prepare(sql).all(...params);
3992
+ return rows.map((row) => ({
3993
+ path: row.path,
3994
+ operation: row.operation,
3995
+ occurredAt: row.occurred_at,
3996
+ sessionId: row.session_id,
3997
+ agentId: row.agent_id,
3998
+ taskId: row.task_id,
3999
+ boardId: row.board_id,
4000
+ runId: row.run_id,
4001
+ toolName: row.tool_name,
4002
+ providerId: row.provider_id,
4003
+ modelId: row.model_id,
4004
+ source: row.source
4005
+ }));
4006
+ }
4007
+ summary() {
4008
+ const provider = this.db.prepare(
4009
+ "SELECT COALESCE(SUM(attempts),0) a, COALESCE(SUM(completed),0) c, COALESCE(SUM(failed),0) f FROM provider_daily"
4010
+ ).get();
4011
+ const tasks = {};
4012
+ for (const row of this.db.prepare("SELECT status, COUNT(*) n FROM task_outcomes GROUP BY status").all()) {
4013
+ tasks[row.status] = Number(row.n);
4063
4014
  }
4015
+ const files = this.db.prepare("SELECT COUNT(*) n, COUNT(DISTINCT path) p FROM file_lineage").get();
4016
+ const cost = this.db.prepare("SELECT COALESCE(SUM(cost),0) c FROM token_cost").get();
4017
+ const terminal = Number(provider.c) + Number(provider.f);
4018
+ return {
4019
+ providers: {
4020
+ attempts: Number(provider.a),
4021
+ completed: Number(provider.c),
4022
+ failed: Number(provider.f),
4023
+ successRate: terminal > 0 ? Number(provider.c) / terminal : 0
4024
+ },
4025
+ tasks,
4026
+ files: { mutations: Number(files.n), uniquePaths: Number(files.p) },
4027
+ estimatedCostUsd: Number(cost.c)
4028
+ };
4064
4029
  }
4065
- ingestFileEvent(event) {
4066
- const attributes = event.attributes ?? {};
4067
- const operation = stringAt(attributes, "operation") ?? "";
4068
- if (!operation || operation === "read") return;
4069
- const filePath = event.resource?.path ?? stringAt(attributes, "filePath");
4070
- if (!filePath) return;
4071
- this.db.prepare(
4072
- `INSERT OR IGNORE INTO file_lineage
4073
- (event_id, path, path_key, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
4074
- tool_name, provider_id, model_id, source)
4075
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
4076
- ).run(
4077
- event.eventId,
4078
- normalizeKey(filePath),
4079
- normalizePathKey(filePath),
4080
- operation,
4081
- event.occurredAt ?? event.observedAt,
4082
- event.scope.sessionId ?? "",
4083
- event.scope.agentId ?? "",
4084
- event.scope.taskId ?? stringAt(attributes, "taskId") ?? "",
4085
- event.scope.kanbanBoardId ?? stringAt(attributes, "boardId") ?? "",
4086
- stringAt(attributes, "runId") ?? "",
4087
- stringAt(attributes, "toolName") ?? "",
4088
- event.runtime?.providerId ?? stringAt(attributes, "provider") ?? "",
4089
- event.runtime?.modelId ?? stringAt(attributes, "model") ?? "",
4090
- stringAt(attributes, "source") ?? (event.eventType === "file.event" ? "tool" : "external")
4091
- );
4030
+ /**
4031
+ * A `ChronicleSummary` for the default/unfiltered dashboard view — only
4032
+ * `from`/`to` (day-precision) narrow it.
4033
+ */
4034
+ defaultSummary(options = {}) {
4035
+ const fromDay = options.from?.slice(0, 10);
4036
+ const toDay = options.to?.slice(0, 10);
4037
+ const dayFilter = (column) => {
4038
+ const clauses = [];
4039
+ const params = [];
4040
+ if (fromDay) {
4041
+ clauses.push(`${column} >= ?`);
4042
+ params.push(fromDay);
4043
+ }
4044
+ if (toDay) {
4045
+ clauses.push(`${column} <= ?`);
4046
+ params.push(toDay);
4047
+ }
4048
+ return { where: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "", params };
4049
+ };
4050
+ const providerRange = dayFilter("day");
4051
+ const provider = this.db.prepare(
4052
+ `SELECT COALESCE(SUM(attempts),0) attempts, COALESCE(SUM(completed),0) completed, COALESCE(SUM(failed),0) failed,
4053
+ COALESCE(SUM(retries),0) retries, COALESCE(SUM(fallbacks),0) fallbacks,
4054
+ COUNT(DISTINCT provider_id) providers, COUNT(DISTINCT model_id) models,
4055
+ COALESCE(SUM(input_tokens),0) inputTokens, COALESCE(SUM(output_tokens),0) outputTokens,
4056
+ COALESCE(SUM(cache_read_tokens),0) cacheReadTokens, COALESCE(SUM(cache_write_tokens),0) cacheWriteTokens,
4057
+ COALESCE(SUM(duration_ms_total),0) durationTotal, COALESCE(MAX(duration_ms_max),0) durationMax,
4058
+ COALESCE(SUM(duration_count),0) durationCount
4059
+ FROM provider_daily${providerRange.where}`
4060
+ ).get(...providerRange.params);
4061
+ const counterRange = dayFilter("day");
4062
+ const counters = this.db.prepare(
4063
+ `SELECT COALESCE(SUM(tool_calls),0) toolCalls, COALESCE(SUM(completed_tools),0) completedTools,
4064
+ COALESCE(SUM(failed_tools),0) failedTools, COALESCE(SUM(tool_duration_ms_total),0) toolDurationTotal,
4065
+ COALESCE(SUM(tool_duration_count),0) toolDurationCount, COALESCE(SUM(processes),0) processes,
4066
+ COALESCE(SUM(failed_processes),0) failedProcesses, COALESCE(SUM(file_events_all),0) fileEvents,
4067
+ COALESCE(SUM(decisions),0) decisions, COALESCE(SUM(escalations),0) escalations,
4068
+ COALESCE(SUM(agent_events),0) agentEvents, COALESCE(SUM(failures),0) failures,
4069
+ COALESCE(SUM(cancellations),0) cancellations
4070
+ FROM daily_counters${counterRange.where}`
4071
+ ).get(...counterRange.params);
4072
+ const familyRange = dayFilter("day");
4073
+ const familyRows = this.db.prepare(`SELECT family, count, failure_count FROM family_daily${familyRange.where}`).all(...familyRange.params);
4074
+ const families = { ...EMPTY_FAMILIES };
4075
+ const failuresByFamily = { ...EMPTY_FAMILIES };
4076
+ for (const row of familyRows) {
4077
+ const family = row.family;
4078
+ families[family] = Number(row.count);
4079
+ failuresByFamily[family] = Number(row.failure_count);
4080
+ }
4081
+ const agentRange = dayFilter("day");
4082
+ const uniqueAgents = this.db.prepare(`SELECT COUNT(DISTINCT agent_id) n FROM agent_daily${agentRange.where}`).get(...agentRange.params).n;
4083
+ const requestRange = dayFilter("day");
4084
+ const logicalRequests = this.db.prepare(
4085
+ `SELECT COUNT(DISTINCT logical_request_id) n FROM logical_request_daily${requestRange.where}`
4086
+ ).get(...requestRange.params).n;
4087
+ const fileRange = dayFilter("day");
4088
+ const uniqueFiles = this.db.prepare(`SELECT COUNT(DISTINCT path_key) n FROM file_seen_daily${fileRange.where}`).get(...fileRange.params).n;
4089
+ const costRange = dayFilter("day");
4090
+ const cost = this.db.prepare(`SELECT COALESCE(SUM(cost),0) c FROM token_cost${costRange.where}`).get(...costRange.params).c;
4091
+ return {
4092
+ logicalRequests: Number(logicalRequests),
4093
+ modelAttempts: Number(provider.attempts),
4094
+ completedAttempts: Number(provider.completed),
4095
+ failedAttempts: Number(provider.failed),
4096
+ scheduledRetries: Number(provider.retries),
4097
+ fallbacks: Number(provider.fallbacks),
4098
+ providers: Number(provider.providers),
4099
+ models: Number(provider.models),
4100
+ inputTokens: Number(provider.inputTokens),
4101
+ outputTokens: Number(provider.outputTokens),
4102
+ cacheReadTokens: Number(provider.cacheReadTokens),
4103
+ cacheWriteTokens: Number(provider.cacheWriteTokens),
4104
+ estimatedCostUsd: Number(cost),
4105
+ providerAvgDurationMs: Number(provider.durationCount) > 0 ? Number(provider.durationTotal) / Number(provider.durationCount) : 0,
4106
+ providerP95DurationMs: Number(provider.durationMax),
4107
+ toolCalls: Number(counters.toolCalls),
4108
+ completedTools: Number(counters.completedTools),
4109
+ failedTools: Number(counters.failedTools),
4110
+ toolAvgDurationMs: Number(counters.toolDurationCount) > 0 ? Number(counters.toolDurationTotal) / Number(counters.toolDurationCount) : 0,
4111
+ processes: Number(counters.processes),
4112
+ failedProcesses: Number(counters.failedProcesses),
4113
+ fileEvents: Number(counters.fileEvents),
4114
+ uniqueFiles: Number(uniqueFiles),
4115
+ agentEvents: Number(counters.agentEvents),
4116
+ uniqueAgents: Number(uniqueAgents),
4117
+ decisions: Number(counters.decisions),
4118
+ escalations: Number(counters.escalations),
4119
+ failures: Number(counters.failures),
4120
+ cancellations: Number(counters.cancellations),
4121
+ families,
4122
+ failuresByFamily
4123
+ };
4092
4124
  }
4093
4125
  };
4094
- function eventDay(event) {
4095
- return (event.occurredAt ?? event.observedAt).slice(0, 10);
4096
- }
4097
- function durationMs2(event) {
4098
- const value = Number(event.durationNs ?? 0) / 1e6;
4099
- return Number.isFinite(value) && value > 0 ? value : 0;
4100
- }
4101
- function numberOrDuration(event, attributes) {
4102
- const explicit = attributes.durationMs;
4103
- if (typeof explicit === "number" && Number.isFinite(explicit)) return explicit;
4104
- return durationMs2(event);
4105
- }
4106
- function numberAt2(event, dotPath) {
4107
- const value = readPath2(event.attributes ?? {}, dotPath);
4108
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
4109
- }
4110
- function readPath2(value, key) {
4111
- return key.split(".").reduce(
4112
- (current, part) => current && typeof current === "object" ? current[part] : void 0,
4113
- value
4114
- );
4115
- }
4116
- function stringAt(record, key) {
4117
- const value = record[key];
4118
- return typeof value === "string" && value.length > 0 ? value : void 0;
4119
- }
4120
- function asString(value) {
4121
- return typeof value === "string" && value.length > 0 ? value : void 0;
4122
- }
4123
- function clampLimit(limit, fallback) {
4124
- if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) return fallback;
4125
- return Math.min(Math.floor(limit), 1e4);
4126
- }
4127
- function normalizeKey(value) {
4128
- return value.replaceAll("\\", "/");
4129
- }
4130
- function normalizePathKey(value) {
4131
- return value.replaceAll("\\", "/").replace(/^\.\//, "").toLowerCase();
4132
- }
4133
4126
 
4134
4127
  // src/chronicle/legacy-journal-import.ts
4135
4128
  import * as fs8 from "node:fs/promises";
4136
- import * as path11 from "node:path";
4129
+ import * as path12 from "node:path";
4137
4130
  var ChronicleImportError = class extends Error {
4138
4131
  constructor(message, day, sequence) {
4139
4132
  super(message);
@@ -4172,12 +4165,14 @@ async function importLegacyChronicleJournal(journal, directory) {
4172
4165
  }
4173
4166
  const families = await discoverFamilies(directory);
4174
4167
  const quarantined = [];
4168
+ const boundary = {};
4175
4169
  let importedEvents = 0;
4176
4170
  let importedFamilies = 0;
4177
4171
  for (const familyBase of families) {
4178
4172
  const day = dayOfFamily(familyBase);
4179
- const basePath = path11.join(directory, `${familyBase}.jsonl`);
4173
+ const basePath = path12.join(directory, `${familyBase}.jsonl`);
4180
4174
  let familyEvents = 0;
4175
+ const familyBoundary = {};
4181
4176
  if (journal.hasImportedDay(day)) continue;
4182
4177
  try {
4183
4178
  await journal.runFamilyImport(async (sink) => {
@@ -4191,6 +4186,7 @@ async function importLegacyChronicleJournal(journal, directory) {
4191
4186
  let expectedSequence = (checkpoint?.sequence ?? 0) + 1;
4192
4187
  let previousHash = checkpoint?.hash ?? GENESIS_HASH;
4193
4188
  for (const partition of await collectPartitions(basePath)) {
4189
+ familyBoundary[path12.relative(directory, partition).replaceAll("\\", "/")] = (await fs8.stat(partition)).size;
4194
4190
  for await (const event of streamEntriesStrict(partition)) {
4195
4191
  if (event.sequence !== expectedSequence) {
4196
4192
  throw new ChronicleImportError(
@@ -4225,10 +4221,13 @@ async function importLegacyChronicleJournal(journal, directory) {
4225
4221
  quarantined.push({ day, sequence: error.sequence, reason: error.message });
4226
4222
  continue;
4227
4223
  }
4224
+ Object.assign(boundary, familyBoundary);
4228
4225
  if (familyEvents > 0) importedFamilies += 1;
4229
4226
  importedEvents += familyEvents;
4227
+ journal.recordLegacyJsonlBoundary(boundary);
4230
4228
  }
4231
4229
  journal.recordQuarantinedFamilies(quarantined);
4230
+ journal.recordLegacyJsonlBoundary(boundary);
4232
4231
  journal.markLegacyJournalImported();
4233
4232
  return {
4234
4233
  alreadyImported: false,
@@ -4242,13 +4241,13 @@ async function importLegacyChronicleJournal(journal, directory) {
4242
4241
  import { spawn } from "node:child_process";
4243
4242
  import * as fs9 from "node:fs";
4244
4243
  import * as net from "node:net";
4245
- import * as path13 from "node:path";
4244
+ import * as path14 from "node:path";
4246
4245
  import { fileURLToPath } from "node:url";
4247
4246
 
4248
4247
  // src/chronicle/project-server-endpoint.ts
4249
4248
  import { createHash as createHash6 } from "node:crypto";
4250
4249
  import * as os3 from "node:os";
4251
- import * as path12 from "node:path";
4250
+ import * as path13 from "node:path";
4252
4251
 
4253
4252
  // src/chronicle/project-server-protocol.ts
4254
4253
  var CHRONICLE_PROJECT_SERVER_PROTOCOL_VERSION = 2;
@@ -4262,25 +4261,25 @@ function encodeChronicleProjectServerMessage(message) {
4262
4261
  // src/chronicle/project-server-endpoint.ts
4263
4262
  var CHRONICLE_PROJECT_SERVER_METADATA_FILE = "server.json";
4264
4263
  function normalizedPath(value) {
4265
- const resolved = path12.resolve(value);
4264
+ const resolved = path13.resolve(value);
4266
4265
  return process.platform === "win32" ? resolved.toLowerCase() : resolved;
4267
4266
  }
4268
4267
  function chronicleProjectServerKey(projectDir) {
4269
- return createHash6("sha256").update(normalizedPath(path12.join(projectDir, "chronicle"))).digest("hex").slice(0, 24);
4268
+ return createHash6("sha256").update(normalizedPath(path13.join(projectDir, "chronicle"))).digest("hex").slice(0, 24);
4270
4269
  }
4271
4270
  function chronicleProjectServerEndpoint(projectDir) {
4272
4271
  const key = chronicleProjectServerKey(projectDir);
4273
4272
  if (process.platform === "win32") {
4274
4273
  return `\\\\.\\pipe\\wrongstack-chronicle-v${CHRONICLE_PROJECT_SERVER_PROTOCOL_VERSION}-${key}`;
4275
4274
  }
4276
- return path12.join(
4275
+ return path13.join(
4277
4276
  os3.tmpdir(),
4278
4277
  `wsch-v${CHRONICLE_PROJECT_SERVER_PROTOCOL_VERSION}`,
4279
4278
  `${key}.sock`
4280
4279
  );
4281
4280
  }
4282
4281
  function chronicleProjectServerMetadataPath(projectDir) {
4283
- return path12.join(projectDir, "chronicle", CHRONICLE_PROJECT_SERVER_METADATA_FILE);
4282
+ return path13.join(projectDir, "chronicle", CHRONICLE_PROJECT_SERVER_METADATA_FILE);
4284
4283
  }
4285
4284
 
4286
4285
  // src/chronicle/project-server-client.ts
@@ -4715,7 +4714,7 @@ var ChronicleRemoteJournal = class {
4715
4714
  }
4716
4715
  };
4717
4716
  function normalizePath(value) {
4718
- const resolved = path13.resolve(value);
4717
+ const resolved = path14.resolve(value);
4719
4718
  return process.platform === "win32" ? resolved.toLowerCase() : resolved;
4720
4719
  }
4721
4720
 
@@ -4723,7 +4722,7 @@ function normalizePath(value) {
4723
4722
  function resolveChronicleProjectServerOptions(options) {
4724
4723
  if (options.projectPaths) {
4725
4724
  return {
4726
- projectRoot: path14.resolve(options.projectRoot),
4725
+ projectRoot: path15.resolve(options.projectRoot),
4727
4726
  ...options.projectPaths,
4728
4727
  ...options.retentionDays !== void 0 ? { retentionDays: options.retentionDays } : {}
4729
4728
  };
@@ -4733,7 +4732,7 @@ function resolveChronicleProjectServerOptions(options) {
4733
4732
  userHome: options.userHome ?? os4.homedir()
4734
4733
  });
4735
4734
  return {
4736
- projectRoot: path14.resolve(options.projectRoot),
4735
+ projectRoot: path15.resolve(options.projectRoot),
4737
4736
  globalRoot: paths.globalRoot,
4738
4737
  projectId: paths.projectHash,
4739
4738
  projectDir: paths.projectDir,
@@ -4778,7 +4777,7 @@ function createChronicleEventJournal(options) {
4778
4777
  }
4779
4778
  assertInlineWasRequested("createChronicleEventJournal");
4780
4779
  if (useSqliteStore()) {
4781
- const journal2 = new ChronicleSqliteEventSink(path14.join(resolved.projectDir, "chronicle"));
4780
+ const journal2 = new ChronicleSqliteEventSink(path15.join(resolved.projectDir, "chronicle"));
4782
4781
  return {
4783
4782
  journal: journal2,
4784
4783
  identity,
@@ -4798,7 +4797,7 @@ function createChronicleEventJournal(options) {
4798
4797
  };
4799
4798
  }
4800
4799
  function legacyPartitionPath(location) {
4801
- return path14.join(location.chronicleDirectory, `${location.day}.events.jsonl`);
4800
+ return path15.join(location.chronicleDirectory, `${location.day}.events.jsonl`);
4802
4801
  }
4803
4802
  function useSqliteStore() {
4804
4803
  return process.env["WRONGSTACK_CHRONICLE_STORE"] !== "jsonl";
@@ -4945,9 +4944,14 @@ var InlineChronicleProjectAccess = class {
4945
4944
  this.sqlite = void 0;
4946
4945
  store.close();
4947
4946
  }
4947
+ for (const journal of this.journals.values()) {
4948
+ await journal.flush().catch(() => {
4949
+ });
4950
+ }
4951
+ this.journals.clear();
4948
4952
  }
4949
4953
  get chronicleDirectory() {
4950
- return path14.join(this.options.projectDir, "chronicle");
4954
+ return path15.join(this.options.projectDir, "chronicle");
4951
4955
  }
4952
4956
  journalForToday() {
4953
4957
  const now = /* @__PURE__ */ new Date();