@wrongstack/core 0.302.0 → 0.303.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-status-tracker.d.ts +6 -2
  3. package/dist/chronicle/index.js +1949 -1671
  4. package/dist/chronicle/metrics-store.d.ts +14 -0
  5. package/dist/chronicle/project-server-protocol.d.ts +13 -0
  6. package/dist/chronicle/project-server.js +1756 -1573
  7. package/dist/chronicle/rollup-adapter.d.ts +2 -0
  8. package/dist/chronicle/sqlite-journal.d.ts +59 -0
  9. package/dist/coordination/agents/index.js +4313 -3516
  10. package/dist/coordination/agents/project-agent-auto-optimize.d.ts +116 -0
  11. package/dist/coordination/agents/project-agent-capture-window.d.ts +29 -0
  12. package/dist/coordination/agents/project-agent-config-io.d.ts +11 -0
  13. package/dist/coordination/agents/project-agent-consolidation.d.ts +29 -2
  14. package/dist/coordination/agents/project-agent-files.d.ts +12 -3
  15. package/dist/coordination/agents/project-agent-identity-types.d.ts +4 -0
  16. package/dist/coordination/agents/project-agent-identity.d.ts +22 -9
  17. package/dist/coordination/agents/project-agent-learning-entries.d.ts +8 -2
  18. package/dist/coordination/agents/project-agent-learning-structured.d.ts +27 -1
  19. package/dist/coordination/agents/project-agent-optimizer.d.ts +49 -0
  20. package/dist/coordination/agents/project-agent-skill-layer.d.ts +101 -0
  21. package/dist/coordination/agents/role-skills.d.ts +11 -1
  22. package/dist/coordination/index.d.ts +1 -1
  23. package/dist/coordination/index.js +4927 -3589
  24. package/dist/coordination/mail-tools.d.ts +3 -3
  25. package/dist/core/context.d.ts +4 -0
  26. package/dist/core/continue-intent.d.ts +2 -0
  27. package/dist/core/conversation-state.d.ts +5 -0
  28. package/dist/core/index.js +129 -19
  29. package/dist/defaults/index.js +1620 -768
  30. package/dist/execution/index.js +2941 -2630
  31. package/dist/goal/index.js +7 -0
  32. package/dist/index.d.ts +3 -1
  33. package/dist/index.js +12269 -9212
  34. package/dist/infrastructure/index.js +722 -672
  35. package/dist/kernel/events/agent-events.d.ts +28 -0
  36. package/dist/kernel/events/memory-events.d.ts +62 -0
  37. package/dist/plugin/index.js +2167 -1986
  38. package/dist/security/index.js +69 -3
  39. package/dist/security/kanban-boundary.d.ts +5 -1
  40. package/dist/session-catalog/client.d.ts +62 -0
  41. package/dist/session-catalog/endpoint.d.ts +6 -0
  42. package/dist/session-catalog/index.d.ts +6 -0
  43. package/dist/session-catalog/index.js +2000 -0
  44. package/dist/session-catalog/project-server.d.ts +3 -0
  45. package/dist/session-catalog/project-server.js +1861 -0
  46. package/dist/session-catalog/protocol.d.ts +284 -0
  47. package/dist/session-catalog/registry.d.ts +59 -0
  48. package/dist/session-catalog/store.d.ts +71 -0
  49. package/dist/storage/index.d.ts +42 -38
  50. package/dist/storage/index.js +13896 -12931
  51. package/dist/storage/plan-store.d.ts +1 -1
  52. package/dist/storage/session-event-bridge.d.ts +2 -2
  53. package/dist/storage/session-store.d.ts +6 -0
  54. package/dist/tasking/index.js +5 -0
  55. package/dist/tools/index.js +2832 -2606
  56. package/dist/types/config/root.d.ts +11 -1
  57. package/dist/types/config/skills-fleet-brain.d.ts +34 -0
  58. package/dist/types/config/ui.d.ts +14 -0
  59. package/dist/types/config.d.ts +1 -0
  60. package/dist/types/context-evidence.d.ts +2 -0
  61. package/dist/types/index.d.ts +2 -2
  62. package/dist/types/index.js +20 -0
  63. package/dist/types/messages.d.ts +8 -0
  64. package/dist/types/multi-agent.d.ts +7 -0
  65. package/dist/types/session.d.ts +19 -0
  66. package/dist/types/task-graph.d.ts +2 -0
  67. package/dist/types/tool-executor.d.ts +2 -0
  68. package/dist/utils/context-evidence.d.ts +13 -1
  69. package/dist/utils/index.js +29 -2
  70. package/instructions/system-lite.md +23 -8
  71. package/instructions/system-pro.md +29 -9
  72. package/instructions/system.md +29 -9
  73. package/package.json +7 -3
  74. package/skills/wrongstack-kanban/SKILL.md +39 -8
@@ -3869,9 +3869,9 @@ function errorMessage(error) {
3869
3869
 
3870
3870
  // src/chronicle/metrics-store.ts
3871
3871
  init_atomic_write();
3872
- import * as fs5 from "node:fs/promises";
3873
- import { createRequire } from "node:module";
3874
- import * as path8 from "node:path";
3872
+ import * as fs7 from "node:fs/promises";
3873
+ import { createRequire as createRequire2 } from "node:module";
3874
+ import * as path10 from "node:path";
3875
3875
 
3876
3876
  // src/utils/sqlite-warning.ts
3877
3877
  var SQLITE_EXPERIMENTAL_WARNING_RE = /sqlite is an experimental feature/i;
@@ -4685,2133 +4685,2314 @@ function facetValue(event, field) {
4685
4685
  return values[field];
4686
4686
  }
4687
4687
 
4688
- // src/chronicle/metrics-store.ts
4689
- var SCHEMA_VERSION = 3;
4690
- var READ_CHUNK_BYTES = 1024 * 1024;
4691
- var EMPTY_FAMILIES = {
4692
- llm: 0,
4693
- agent: 0,
4694
- tool: 0,
4695
- file: 0,
4696
- memory: 0,
4697
- task: 0,
4698
- decision: 0,
4699
- runtime: 0,
4700
- finding: 0
4688
+ // src/chronicle/sqlite-journal.ts
4689
+ import { randomUUID as randomUUID3 } from "node:crypto";
4690
+ import * as fs6 from "node:fs";
4691
+ import { createRequire } from "node:module";
4692
+ import * as path9 from "node:path";
4693
+
4694
+ // src/utils/index.ts
4695
+ init_atomic_write();
4696
+
4697
+ // src/utils/term.ts
4698
+ var hasStdout = () => typeof process !== "undefined" && !!process.stdout;
4699
+ function isStdoutTTY() {
4700
+ return hasStdout() && Boolean(process.stdout.isTTY);
4701
+ }
4702
+ var ESCAPE_TERMINATOR = Object.freeze({
4703
+ BEL: "\x07",
4704
+ // OSC 0 / OSC 2 (title)
4705
+ ST: "\x1B\\"
4706
+ // DCS / strict OSC
4707
+ });
4708
+
4709
+ // src/utils/color.ts
4710
+ var isColorTty = () => {
4711
+ if (envFlag(process.env.NO_COLOR)) return false;
4712
+ if (envFlag(process.env.FORCE_COLOR)) return true;
4713
+ return isStdoutTTY();
4701
4714
  };
4702
- var Ctor;
4703
- function loadDatabaseSync() {
4704
- if (Ctor) return Ctor;
4705
- if (Ctor === null) throw new Error("node:sqlite is unavailable in this runtime");
4715
+ function envFlag(value) {
4716
+ if (value === void 0) return false;
4717
+ if (value.trim() === "") return false;
4718
+ return !/^(0|false|no|off)$/i.test(value.trim());
4719
+ }
4720
+ var COLOR = isColorTty();
4721
+ var wrap = (open5, close) => (s) => COLOR ? `\x1B[${open5}m${s}\x1B[${close}m` : s;
4722
+ var color = {
4723
+ reset: wrap("0", "0"),
4724
+ bold: wrap("1", "22"),
4725
+ dim: wrap("2", "22"),
4726
+ italic: wrap("3", "23"),
4727
+ underline: wrap("4", "24"),
4728
+ red: wrap("31", "39"),
4729
+ green: wrap("32", "39"),
4730
+ yellow: wrap("33", "39"),
4731
+ blue: wrap("34", "39"),
4732
+ magenta: wrap("35", "39"),
4733
+ cyan: wrap("36", "39"),
4734
+ gray: wrap("90", "39"),
4735
+ amber: wrap("38;5;214", "39"),
4736
+ pink: wrap("38;5;205", "39"),
4737
+ bgRed: wrap("41", "49"),
4738
+ bgGreen: wrap("42", "49")
4739
+ };
4740
+
4741
+ // src/utils/config-json.ts
4742
+ import * as fs5 from "node:fs/promises";
4743
+ async function readJsonObjectFile(filePath) {
4706
4744
  try {
4707
- Ctor = withSqliteExperimentalWarningSuppressed(
4708
- () => createRequire(import.meta.url)("node:sqlite").DatabaseSync
4709
- );
4710
- return Ctor;
4711
- } catch (error) {
4712
- Ctor = null;
4713
- throw new Error(
4714
- "Chronicle metrics need Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
4715
- );
4745
+ const parsed = JSON.parse(await fs5.readFile(filePath, "utf8"));
4746
+ return isJsonObject(parsed) ? parsed : {};
4747
+ } catch {
4748
+ return {};
4716
4749
  }
4717
4750
  }
4718
- var ChronicleMetricsStore = class _ChronicleMetricsStore {
4719
- db;
4720
- directory;
4721
- dbPath;
4722
- constructor(directory) {
4723
- this.directory = path8.resolve(directory);
4724
- this.dbPath = path8.join(this.directory, "metrics.db");
4725
- const Database = loadDatabaseSync();
4726
- this.db = new Database(this.dbPath);
4727
- this.db.exec("PRAGMA journal_mode = WAL");
4728
- this.ensureSchema();
4729
- }
4730
- static open(chronicleDirectory) {
4731
- return new _ChronicleMetricsStore(chronicleDirectory);
4732
- }
4733
- close() {
4734
- this.db.close();
4735
- }
4736
- /** Incrementally ingest journal bytes appended since the last refresh.
4737
- * Safe across processes: guarded by a file lock on the database path. */
4738
- async refresh() {
4739
- const result = {
4740
- ingestedEvents: 0,
4741
- ingestedBytes: 0,
4742
- sourceFiles: 0,
4743
- invalidLines: 0
4744
- };
4745
- await withFileLock(this.dbPath, async () => {
4746
- const files = await findChroniclePartitions(this.directory);
4747
- const offsets = this.loadOffsets();
4748
- for (const file of files) {
4749
- const key = normalizeKey(path8.relative(this.directory, file));
4750
- const consumed = offsets.get(key) ?? 0;
4751
- const ingested = await this.ingestFile(file, key, consumed, result);
4752
- if (ingested) result.sourceFiles++;
4753
- }
4754
- this.pruneOffsets(files);
4755
- });
4756
- return result;
4757
- }
4758
- providerDaily(options = {}) {
4759
- const clauses = [];
4760
- const params = [];
4761
- if (options.from) {
4762
- clauses.push("day >= ?");
4763
- params.push(options.from.slice(0, 10));
4764
- }
4765
- if (options.to) {
4766
- clauses.push("day <= ?");
4767
- params.push(options.to.slice(0, 10));
4768
- }
4769
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
4770
- const rows = this.db.prepare(
4771
- `SELECT day, provider_id, model_id, attempts, completed, failed, retries, fallbacks,
4772
- input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
4773
- duration_ms_total, duration_ms_max, duration_count
4774
- FROM provider_daily${where} ORDER BY day DESC, provider_id, model_id`
4775
- ).all(...params);
4776
- return rows.map((row) => ({
4777
- day: String(row.day),
4778
- providerId: String(row.provider_id),
4779
- modelId: String(row.model_id),
4780
- attempts: Number(row.attempts),
4781
- completed: Number(row.completed),
4782
- failed: Number(row.failed),
4783
- retries: Number(row.retries),
4784
- fallbacks: Number(row.fallbacks),
4785
- inputTokens: Number(row.input_tokens),
4786
- outputTokens: Number(row.output_tokens),
4787
- cacheReadTokens: Number(row.cache_read_tokens),
4788
- cacheWriteTokens: Number(row.cache_write_tokens),
4789
- avgDurationMs: Number(row.duration_count) > 0 ? Number(row.duration_ms_total) / Number(row.duration_count) : 0,
4790
- maxDurationMs: Number(row.duration_ms_max)
4791
- }));
4792
- }
4793
- taskOutcomes(options = {}) {
4794
- const clauses = [];
4795
- const params = [];
4796
- if (options.runId) {
4797
- clauses.push("t.run_id = ?");
4798
- params.push(options.runId);
4799
- }
4800
- if (options.boardId) {
4801
- clauses.push("t.board_id = ?");
4802
- params.push(options.boardId);
4803
- }
4804
- if (options.sessionId) {
4805
- clauses.push("t.session_id = ?");
4806
- params.push(options.sessionId);
4807
- }
4808
- if (options.status) {
4809
- clauses.push("t.status = ?");
4810
- params.push(options.status);
4811
- }
4812
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
4813
- params.push(clampLimit(options.limit, 100));
4814
- const rows = this.db.prepare(
4815
- `SELECT t.*, (SELECT COUNT(*) FROM file_lineage f WHERE f.task_id = t.task_id) AS files_touched
4816
- FROM task_outcomes t${where}
4817
- ORDER BY COALESCE(t.started_at, '') DESC LIMIT ?`
4818
- ).all(...params);
4819
- return rows.map((row) => ({
4820
- taskId: String(row.task_id),
4821
- runId: String(row.run_id),
4822
- boardId: String(row.board_id),
4823
- sessionId: String(row.session_id),
4824
- agentId: String(row.agent_id),
4825
- status: String(row.status),
4826
- startedAt: row.started_at === null ? null : String(row.started_at),
4827
- endedAt: row.ended_at === null ? null : String(row.ended_at),
4828
- durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
4829
- retries: Number(row.retries),
4830
- verificationFailures: Number(row.verification_failures),
4831
- filesTouched: Number(row.files_touched)
4832
- }));
4833
- }
4834
- fileLineage(options = {}) {
4835
- const clauses = [];
4836
- const params = [];
4837
- if (options.path) {
4838
- clauses.push("path_key = ?");
4839
- params.push(normalizePathKey(options.path));
4840
- }
4841
- if (options.paths) {
4842
- const pathKeys = [...new Set(options.paths.map(normalizePathKey))];
4843
- if (pathKeys.length === 0) return [];
4844
- clauses.push(`path_key IN (${pathKeys.map(() => "?").join(",")})`);
4845
- params.push(...pathKeys);
4846
- }
4847
- if (options.taskId) {
4848
- clauses.push("task_id = ?");
4849
- params.push(options.taskId);
4850
- }
4851
- if (options.boardId) {
4852
- clauses.push("board_id = ?");
4853
- params.push(options.boardId);
4854
- }
4855
- if (options.sessionId) {
4856
- clauses.push("session_id = ?");
4857
- params.push(options.sessionId);
4858
- }
4859
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
4860
- params.push(clampLimit(options.limit, 200));
4861
- const projection = `path, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
4862
- tool_name, provider_id, model_id, source`;
4863
- const sql = options.latestPerPath ? `SELECT ${projection} FROM (
4864
- SELECT ${projection}, ROW_NUMBER() OVER (
4865
- PARTITION BY path_key ORDER BY occurred_at DESC, event_id DESC
4866
- ) AS path_rank
4867
- FROM file_lineage${where}
4868
- ) WHERE path_rank = 1 ORDER BY occurred_at DESC LIMIT ?` : `SELECT ${projection}
4869
- FROM file_lineage${where} ORDER BY occurred_at DESC LIMIT ?`;
4870
- const rows = this.db.prepare(sql).all(...params);
4871
- return rows.map((row) => ({
4872
- path: row.path,
4873
- operation: row.operation,
4874
- occurredAt: row.occurred_at,
4875
- sessionId: row.session_id,
4876
- agentId: row.agent_id,
4877
- taskId: row.task_id,
4878
- boardId: row.board_id,
4879
- runId: row.run_id,
4880
- toolName: row.tool_name,
4881
- providerId: row.provider_id,
4882
- modelId: row.model_id,
4883
- source: row.source
4884
- }));
4885
- }
4886
- summary() {
4887
- const provider = this.db.prepare(
4888
- "SELECT COALESCE(SUM(attempts),0) a, COALESCE(SUM(completed),0) c, COALESCE(SUM(failed),0) f FROM provider_daily"
4889
- ).get();
4890
- const tasks = {};
4891
- for (const row of this.db.prepare("SELECT status, COUNT(*) n FROM task_outcomes GROUP BY status").all()) {
4892
- tasks[row.status] = Number(row.n);
4893
- }
4894
- const files = this.db.prepare("SELECT COUNT(*) n, COUNT(DISTINCT path) p FROM file_lineage").get();
4895
- const cost = this.db.prepare("SELECT COALESCE(SUM(cost),0) c FROM token_cost").get();
4896
- const terminal = Number(provider.c) + Number(provider.f);
4897
- return {
4898
- providers: {
4899
- attempts: Number(provider.a),
4900
- completed: Number(provider.c),
4901
- failed: Number(provider.f),
4902
- successRate: terminal > 0 ? Number(provider.c) / terminal : 0
4903
- },
4904
- tasks,
4905
- files: { mutations: Number(files.n), uniquePaths: Number(files.p) },
4906
- estimatedCostUsd: Number(cost.c)
4907
- };
4908
- }
4751
+ function isJsonObject(value) {
4752
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4753
+ }
4754
+
4755
+ // src/skills/limits.ts
4756
+ var SKILL_LIMITS = {
4909
4757
  /**
4910
- * A `ChronicleSummary` for the default/unfiltered dashboard view only
4911
- * `from`/`to` (day-precision) narrow it. Any other ad hoc filter (text,
4912
- * path, provider, model, session) can't be answered from these
4913
- * fixed-dimension aggregates; callers must fall back to query.ts's
4914
- * raw-scan summary for those.
4758
+ * Per-skill body cap when injecting a full skill body into the system prompt
4759
+ * (eager mode), and when returning a skill body from the `skill` tool.
4760
+ * ~4k tokens. Oversized bodies are truncated at a paragraph boundary.
4761
+ *
4762
+ * Consumers: `system-prompt-builder.capSkillBody`, `skill` tool body return.
4915
4763
  */
4916
- defaultSummary(options = {}) {
4917
- const fromDay = options.from?.slice(0, 10);
4918
- const toDay = options.to?.slice(0, 10);
4919
- const dayFilter = (column) => {
4920
- const clauses = [];
4921
- const params = [];
4922
- if (fromDay) {
4923
- clauses.push(`${column} >= ?`);
4924
- params.push(fromDay);
4925
- }
4926
- if (toDay) {
4927
- clauses.push(`${column} <= ?`);
4928
- params.push(toDay);
4929
- }
4930
- return { where: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "", params };
4931
- };
4932
- const providerRange = dayFilter("day");
4933
- const provider = this.db.prepare(
4934
- `SELECT COALESCE(SUM(attempts),0) attempts, COALESCE(SUM(completed),0) completed, COALESCE(SUM(failed),0) failed,
4935
- COALESCE(SUM(retries),0) retries, COALESCE(SUM(fallbacks),0) fallbacks,
4936
- COUNT(DISTINCT provider_id) providers, COUNT(DISTINCT model_id) models,
4937
- COALESCE(SUM(input_tokens),0) inputTokens, COALESCE(SUM(output_tokens),0) outputTokens,
4938
- COALESCE(SUM(cache_read_tokens),0) cacheReadTokens, COALESCE(SUM(cache_write_tokens),0) cacheWriteTokens,
4939
- COALESCE(SUM(duration_ms_total),0) durationTotal, COALESCE(MAX(duration_ms_max),0) durationMax,
4940
- COALESCE(SUM(duration_count),0) durationCount
4941
- FROM provider_daily${providerRange.where}`
4942
- ).get(...providerRange.params);
4943
- const counterRange = dayFilter("day");
4944
- const counters = this.db.prepare(
4945
- `SELECT COALESCE(SUM(tool_calls),0) toolCalls, COALESCE(SUM(completed_tools),0) completedTools,
4946
- COALESCE(SUM(failed_tools),0) failedTools, COALESCE(SUM(tool_duration_ms_total),0) toolDurationTotal,
4947
- COALESCE(SUM(tool_duration_count),0) toolDurationCount, COALESCE(SUM(processes),0) processes,
4948
- COALESCE(SUM(failed_processes),0) failedProcesses, COALESCE(SUM(file_events_all),0) fileEvents,
4949
- COALESCE(SUM(decisions),0) decisions, COALESCE(SUM(escalations),0) escalations,
4950
- COALESCE(SUM(agent_events),0) agentEvents, COALESCE(SUM(failures),0) failures,
4951
- COALESCE(SUM(cancellations),0) cancellations
4952
- FROM daily_counters${counterRange.where}`
4953
- ).get(...counterRange.params);
4954
- const familyRange = dayFilter("day");
4955
- const familyRows = this.db.prepare(`SELECT family, count, failure_count FROM family_daily${familyRange.where}`).all(...familyRange.params);
4956
- const families = { ...EMPTY_FAMILIES };
4957
- const failuresByFamily = { ...EMPTY_FAMILIES };
4958
- for (const row of familyRows) {
4959
- const family = row.family;
4960
- families[family] = Number(row.count);
4961
- failuresByFamily[family] = Number(row.failure_count);
4962
- }
4963
- const agentRange = dayFilter("day");
4964
- const uniqueAgents = this.db.prepare(`SELECT COUNT(DISTINCT agent_id) n FROM agent_daily${agentRange.where}`).get(...agentRange.params).n;
4965
- const requestRange = dayFilter("day");
4966
- const logicalRequests = this.db.prepare(`SELECT COUNT(DISTINCT logical_request_id) n FROM logical_request_daily${requestRange.where}`).get(...requestRange.params).n;
4967
- const fileRange = dayFilter("day");
4968
- const uniqueFiles = this.db.prepare(`SELECT COUNT(DISTINCT path_key) n FROM file_seen_daily${fileRange.where}`).get(...fileRange.params).n;
4969
- const costRange = dayFilter("day");
4970
- const cost = this.db.prepare(`SELECT COALESCE(SUM(cost),0) c FROM token_cost${costRange.where}`).get(...costRange.params).c;
4971
- return {
4972
- logicalRequests: Number(logicalRequests),
4973
- modelAttempts: Number(provider.attempts),
4974
- completedAttempts: Number(provider.completed),
4975
- failedAttempts: Number(provider.failed),
4976
- scheduledRetries: Number(provider.retries),
4977
- fallbacks: Number(provider.fallbacks),
4978
- providers: Number(provider.providers),
4979
- models: Number(provider.models),
4980
- inputTokens: Number(provider.inputTokens),
4981
- outputTokens: Number(provider.outputTokens),
4982
- cacheReadTokens: Number(provider.cacheReadTokens),
4983
- cacheWriteTokens: Number(provider.cacheWriteTokens),
4984
- estimatedCostUsd: Number(cost),
4985
- providerAvgDurationMs: Number(provider.durationCount) > 0 ? Number(provider.durationTotal) / Number(provider.durationCount) : 0,
4986
- // True p95 needs a retained distribution; this per-day aggregate only
4987
- // keeps sum/max/count, so approximate with the observed max rather
4988
- // than adding a per-attempt histogram write (would add the same kind
4989
- // of per-event overhead this whole effort is trying to remove).
4990
- providerP95DurationMs: Number(provider.durationMax),
4991
- toolCalls: Number(counters.toolCalls),
4992
- completedTools: Number(counters.completedTools),
4993
- failedTools: Number(counters.failedTools),
4994
- toolAvgDurationMs: Number(counters.toolDurationCount) > 0 ? Number(counters.toolDurationTotal) / Number(counters.toolDurationCount) : 0,
4995
- processes: Number(counters.processes),
4996
- failedProcesses: Number(counters.failedProcesses),
4997
- fileEvents: Number(counters.fileEvents),
4998
- uniqueFiles: Number(uniqueFiles),
4999
- agentEvents: Number(counters.agentEvents),
5000
- uniqueAgents: Number(uniqueAgents),
5001
- decisions: Number(counters.decisions),
5002
- escalations: Number(counters.escalations),
5003
- failures: Number(counters.failures),
5004
- cancellations: Number(counters.cancellations),
5005
- families,
5006
- failuresByFamily
5007
- };
5008
- }
5009
- // ─── Ingest internals ─────────────────────────────────────────────────────
5010
- ensureSchema() {
5011
- const version = this.db.prepare("PRAGMA user_version").get().user_version;
5012
- if (version !== 0 && version !== SCHEMA_VERSION) {
5013
- this.db.exec(
5014
- "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;"
5015
- );
5016
- }
5017
- this.db.exec(`
5018
- CREATE TABLE IF NOT EXISTS ingest_state (
5019
- file TEXT PRIMARY KEY,
5020
- bytes INTEGER NOT NULL
5021
- );
5022
- CREATE TABLE IF NOT EXISTS provider_daily (
5023
- day TEXT NOT NULL,
5024
- provider_id TEXT NOT NULL,
5025
- model_id TEXT NOT NULL,
5026
- attempts INTEGER NOT NULL DEFAULT 0,
5027
- completed INTEGER NOT NULL DEFAULT 0,
5028
- failed INTEGER NOT NULL DEFAULT 0,
5029
- retries INTEGER NOT NULL DEFAULT 0,
5030
- fallbacks INTEGER NOT NULL DEFAULT 0,
5031
- input_tokens INTEGER NOT NULL DEFAULT 0,
5032
- output_tokens INTEGER NOT NULL DEFAULT 0,
5033
- cache_read_tokens INTEGER NOT NULL DEFAULT 0,
5034
- cache_write_tokens INTEGER NOT NULL DEFAULT 0,
5035
- duration_ms_total REAL NOT NULL DEFAULT 0,
5036
- duration_ms_max REAL NOT NULL DEFAULT 0,
5037
- duration_count INTEGER NOT NULL DEFAULT 0,
5038
- PRIMARY KEY (day, provider_id, model_id)
5039
- );
5040
- CREATE TABLE IF NOT EXISTS task_outcomes (
5041
- task_id TEXT PRIMARY KEY,
5042
- run_id TEXT NOT NULL DEFAULT '',
5043
- board_id TEXT NOT NULL DEFAULT '',
5044
- session_id TEXT NOT NULL DEFAULT '',
5045
- agent_id TEXT NOT NULL DEFAULT '',
5046
- status TEXT NOT NULL DEFAULT 'started',
5047
- started_at TEXT,
5048
- ended_at TEXT,
5049
- duration_ms REAL,
5050
- retries INTEGER NOT NULL DEFAULT 0,
5051
- verification_failures INTEGER NOT NULL DEFAULT 0
5052
- );
5053
- CREATE TABLE IF NOT EXISTS file_lineage (
5054
- event_id TEXT PRIMARY KEY,
5055
- path TEXT NOT NULL,
5056
- path_key TEXT NOT NULL,
5057
- operation TEXT NOT NULL,
5058
- occurred_at TEXT NOT NULL,
5059
- session_id TEXT NOT NULL DEFAULT '',
5060
- agent_id TEXT NOT NULL DEFAULT '',
5061
- task_id TEXT NOT NULL DEFAULT '',
5062
- board_id TEXT NOT NULL DEFAULT '',
5063
- run_id TEXT NOT NULL DEFAULT '',
5064
- tool_name TEXT NOT NULL DEFAULT '',
5065
- provider_id TEXT NOT NULL DEFAULT '',
5066
- model_id TEXT NOT NULL DEFAULT '',
5067
- source TEXT NOT NULL DEFAULT ''
5068
- );
5069
- -- Lookups filter on the case-normalized path_key (matching the query
5070
- -- engine); the path column retains original casing for display.
5071
- CREATE INDEX IF NOT EXISTS idx_file_lineage_path ON file_lineage(path_key, occurred_at);
5072
- CREATE INDEX IF NOT EXISTS idx_file_lineage_task ON file_lineage(task_id);
5073
- CREATE TABLE IF NOT EXISTS token_cost (
5074
- scope_key TEXT PRIMARY KEY,
5075
- day TEXT NOT NULL,
5076
- occurred_at TEXT NOT NULL,
5077
- sequence INTEGER NOT NULL,
5078
- cost REAL NOT NULL
5079
- );
5080
- -- Backing store for defaultSummary(): per-day scalar counters plus
5081
- -- dedup sets, populated for every ingested event (not just the
5082
- -- provider/task/file families above).
5083
- CREATE TABLE IF NOT EXISTS daily_counters (
5084
- day TEXT PRIMARY KEY,
5085
- tool_calls INTEGER NOT NULL DEFAULT 0,
5086
- completed_tools INTEGER NOT NULL DEFAULT 0,
5087
- failed_tools INTEGER NOT NULL DEFAULT 0,
5088
- tool_duration_ms_total REAL NOT NULL DEFAULT 0,
5089
- tool_duration_ms_max REAL NOT NULL DEFAULT 0,
5090
- tool_duration_count INTEGER NOT NULL DEFAULT 0,
5091
- processes INTEGER NOT NULL DEFAULT 0,
5092
- failed_processes INTEGER NOT NULL DEFAULT 0,
5093
- file_events_all INTEGER NOT NULL DEFAULT 0,
5094
- decisions INTEGER NOT NULL DEFAULT 0,
5095
- escalations INTEGER NOT NULL DEFAULT 0,
5096
- agent_events INTEGER NOT NULL DEFAULT 0,
5097
- failures INTEGER NOT NULL DEFAULT 0,
5098
- cancellations INTEGER NOT NULL DEFAULT 0
5099
- );
5100
- CREATE TABLE IF NOT EXISTS family_daily (
5101
- day TEXT NOT NULL,
5102
- family TEXT NOT NULL,
5103
- count INTEGER NOT NULL DEFAULT 0,
5104
- failure_count INTEGER NOT NULL DEFAULT 0,
5105
- PRIMARY KEY (day, family)
5106
- );
5107
- CREATE TABLE IF NOT EXISTS agent_daily (day TEXT NOT NULL, agent_id TEXT NOT NULL, PRIMARY KEY (day, agent_id));
5108
- CREATE TABLE IF NOT EXISTS logical_request_daily (day TEXT NOT NULL, logical_request_id TEXT NOT NULL, PRIMARY KEY (day, logical_request_id));
5109
- CREATE TABLE IF NOT EXISTS file_seen_daily (day TEXT NOT NULL, path_key TEXT NOT NULL, PRIMARY KEY (day, path_key));
5110
- PRAGMA user_version = ${SCHEMA_VERSION};
5111
- `);
5112
- }
5113
- loadOffsets() {
5114
- const rows = this.db.prepare("SELECT file, bytes FROM ingest_state").all();
5115
- return new Map(rows.map((row) => [row.file, Number(row.bytes)]));
5116
- }
5117
- pruneOffsets(existingFiles) {
5118
- const keep = new Set(
5119
- existingFiles.map((file) => normalizeKey(path8.relative(this.directory, file)))
5120
- );
5121
- for (const row of this.db.prepare("SELECT file FROM ingest_state").all()) {
5122
- if (!keep.has(row.file))
5123
- this.db.prepare("DELETE FROM ingest_state WHERE file = ?").run(row.file);
5124
- }
5125
- }
5126
- /** Read complete lines appended after `consumed` bytes. The trailing
5127
- * partial line of an actively-written partition is left for the next
5128
- * refresh — `ingest_state.bytes` only ever advances past full lines. */
5129
- async ingestFile(file, key, consumed, result) {
5130
- let handle;
5131
- try {
5132
- handle = await fs5.open(file, "r");
5133
- } catch {
5134
- return false;
5135
- }
5136
- try {
5137
- const size = (await handle.stat()).size;
5138
- if (size <= consumed) return false;
5139
- let position = consumed;
5140
- let remainder = Buffer.alloc(0);
5141
- let advanced = consumed;
5142
- this.db.exec("BEGIN");
5143
- try {
5144
- while (position < size) {
5145
- const length = Math.min(READ_CHUNK_BYTES, size - position);
5146
- const buffer = Buffer.allocUnsafe(length);
5147
- const { bytesRead } = await handle.read(buffer, 0, length, position);
5148
- if (bytesRead <= 0) break;
5149
- position += bytesRead;
5150
- const data = remainder.length > 0 ? Buffer.concat([remainder, buffer.subarray(0, bytesRead)]) : buffer.subarray(0, bytesRead);
5151
- const lastNewline = data.lastIndexOf(10);
5152
- if (lastNewline < 0) {
5153
- remainder = Buffer.from(data);
5154
- continue;
5155
- }
5156
- for (const line of data.subarray(0, lastNewline).toString("utf8").split("\n")) {
5157
- const trimmed = line.trim();
5158
- if (!trimmed) continue;
5159
- try {
5160
- this.ingestEvent(JSON.parse(trimmed));
5161
- result.ingestedEvents++;
5162
- } catch {
5163
- result.invalidLines++;
5164
- }
5165
- }
5166
- advanced += lastNewline + 1;
5167
- remainder = Buffer.from(data.subarray(lastNewline + 1));
5168
- }
5169
- this.db.prepare(
5170
- "INSERT INTO ingest_state (file, bytes) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET bytes = excluded.bytes"
5171
- ).run(key, advanced);
5172
- this.db.exec("COMMIT");
5173
- } catch (error) {
5174
- this.db.exec("ROLLBACK");
5175
- throw error;
5176
- }
5177
- result.ingestedBytes += advanced - consumed;
5178
- return advanced > consumed;
5179
- } finally {
5180
- await handle.close();
5181
- }
5182
- }
5183
- ingestEvent(event) {
5184
- if (typeof event?.eventType !== "string" || !event.scope) return;
5185
- this.ingestDailyCounters(event);
5186
- const type = event.eventType;
5187
- if (type.startsWith("provider.attempt.") || type === "provider.fallback") {
5188
- this.ingestProvider(event);
5189
- } else if (type === "token.accounted") {
5190
- this.ingestTokenCost(event);
5191
- } else if (/^(?:sdd|subagent|kanban)\.task[._]/.test(type)) {
5192
- this.ingestTask(event);
5193
- } else if (type === "file.event" || /^file\.(?:tool|external)\./.test(type)) {
5194
- this.ingestFileEvent(event);
5195
- }
5196
- }
5197
- /** Runs for every ingested event (not just the type-specific branches
5198
- * below) — mirrors query.ts's updateSummary() closely enough that
5199
- * defaultSummary() matches what a raw scan of the same window would say. */
5200
- ingestDailyCounters(event) {
5201
- const day = eventDay(event);
5202
- this.db.prepare("INSERT OR IGNORE INTO daily_counters (day) VALUES (?)").run(day);
5203
- const bump = (sql, ...params) => this.db.prepare(`UPDATE daily_counters SET ${sql} WHERE day = ?`).run(...params, day);
5204
- const family = signalFamily(event);
5205
- const failed = isTerminalFailure(event) ? 1 : 0;
5206
- this.db.prepare(
5207
- `INSERT INTO family_daily (day, family, count, failure_count) VALUES (?, ?, 1, ?)
5208
- ON CONFLICT(day, family) DO UPDATE SET count = count + 1, failure_count = failure_count + excluded.failure_count`
5209
- ).run(day, family, failed);
5210
- if (failed) bump("failures = failures + 1");
5211
- if (event.outcome === "cancelled" || event.outcome === "abandoned") bump("cancellations = cancellations + 1");
5212
- if (family === "agent") bump("agent_events = agent_events + 1");
5213
- if (event.correlation.logicalRequestId) {
5214
- this.db.prepare("INSERT OR IGNORE INTO logical_request_daily (day, logical_request_id) VALUES (?, ?)").run(day, event.correlation.logicalRequestId);
5215
- }
5216
- if (event.scope.agentId) {
5217
- this.db.prepare("INSERT OR IGNORE INTO agent_daily (day, agent_id) VALUES (?, ?)").run(day, event.scope.agentId);
5218
- }
5219
- const type = event.eventType;
5220
- if (type === "decision.requested") bump("decisions = decisions + 1");
5221
- else if (type === "decision.escalated") bump("escalations = escalations + 1");
5222
- else if (type === "tool.started") bump("tool_calls = tool_calls + 1");
5223
- else if (type === "tool.executed" || type === "tool.failed") {
5224
- const dur = durationMs2(event);
5225
- const durationCount = dur > 0 ? 1 : 0;
5226
- bump(
5227
- `${type === "tool.executed" ? "completed_tools" : "failed_tools"} = ${type === "tool.executed" ? "completed_tools" : "failed_tools"} + 1,
5228
- tool_duration_ms_total = tool_duration_ms_total + ?, tool_duration_ms_max = MAX(tool_duration_ms_max, ?), tool_duration_count = tool_duration_count + ?`,
5229
- dur,
5230
- dur,
5231
- durationCount
5232
- );
5233
- } else if (type === "process.started") bump("processes = processes + 1");
5234
- else if (type === "process.completed" && event.outcome === "failure") bump("failed_processes = failed_processes + 1");
5235
- if (event.resource?.kind === "file" || type.startsWith("file.")) {
5236
- bump("file_events_all = file_events_all + 1");
5237
- if (event.resource?.path) {
5238
- this.db.prepare("INSERT OR IGNORE INTO file_seen_daily (day, path_key) VALUES (?, ?)").run(day, normalizePathKey(event.resource.path));
5239
- }
5240
- }
5241
- }
5242
- ingestProvider(event) {
5243
- const day = eventDay(event);
5244
- const providerId = event.runtime?.providerId ?? asString(readPath2(event.attributes ?? {}, "from.providerId")) ?? "";
5245
- const modelId = event.runtime?.modelId ?? asString(readPath2(event.attributes ?? {}, "from.model")) ?? "";
5246
- if (!providerId && !modelId) return;
5247
- this.db.prepare("INSERT OR IGNORE INTO provider_daily (day, provider_id, model_id) VALUES (?, ?, ?)").run(day, providerId, modelId);
5248
- const update = (sql, ...params) => this.db.prepare(
5249
- `UPDATE provider_daily SET ${sql} WHERE day = ? AND provider_id = ? AND model_id = ?`
5250
- ).run(...params, day, providerId, modelId);
5251
- const duration = durationMs2(event);
5252
- switch (event.eventType) {
5253
- case "provider.attempt.started":
5254
- update("attempts = attempts + 1");
5255
- break;
5256
- case "provider.attempt.completed":
5257
- update(
5258
- "completed = completed + 1, input_tokens = input_tokens + ?, output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?, cache_write_tokens = cache_write_tokens + ?, duration_ms_total = duration_ms_total + ?, duration_ms_max = MAX(duration_ms_max, ?), duration_count = duration_count + ?",
5259
- numberAt2(event, "usage.input"),
5260
- numberAt2(event, "usage.output"),
5261
- numberAt2(event, "usage.cacheRead"),
5262
- numberAt2(event, "usage.cacheWrite"),
5263
- duration,
5264
- duration,
5265
- duration > 0 ? 1 : 0
5266
- );
5267
- break;
5268
- case "provider.attempt.failed":
5269
- update(
5270
- "failed = failed + 1, retries = retries + ?, duration_ms_total = duration_ms_total + ?, duration_ms_max = MAX(duration_ms_max, ?), duration_count = duration_count + ?",
5271
- event.attributes?.retryScheduled === true ? 1 : 0,
5272
- duration,
5273
- duration,
5274
- duration > 0 ? 1 : 0
5275
- );
5276
- break;
5277
- case "provider.fallback":
5278
- update("fallbacks = fallbacks + 1");
5279
- break;
5280
- default:
5281
- break;
5282
- }
5283
- }
5284
- ingestTokenCost(event) {
5285
- const cost = readPath2(event.attributes ?? {}, "cost.total");
5286
- if (typeof cost !== "number" || !Number.isFinite(cost)) return;
5287
- const scopeKey2 = `${event.scope.projectId ?? ""}\0${event.scope.sessionId ?? ""}\0${event.scope.agentId ?? ""}`;
5288
- const occurredAt = event.occurredAt ?? event.observedAt;
5289
- this.db.prepare(
5290
- `INSERT INTO token_cost (scope_key, day, occurred_at, sequence, cost) VALUES (?, ?, ?, ?, ?)
5291
- ON CONFLICT(scope_key) DO UPDATE SET
5292
- day = excluded.day, occurred_at = excluded.occurred_at,
5293
- sequence = excluded.sequence, cost = excluded.cost
5294
- WHERE excluded.occurred_at > token_cost.occurred_at
5295
- OR (excluded.occurred_at = token_cost.occurred_at AND excluded.sequence > token_cost.sequence)`
5296
- ).run(scopeKey2, eventDay(event), occurredAt, event.sequence, cost);
5297
- }
5298
- ingestTask(event) {
5299
- const attributes = event.attributes ?? {};
5300
- const taskId = event.scope.taskId ?? stringAt(attributes, "taskId");
5301
- if (!taskId) return;
5302
- const occurredAt = event.occurredAt ?? event.observedAt;
5303
- this.db.prepare("INSERT OR IGNORE INTO task_outcomes (task_id) VALUES (?)").run(taskId);
5304
- const set = (sql, ...params) => this.db.prepare(`UPDATE task_outcomes SET ${sql} WHERE task_id = ?`).run(...params, taskId);
5305
- const lineage = [
5306
- ["run_id", stringAt(attributes, "runId")],
5307
- ["board_id", event.scope.kanbanBoardId ?? stringAt(attributes, "boardId")],
5308
- ["session_id", event.scope.sessionId],
5309
- ["agent_id", event.scope.agentId ?? stringAt(attributes, "subagentId")]
5310
- ];
5311
- for (const [column, value] of lineage) {
5312
- if (value) set(`${column} = ?`, value);
5313
- }
5314
- const base = event.eventType.replace(/^(?:sdd|subagent|kanban)\.task[._]/, "");
5315
- switch (base) {
5316
- case "started":
5317
- set("status = 'started', started_at = COALESCE(started_at, ?)", occurredAt);
5318
- break;
5319
- case "completed":
5320
- set(
5321
- "status = 'completed', ended_at = ?, duration_ms = ?",
5322
- occurredAt,
5323
- numberOrDuration(event, attributes)
5324
- );
5325
- break;
5326
- case "failed":
5327
- set("status = 'failed', ended_at = ?", occurredAt);
5328
- break;
5329
- case "retrying":
5330
- set("retries = retries + 1");
5331
- break;
5332
- case "verification_failed":
5333
- set("verification_failures = verification_failures + 1");
5334
- break;
5335
- case "merged":
5336
- set("status = 'merged'");
5337
- break;
5338
- case "conflict":
5339
- set("status = 'conflict'");
5340
- break;
5341
- default:
5342
- break;
5343
- }
5344
- }
5345
- ingestFileEvent(event) {
5346
- const attributes = event.attributes ?? {};
5347
- const operation = stringAt(attributes, "operation") ?? "";
5348
- if (!operation || operation === "read") return;
5349
- const filePath = event.resource?.path ?? stringAt(attributes, "filePath");
5350
- if (!filePath) return;
5351
- this.db.prepare(
5352
- `INSERT OR IGNORE INTO file_lineage
5353
- (event_id, path, path_key, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
5354
- tool_name, provider_id, model_id, source)
5355
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
5356
- ).run(
5357
- event.eventId,
5358
- normalizeKey(filePath),
5359
- normalizePathKey(filePath),
5360
- operation,
5361
- event.occurredAt ?? event.observedAt,
5362
- event.scope.sessionId ?? "",
5363
- event.scope.agentId ?? "",
5364
- event.scope.taskId ?? stringAt(attributes, "taskId") ?? "",
5365
- event.scope.kanbanBoardId ?? stringAt(attributes, "boardId") ?? "",
5366
- stringAt(attributes, "runId") ?? "",
5367
- stringAt(attributes, "toolName") ?? "",
5368
- event.runtime?.providerId ?? stringAt(attributes, "provider") ?? "",
5369
- event.runtime?.modelId ?? stringAt(attributes, "model") ?? "",
5370
- stringAt(attributes, "source") ?? (event.eventType === "file.event" ? "tool" : "external")
5371
- );
5372
- }
5373
- };
5374
- function eventDay(event) {
5375
- return (event.occurredAt ?? event.observedAt).slice(0, 10);
5376
- }
5377
- function durationMs2(event) {
5378
- const value = Number(event.durationNs ?? 0) / 1e6;
5379
- return Number.isFinite(value) && value > 0 ? value : 0;
5380
- }
5381
- function numberOrDuration(event, attributes) {
5382
- const explicit = attributes.durationMs;
5383
- if (typeof explicit === "number" && Number.isFinite(explicit)) return explicit;
5384
- return durationMs2(event);
5385
- }
5386
- function numberAt2(event, dotPath) {
5387
- const value = readPath2(event.attributes ?? {}, dotPath);
5388
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
4764
+ MAX_SKILL_BODY_CHARS: 16e3,
4765
+ /**
4766
+ * Per-resource cap when the `skill` tool loads a bundled file
4767
+ * (`scripts/`, `references/`, `assets/`). ~8k tokens.
4768
+ *
4769
+ * Consumer: `skill` tool `loadResource`.
4770
+ */
4771
+ MAX_RESOURCE_CHARS: 32e3,
4772
+ /**
4773
+ * Maximum number of bundled resource paths the `skill` tool lists in one
4774
+ * response. Caps a pathological skill (huge `assets/` tree) from blowing up
4775
+ * the tool result.
4776
+ *
4777
+ * Consumer: `skill` tool resource listing.
4778
+ */
4779
+ MAX_LISTED_RESOURCES: 100,
4780
+ /**
4781
+ * Max size of a single installed skill file (SKILL.md or a bundled resource).
4782
+ * Guards against a malicious registry skill shipping a multi-MB blob that
4783
+ * then flows into the prompt. 100KB.
4784
+ *
4785
+ * Consumer: `SkillInstaller.install` / `importFromDir`.
4786
+ */
4787
+ MAX_SKILL_FILE_SIZE: 100 * 1024,
4788
+ /**
4789
+ * Max size of a GitHub tarball downloaded by the skill installer. Guards
4790
+ * against a repo accidentally (or maliciously) shipping a giant tarball that
4791
+ * would be extracted into a temp dir. 50MB.
4792
+ *
4793
+ * Consumer: `downloadGitHubTarball`.
4794
+ */
4795
+ MAX_TARBALL_SIZE: 50 * 1024 * 1024,
4796
+ /**
4797
+ * Maximum size of a GitHub skill tarball after gzip decompression. The
4798
+ * compressed download limit alone does not protect against highly
4799
+ * compressible archives expanding until the process runs out of memory.
4800
+ *
4801
+ * Consumer: `downloadGitHubTarball`.
4802
+ */
4803
+ MAX_UNCOMPRESSED_TARBALL_SIZE: 64 * 1024 * 1024,
4804
+ /**
4805
+ * Default total char budget for skill bodies injected in eager mode when
4806
+ * `config.skills.eagerMaxChars` is unset. ~6k tokens. Skills are injected
4807
+ * highest-priority first; once the budget is exhausted the remaining skills
4808
+ * are listed as a manifest the agent loads via the `skill` tool. Set very
4809
+ * high to effectively disable budgeting.
4810
+ *
4811
+ * Consumer: `DefaultSystemPromptBuilder.buildMemoryAndSkills` (default for
4812
+ * `skillEagerMaxChars`).
4813
+ */
4814
+ EAGER_DEFAULT_MAX_CHARS: 24e3,
4815
+ /**
4816
+ * Default total char budget for compact (token-saving) skill bodies injected
4817
+ * by `buildCompactSkillBodiesText`. Mirrors the eager ceiling so compact mode
4818
+ * can never inject more total skill text than eager mode. Before this bound
4819
+ * existed, compact mode injected EVERY skill's compact body with no cap, so
4820
+ * the section grew linearly with the number of installed skills. Skills past
4821
+ * the budget overflow to a name list the agent loads via the `skill` tool.
4822
+ *
4823
+ * Consumer: `buildCompactSkillBodiesText` (default for its `budget` param).
4824
+ */
4825
+ COMPACT_DEFAULT_MAX_CHARS: 24e3,
4826
+ /**
4827
+ * Auto-compact body limits (the token-saving fallback used when a skill has
4828
+ * no hand-crafted `SKILL.save.md`). The Overview and Rules sections are
4829
+ * extracted and trimmed to these char budgets, then the total is capped.
4830
+ *
4831
+ * Consumer: `DefaultSkillLoader.compactSkillBody`.
4832
+ */
4833
+ COMPACT_OVERVIEW_MAX: 200,
4834
+ COMPACT_RULES_MAX: 350,
4835
+ COMPACT_TOTAL_MAX: 450,
4836
+ /**
4837
+ * Max length of a skill name (agentskills.io spec). Enforced by the
4838
+ * frontmatter validator's format regex plus this length bound.
4839
+ *
4840
+ * Consumer: `isValidSkillNameFormat`.
4841
+ */
4842
+ SKILL_NAME_MAX_LEN: 64,
4843
+ /**
4844
+ * Soft line limit for a SKILL.md body, per the bundled `skill-creator` rule.
4845
+ * Not enforced in code (a skill can exceed it) — surfaced by `/skill-gen
4846
+ * validate` and the `skill-creator` skill as guidance to move deep material
4847
+ * into `references/`.
4848
+ *
4849
+ * Consumer: `skill-creator` skill, `/skill-gen validate` advisory.
4850
+ */
4851
+ SKILL_BODY_LINE_LIMIT: 500
4852
+ };
4853
+
4854
+ // src/skills/frontmatter.ts
4855
+ var SCALAR_KEYS = /* @__PURE__ */ new Set([
4856
+ "name",
4857
+ "description",
4858
+ "trigger",
4859
+ "version",
4860
+ "license",
4861
+ "compatibility"
4862
+ ]);
4863
+ function parseSkillFrontmatter(raw) {
4864
+ const text = normalizeLineEndings(raw);
4865
+ if (!text.startsWith("---")) return {};
4866
+ const end = text.indexOf("\n---", 4);
4867
+ if (end === -1) return {};
4868
+ return parseFrontmatterBlock(text.slice(4, end));
5389
4869
  }
5390
- function readPath2(value, key) {
5391
- return key.split(".").reduce(
5392
- (current, part) => current && typeof current === "object" ? current[part] : void 0,
5393
- value
5394
- );
4870
+ function stripFrontmatter(raw) {
4871
+ const text = normalizeLineEndings(raw);
4872
+ if (!text.startsWith("---")) return text;
4873
+ const end = text.indexOf("\n---", 4);
4874
+ if (end === -1) return text;
4875
+ let body = text.slice(end + 4);
4876
+ if (body.startsWith("\n")) body = body.slice(1);
4877
+ return body;
5395
4878
  }
5396
- function stringAt(record, key) {
5397
- const value = record[key];
5398
- return typeof value === "string" && value.length > 0 ? value : void 0;
4879
+ function normalizeLineEndings(s) {
4880
+ return s.replace(/\r\n?/g, "\n");
5399
4881
  }
5400
- function asString(value) {
5401
- return typeof value === "string" && value.length > 0 ? value : void 0;
4882
+ function parseFrontmatterBlock(block) {
4883
+ const out = {};
4884
+ const lines = block.split("\n");
4885
+ let i = 0;
4886
+ while (i < lines.length) {
4887
+ const line = lines[i] ?? "";
4888
+ const m = /^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/.exec(line);
4889
+ if (!m) {
4890
+ i++;
4891
+ continue;
4892
+ }
4893
+ const key = m[1] ?? "";
4894
+ const rest = (m[2] ?? "").trim();
4895
+ if (key === "metadata") {
4896
+ const map = {};
4897
+ i++;
4898
+ while (i < lines.length) {
4899
+ const sub = lines[i] ?? "";
4900
+ const sm = /^\s+([a-zA-Z0-9_.-]+):\s*(.*)$/.exec(sub);
4901
+ if (!sm) break;
4902
+ map[sm[1] ?? ""] = unquote((sm[2] ?? "").trim());
4903
+ i++;
4904
+ }
4905
+ if (Object.keys(map).length > 0) out.metadata = map;
4906
+ continue;
4907
+ }
4908
+ if (rest === "|" || rest === ">") {
4909
+ const collected = [];
4910
+ i++;
4911
+ while (i < lines.length) {
4912
+ const sub = lines[i] ?? "";
4913
+ if (sub === "" || sub.startsWith(" ") || sub.startsWith(" ")) {
4914
+ collected.push(sub.replace(/^\s+/, ""));
4915
+ i++;
4916
+ } else break;
4917
+ }
4918
+ out[normalizeKey(key)] = collected.join("\n").trim();
4919
+ continue;
4920
+ }
4921
+ if (key === "allowed-tools" || key === "allowedTools" || key === "required-capabilities" || key === "requiredCapabilities" || key === "required-tools" || key === "requiredTools" || key === "optional-capabilities" || key === "optionalCapabilities") {
4922
+ const values = rest.replace(/^\[/, "").replace(/\]$/, "").split(/[\s,]+/).map(unquote).filter(Boolean);
4923
+ out[normalizeKey(key)] = values;
4924
+ i++;
4925
+ continue;
4926
+ }
4927
+ if (SCALAR_KEYS.has(key)) {
4928
+ out[key] = unquote(rest);
4929
+ i++;
4930
+ continue;
4931
+ }
4932
+ i++;
4933
+ }
4934
+ return out;
5402
4935
  }
5403
- function clampLimit(limit, fallback) {
5404
- if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) return fallback;
5405
- return Math.min(Math.floor(limit), 1e4);
4936
+ function normalizeKey(key) {
4937
+ if (key === "allowed-tools") return "allowedTools";
4938
+ if (key === "required-capabilities") return "requiredCapabilities";
4939
+ if (key === "required-tools") return "requiredTools";
4940
+ if (key === "optional-capabilities") return "optionalCapabilities";
4941
+ return key;
5406
4942
  }
5407
- function normalizeKey(value) {
5408
- return value.replaceAll("\\", "/");
4943
+ function unquote(s) {
4944
+ if (s.length >= 2 && (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'"))) {
4945
+ return s.slice(1, -1);
4946
+ }
4947
+ return s;
5409
4948
  }
5410
- function normalizePathKey(value) {
5411
- return value.replaceAll("\\", "/").replace(/^\.\//, "").toLowerCase();
4949
+ function isValidSkillNameFormat(name) {
4950
+ return name.length >= 1 && name.length <= 64 && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(name);
5412
4951
  }
5413
-
5414
- // src/chronicle/legacy-journal-import.ts
5415
- import * as fs6 from "node:fs/promises";
5416
- import * as path9 from "node:path";
5417
- var ChronicleImportError = class extends Error {
5418
- constructor(message, day, sequence) {
5419
- super(message);
5420
- this.day = day;
5421
- this.sequence = sequence;
5422
- this.name = "ChronicleImportError";
4952
+ function validateSkillName(name, parentDirName) {
4953
+ const errors = [];
4954
+ if (!name || name.trim().length === 0) {
4955
+ errors.push("name is empty");
4956
+ return errors;
5423
4957
  }
5424
- day;
5425
- sequence;
5426
- };
5427
- async function discoverFamilies(directory) {
5428
- let entries;
5429
- try {
5430
- entries = await fs6.readdir(directory);
5431
- } catch {
5432
- return [];
4958
+ if (name.length > 64) errors.push(`name is ${name.length} characters (max 64)`);
4959
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) {
4960
+ errors.push(
4961
+ "name must be lowercase letters, digits, and single hyphens only (no leading/trailing/consecutive hyphens)"
4962
+ );
5433
4963
  }
5434
- const bases = /* @__PURE__ */ new Set();
5435
- for (const entry of entries) {
5436
- const match = PARTITION_FILE_PATTERN.exec(entry);
5437
- if (match?.[1]) bases.add(match[1]);
4964
+ if (parentDirName !== void 0 && name !== parentDirName) {
4965
+ errors.push(`name "${name}" must match its parent directory "${parentDirName}"`);
5438
4966
  }
5439
- return [...bases].sort();
4967
+ return errors;
5440
4968
  }
5441
- function dayOfFamily(familyBase) {
5442
- return familyBase.replace(/\.events$/u, "");
4969
+
4970
+ // src/core/system-prompt-skill-text.ts
4971
+ function capSkillBody(body) {
4972
+ const max = SKILL_LIMITS.MAX_SKILL_BODY_CHARS;
4973
+ if (body.length <= max) return body;
4974
+ const budget = max - 1;
4975
+ const cut = body.lastIndexOf("\n\n", budget);
4976
+ const truncated = cut > budget / 2 ? body.slice(0, cut) : body.slice(0, budget);
4977
+ return truncated + "\u2026";
5443
4978
  }
5444
- async function importLegacyChronicleJournal(journal, directory) {
5445
- if (journal.hasImportedLegacyJournal()) {
5446
- return {
5447
- alreadyImported: true,
5448
- families: 0,
5449
- events: 0,
5450
- quarantined: journal.quarantinedFamilies()
5451
- };
5452
- }
5453
- const families = await discoverFamilies(directory);
5454
- const quarantined = [];
5455
- let importedEvents = 0;
5456
- let importedFamilies = 0;
5457
- for (const familyBase of families) {
5458
- const day = dayOfFamily(familyBase);
5459
- const basePath = path9.join(directory, `${familyBase}.jsonl`);
5460
- let familyEvents = 0;
5461
- if (journal.hasImportedDay(day)) continue;
4979
+
4980
+ // src/utils/instruction-file.ts
4981
+ import { readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
4982
+ import * as path8 from "node:path";
4983
+ import { fileURLToPath } from "node:url";
4984
+ var textCache = /* @__PURE__ */ new Map();
4985
+ var rootCandidates;
4986
+ function readBundledInstructionText(relativePath) {
4987
+ const cached = textCache.get(relativePath);
4988
+ if (cached !== void 0) return cached;
4989
+ let resolved = "";
4990
+ for (const root of instructionRootCandidates()) {
5462
4991
  try {
5463
- await journal.runFamilyImport(async (sink) => {
5464
- familyEvents = 0;
5465
- const checkpointResult = await readRetentionCheckpoint(basePath);
5466
- if (checkpointResult.error) {
5467
- throw new ChronicleImportError(checkpointResult.error, day, 0);
5468
- }
5469
- const checkpoint = checkpointResult.checkpoint;
5470
- if (checkpoint) sink.checkpoint(day, checkpoint.sequence, checkpoint.hash);
5471
- let expectedSequence = (checkpoint?.sequence ?? 0) + 1;
5472
- let previousHash = checkpoint?.hash ?? GENESIS_HASH;
5473
- for (const partition of await collectPartitions(basePath)) {
5474
- for await (const event of streamEntriesStrict(partition)) {
5475
- if (event.sequence !== expectedSequence) {
5476
- throw new ChronicleImportError(
5477
- `sequence gap in ${day}: expected ${expectedSequence}, found ${event.sequence}`,
5478
- day,
5479
- event.sequence
5480
- );
5481
- }
5482
- if (event.previousHash !== previousHash) {
5483
- throw new ChronicleImportError(
5484
- `previous hash mismatch in ${day} at sequence ${event.sequence}`,
5485
- day,
5486
- event.sequence
5487
- );
5488
- }
5489
- if (chronicleEventHash(event) !== event.hash) {
5490
- throw new ChronicleImportError(
5491
- `entry hash mismatch in ${day} at sequence ${event.sequence}`,
5492
- day,
5493
- event.sequence
5494
- );
5495
- }
5496
- sink.insert(day, event);
5497
- familyEvents += 1;
5498
- expectedSequence = event.sequence + 1;
5499
- previousHash = event.hash;
5500
- }
5501
- }
5502
- });
5503
- } catch (error) {
5504
- if (!(error instanceof ChronicleImportError)) throw error;
5505
- quarantined.push({ day, sequence: error.sequence, reason: error.message });
5506
- continue;
4992
+ resolved = readFileSync2(path8.join(root, relativePath), "utf8").trimEnd();
4993
+ break;
4994
+ } catch {
5507
4995
  }
5508
- if (familyEvents > 0) importedFamilies += 1;
5509
- importedEvents += familyEvents;
5510
4996
  }
5511
- journal.recordQuarantinedFamilies(quarantined);
5512
- journal.markLegacyJournalImported();
5513
- return {
5514
- alreadyImported: false,
5515
- families: importedFamilies,
5516
- events: importedEvents,
5517
- quarantined
5518
- };
4997
+ textCache.set(relativePath, resolved);
4998
+ return resolved;
4999
+ }
5000
+ function renderInstructionTemplate(template, values) {
5001
+ return template.replace(
5002
+ /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g,
5003
+ (match, key) => Object.hasOwn(values, key) ? values[key] ?? "" : match
5004
+ );
5005
+ }
5006
+ function instructionRootCandidates() {
5007
+ if (rootCandidates !== void 0) return rootCandidates;
5008
+ const here = path8.dirname(fileURLToPath(import.meta.url));
5009
+ const candidates = [
5010
+ path8.resolve(here, "../../instructions"),
5011
+ path8.resolve(here, "../instructions"),
5012
+ path8.resolve(here, "instructions")
5013
+ ];
5014
+ rootCandidates = candidates.sort((a, b) => Number(!isDirectory(a)) - Number(!isDirectory(b)));
5015
+ return rootCandidates;
5016
+ }
5017
+ function isDirectory(candidate) {
5018
+ try {
5019
+ return statSync2(candidate).isDirectory();
5020
+ } catch {
5021
+ return false;
5022
+ }
5519
5023
  }
5520
5024
 
5521
- // src/chronicle/sqlite-journal.ts
5522
- import { randomUUID as randomUUID3 } from "node:crypto";
5523
- import * as fs8 from "node:fs";
5524
- import { createRequire as createRequire2 } from "node:module";
5525
- import * as path11 from "node:path";
5526
-
5527
- // src/utils/index.ts
5528
- init_atomic_write();
5529
-
5530
- // src/utils/term.ts
5531
- var hasStdout = () => typeof process !== "undefined" && !!process.stdout;
5532
- function isStdoutTTY() {
5533
- return hasStdout() && Boolean(process.stdout.isTTY);
5025
+ // src/utils/ulid.ts
5026
+ import { randomBytes } from "node:crypto";
5027
+ var ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
5028
+ var ENCODING_LEN = ENCODING.length;
5029
+ var TIME_LEN = 10;
5030
+ var RANDOM_LEN = 16;
5031
+ function encodeTime(now, len) {
5032
+ let mod;
5033
+ let str = "";
5034
+ for (let i = len - 1; i >= 0; i--) {
5035
+ mod = now % ENCODING_LEN;
5036
+ str = ENCODING[mod] + str;
5037
+ now = (now - mod) / ENCODING_LEN;
5038
+ }
5039
+ return str;
5040
+ }
5041
+ function encodeRandom(len) {
5042
+ const bytes = randomBytes(len);
5043
+ let str = "";
5044
+ for (let i = 0; i < len; i++) {
5045
+ str += ENCODING[bytes[i] % ENCODING_LEN];
5046
+ }
5047
+ return str;
5048
+ }
5049
+ function ulid(seedTime = Date.now()) {
5050
+ return encodeTime(seedTime, TIME_LEN) + encodeRandom(RANDOM_LEN);
5534
5051
  }
5535
- var ESCAPE_TERMINATOR = Object.freeze({
5536
- BEL: "\x07",
5537
- // OSC 0 / OSC 2 (title)
5538
- ST: "\x1B\\"
5539
- // DCS / strict OSC
5540
- });
5541
5052
 
5542
- // src/utils/color.ts
5543
- var isColorTty = () => {
5544
- if (envFlag(process.env.NO_COLOR)) return false;
5545
- if (envFlag(process.env.FORCE_COLOR)) return true;
5546
- return isStdoutTTY();
5547
- };
5548
- function envFlag(value) {
5549
- if (value === void 0) return false;
5550
- if (value.trim() === "") return false;
5551
- return !/^(0|false|no|off)$/i.test(value.trim());
5053
+ // src/utils/slug.ts
5054
+ function slugify2(name, fallback = "prompt", maxLen = 64) {
5055
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, maxLen).replace(/-+$/g, "") || fallback;
5552
5056
  }
5553
- var COLOR = isColorTty();
5554
- var wrap = (open5, close) => (s) => COLOR ? `\x1B[${open5}m${s}\x1B[${close}m` : s;
5555
- var color = {
5556
- reset: wrap("0", "0"),
5557
- bold: wrap("1", "22"),
5558
- dim: wrap("2", "22"),
5559
- italic: wrap("3", "23"),
5560
- underline: wrap("4", "24"),
5561
- red: wrap("31", "39"),
5562
- green: wrap("32", "39"),
5563
- yellow: wrap("33", "39"),
5564
- blue: wrap("34", "39"),
5565
- magenta: wrap("35", "39"),
5566
- cyan: wrap("36", "39"),
5567
- gray: wrap("90", "39"),
5568
- amber: wrap("38;5;214", "39"),
5569
- pink: wrap("38;5;205", "39"),
5570
- bgRed: wrap("41", "49"),
5571
- bgGreen: wrap("42", "49")
5572
- };
5573
5057
 
5574
- // src/utils/config-json.ts
5575
- import * as fs7 from "node:fs/promises";
5576
- async function readJsonObjectFile(filePath) {
5058
+ // src/chronicle/sqlite-query.ts
5059
+ var MAX_LIMIT = 1e4;
5060
+ function encodeCursor2(cursor) {
5061
+ return Buffer.from(`${cursor.day}:${cursor.sequence}`, "utf8").toString("base64url");
5062
+ }
5063
+ function decodeCursor2(raw) {
5064
+ if (!raw) return void 0;
5577
5065
  try {
5578
- const parsed = JSON.parse(await fs7.readFile(filePath, "utf8"));
5579
- return isJsonObject(parsed) ? parsed : {};
5066
+ const [day, sequence] = Buffer.from(raw, "base64url").toString("utf8").split(":");
5067
+ if (!day || sequence === void 0) return void 0;
5068
+ const parsed = Number(sequence);
5069
+ return Number.isSafeInteger(parsed) ? { day, sequence: parsed } : void 0;
5580
5070
  } catch {
5581
- return {};
5071
+ return void 0;
5582
5072
  }
5583
5073
  }
5584
- function isJsonObject(value) {
5585
- return typeof value === "object" && value !== null && !Array.isArray(value);
5074
+ function pushDown(query) {
5075
+ const clauses = [];
5076
+ const params = [];
5077
+ const eq = (column, value) => {
5078
+ if (value === void 0) return;
5079
+ clauses.push(`${column} = ?`);
5080
+ params.push(value);
5081
+ };
5082
+ eq("event_id", query.eventId);
5083
+ eq("project_id", query.projectId);
5084
+ eq("session_id", query.sessionId);
5085
+ eq("agent_id", query.agentId);
5086
+ eq("task_id", query.taskId);
5087
+ eq("trace_id", query.traceId);
5088
+ eq("logical_request_id", query.logicalRequestId);
5089
+ eq("resource_kind", query.resourceKind);
5090
+ eq("resource_id", query.resourceId);
5091
+ if (query.eventTypes?.length) {
5092
+ clauses.push(`event_type IN (${query.eventTypes.map(() => "?").join(",")})`);
5093
+ params.push(...query.eventTypes);
5094
+ }
5095
+ if (query.outcomes?.length) {
5096
+ clauses.push(`outcome IN (${query.outcomes.map(() => "?").join(",")})`);
5097
+ params.push(...query.outcomes);
5098
+ }
5099
+ if (query.from) {
5100
+ clauses.push("occurred_at >= ?");
5101
+ params.push(query.from);
5102
+ }
5103
+ if (query.to) {
5104
+ clauses.push("occurred_at <= ?");
5105
+ params.push(query.to);
5106
+ }
5107
+ return { clause: clauses.length ? clauses.join(" AND ") : "1=1", params };
5586
5108
  }
5587
-
5588
- // src/skills/limits.ts
5589
- var SKILL_LIMITS = {
5590
- /**
5591
- * Per-skill body cap when injecting a full skill body into the system prompt
5592
- * (eager mode), and when returning a skill body from the `skill` tool.
5593
- * ~4k tokens. Oversized bodies are truncated at a paragraph boundary.
5594
- *
5595
- * Consumers: `system-prompt-builder.capSkillBody`, `skill` tool body return.
5596
- */
5597
- MAX_SKILL_BODY_CHARS: 16e3,
5598
- /**
5599
- * Per-resource cap when the `skill` tool loads a bundled file
5600
- * (`scripts/`, `references/`, `assets/`). ~8k tokens.
5601
- *
5602
- * Consumer: `skill` tool `loadResource`.
5603
- */
5604
- MAX_RESOURCE_CHARS: 32e3,
5605
- /**
5606
- * Maximum number of bundled resource paths the `skill` tool lists in one
5607
- * response. Caps a pathological skill (huge `assets/` tree) from blowing up
5608
- * the tool result.
5609
- *
5610
- * Consumer: `skill` tool resource listing.
5611
- */
5612
- MAX_LISTED_RESOURCES: 100,
5613
- /**
5614
- * Max size of a single installed skill file (SKILL.md or a bundled resource).
5615
- * Guards against a malicious registry skill shipping a multi-MB blob that
5616
- * then flows into the prompt. 100KB.
5617
- *
5618
- * Consumer: `SkillInstaller.install` / `importFromDir`.
5619
- */
5620
- MAX_SKILL_FILE_SIZE: 100 * 1024,
5621
- /**
5622
- * Max size of a GitHub tarball downloaded by the skill installer. Guards
5623
- * against a repo accidentally (or maliciously) shipping a giant tarball that
5624
- * would be extracted into a temp dir. 50MB.
5625
- *
5626
- * Consumer: `downloadGitHubTarball`.
5627
- */
5628
- MAX_TARBALL_SIZE: 50 * 1024 * 1024,
5629
- /**
5630
- * Maximum size of a GitHub skill tarball after gzip decompression. The
5631
- * compressed download limit alone does not protect against highly
5632
- * compressible archives expanding until the process runs out of memory.
5633
- *
5634
- * Consumer: `downloadGitHubTarball`.
5635
- */
5636
- MAX_UNCOMPRESSED_TARBALL_SIZE: 64 * 1024 * 1024,
5109
+ var ChronicleSqliteQueryEngine = class {
5110
+ constructor(db, options = {}) {
5111
+ this.db = db;
5112
+ this.batchSize = Math.max(1, options.batchSize ?? 1e3);
5113
+ }
5114
+ db;
5115
+ diagnostics = { sourceFiles: 1, invalidLines: 0 };
5116
+ batchSize;
5117
+ async query(query = {}) {
5118
+ const order = query.order ?? "desc";
5119
+ const limit = Math.max(1, Math.min(query.limit ?? 100, MAX_LIMIT));
5120
+ const cursor = decodeCursor2(query.cursor);
5121
+ const pushed = pushDown(query);
5122
+ const direction = order === "asc" ? "ASC" : "DESC";
5123
+ const comparison = order === "asc" ? ">" : "<";
5124
+ const keyset = cursor ? ` AND (day, sequence) ${comparison} (?, ?)` : "";
5125
+ const sql = `SELECT day, sequence, payload FROM events WHERE ${pushed.clause}${keyset} ORDER BY day ${direction}, sequence ${direction} LIMIT ? OFFSET ?`;
5126
+ const summary2 = createSummaryAccumulator();
5127
+ const page = [];
5128
+ let total = 0;
5129
+ let scannedEvents = 0;
5130
+ let last;
5131
+ let offset = 0;
5132
+ for (; ; ) {
5133
+ const params = [...pushed.params];
5134
+ if (cursor) params.push(cursor.day, cursor.sequence);
5135
+ params.push(this.batchSize, offset);
5136
+ const rows = this.db.prepare(sql).all(...params);
5137
+ if (rows.length === 0) break;
5138
+ offset += rows.length;
5139
+ for (const row of rows) {
5140
+ scannedEvents++;
5141
+ let event;
5142
+ try {
5143
+ event = JSON.parse(row.payload);
5144
+ } catch {
5145
+ this.diagnostics.invalidLines++;
5146
+ continue;
5147
+ }
5148
+ if (!matches(event, query)) continue;
5149
+ total++;
5150
+ updateSummary(summary2, event);
5151
+ if (page.length < limit) {
5152
+ page.push(event);
5153
+ last = { day: row.day, sequence: row.sequence };
5154
+ }
5155
+ }
5156
+ if (rows.length < this.batchSize) break;
5157
+ }
5158
+ page.sort((left, right) => compareEvents(left, right) * (order === "asc" ? 1 : -1));
5159
+ const result = {
5160
+ events: page,
5161
+ total,
5162
+ scannedEvents,
5163
+ sourceFiles: this.diagnostics.sourceFiles,
5164
+ invalidLines: this.diagnostics.invalidLines,
5165
+ summary: finalizeSummary(summary2)
5166
+ };
5167
+ if (total > page.length && last) {
5168
+ return { ...result, nextCursor: encodeCursor2(last) };
5169
+ }
5170
+ return result;
5171
+ }
5637
5172
  /**
5638
- * Default total char budget for skill bodies injected in eager mode when
5639
- * `config.skills.eagerMaxChars` is unset. ~6k tokens. Skills are injected
5640
- * highest-priority first; once the budget is exhausted the remaining skills
5641
- * are listed as a manifest the agent loads via the `skill` tool. Set very
5642
- * high to effectively disable budgeting.
5173
+ * Value counts per facet field.
5643
5174
  *
5644
- * Consumer: `DefaultSystemPromptBuilder.buildMemoryAndSkills` (default for
5645
- * `skillEagerMaxChars`).
5175
+ * Not a SQL `GROUP BY`: `facetValue()` reads fields that live inside the
5176
+ * payload — provider, model, tool call, tag — so grouping in SQL would only
5177
+ * work for the handful that happen to be columns and would need a second,
5178
+ * divergent definition for the rest. The narrowing is still done by SQL; the
5179
+ * counting uses the JSONL engine's own projection.
5646
5180
  */
5647
- EAGER_DEFAULT_MAX_CHARS: 24e3,
5181
+ async facets(fields, query = {}, limit = 100) {
5182
+ const uniqueFields = [...new Set(fields)];
5183
+ if (uniqueFields.length === 0) return {};
5184
+ const counts = new Map(uniqueFields.map((field) => [field, /* @__PURE__ */ new Map()]));
5185
+ for (const event of this.eachMatch(query)) {
5186
+ for (const field of uniqueFields) {
5187
+ const value = facetValue(event, field);
5188
+ if (value === void 0) continue;
5189
+ const fieldCounts = counts.get(field);
5190
+ fieldCounts?.set(value, (fieldCounts.get(value) ?? 0) + 1);
5191
+ }
5192
+ }
5193
+ const result = {};
5194
+ for (const field of uniqueFields) {
5195
+ result[field] = [...counts.get(field) ?? /* @__PURE__ */ new Map()].map(([value, count]) => ({ value, count })).sort((left, right) => right.count - left.count || left.value.localeCompare(right.value)).slice(0, Math.max(0, limit));
5196
+ }
5197
+ return result;
5198
+ }
5199
+ async facet(field, query = {}, limit = 100) {
5200
+ return (await this.facets([field], query, limit))[field] ?? [];
5201
+ }
5648
5202
  /**
5649
- * Default total char budget for compact (token-saving) skill bodies injected
5650
- * by `buildCompactSkillBodiesText`. Mirrors the eager ceiling so compact mode
5651
- * can never inject more total skill text than eager mode. Before this bound
5652
- * existed, compact mode injected EVERY skill's compact body with no cap, so
5653
- * the section grew linearly with the number of installed skills. Skills past
5654
- * the budget overflow to a name list the agent loads via the `skill` tool.
5203
+ * Expand explicit and typed correlation edges around a seed set.
5655
5204
  *
5656
- * Consumer: `buildCompactSkillBodiesText` (default for its `budget` param).
5205
+ * The traversal is the JSONL engine's, moved onto rows: `relationKeys()`
5206
+ * defines the edges, `compareEvents` orders the nodes, and each hop is
5207
+ * another ordered pass. Order is load-bearing rather than cosmetic — both
5208
+ * `maxNodes` truncations stop at whatever they reach first, so a differently
5209
+ * ordered scan would return a different subgraph rather than the same one
5210
+ * shuffled. `ORDER BY day, sequence` reproduces `comparePartitionPaths`
5211
+ * (family, then rotation index) followed by line order within a partition.
5657
5212
  */
5658
- COMPACT_DEFAULT_MAX_CHARS: 24e3,
5213
+ async graph(seed = {}, hops = 2, maxNodes = 1e3) {
5214
+ const nodeLimit = Math.max(0, Math.floor(maxNodes));
5215
+ const selected = /* @__PURE__ */ new Map();
5216
+ let seedCount = 0;
5217
+ for (const event of this.eachMatch(seed)) {
5218
+ seedCount++;
5219
+ if (selected.size < nodeLimit) selected.set(event.eventId, event);
5220
+ }
5221
+ let frontier = [...selected.values()];
5222
+ const depthLimit = Math.max(0, Math.min(hops, 10));
5223
+ for (let depth = 0; depth < depthLimit && frontier.length > 0 && selected.size < nodeLimit; depth++) {
5224
+ const frontierKeys = new Set(
5225
+ frontier.flatMap((event) => relationKeys(event).map((relation) => relation.key))
5226
+ );
5227
+ const next = [];
5228
+ for (const event of this.eachMatch({})) {
5229
+ if (selected.has(event.eventId)) continue;
5230
+ if (!relationKeys(event).some((relation) => frontierKeys.has(relation.key))) continue;
5231
+ selected.set(event.eventId, event);
5232
+ next.push(event);
5233
+ if (selected.size >= nodeLimit) break;
5234
+ }
5235
+ frontier = next;
5236
+ }
5237
+ const nodes = [...selected.values()].sort(compareEvents);
5238
+ const byKey = /* @__PURE__ */ new Map();
5239
+ for (const node of nodes) {
5240
+ for (const relation of relationKeys(node)) {
5241
+ const related = byKey.get(relation.key) ?? [];
5242
+ related.push(node);
5243
+ byKey.set(relation.key, related);
5244
+ }
5245
+ }
5246
+ const edges = [];
5247
+ const seen = /* @__PURE__ */ new Set();
5248
+ for (const node of nodes) {
5249
+ for (const relation of relationKeys(node)) {
5250
+ for (const candidate of byKey.get(relation.key) ?? []) {
5251
+ if (candidate.eventId === node.eventId) continue;
5252
+ const [from, to] = compareEvents(node, candidate) <= 0 ? [node, candidate] : [candidate, node];
5253
+ const id = `${from.eventId}:${to.eventId}:${relation.kind}`;
5254
+ if (seen.has(id)) continue;
5255
+ seen.add(id);
5256
+ edges.push({
5257
+ from: from.eventId,
5258
+ to: to.eventId,
5259
+ kind: relation.kind,
5260
+ confidence: relation.confidence
5261
+ });
5262
+ }
5263
+ }
5264
+ }
5265
+ return {
5266
+ nodes,
5267
+ edges,
5268
+ truncated: seedCount > nodeLimit || selected.size >= nodeLimit
5269
+ };
5270
+ }
5271
+ /** Every event satisfying `query`, pulled in batches so a wide scan stays bounded. */
5272
+ *eachMatch(query) {
5273
+ const pushed = pushDown(query);
5274
+ const sql = `SELECT payload FROM events WHERE ${pushed.clause} ORDER BY day, sequence LIMIT ? OFFSET ?`;
5275
+ let offset = 0;
5276
+ for (; ; ) {
5277
+ const rows = this.db.prepare(sql).all(...pushed.params, this.batchSize, offset);
5278
+ if (rows.length === 0) return;
5279
+ offset += rows.length;
5280
+ for (const row of rows) {
5281
+ let event;
5282
+ try {
5283
+ event = JSON.parse(row.payload);
5284
+ } catch {
5285
+ this.diagnostics.invalidLines++;
5286
+ continue;
5287
+ }
5288
+ if (matches(event, query)) yield event;
5289
+ }
5290
+ if (rows.length < this.batchSize) return;
5291
+ }
5292
+ }
5293
+ };
5294
+
5295
+ // src/chronicle/sqlite-journal.ts
5296
+ var CHRONICLE_SQLITE_FILE = "chronicle.sqlite";
5297
+ var LEGACY_JSONL_MIGRATION_KEY = "legacy-jsonl-v1";
5298
+ var LEGACY_JSONL_QUARANTINE_KEY = "legacy-jsonl-v1:quarantine";
5299
+ var SCHEMA_VERSION = 2;
5300
+ var SQLITE_FIXED_OVERHEAD_BYTES = 16 * 1024 * 1024;
5301
+ var MIN_SQLITE_PAGE_BUDGET_BYTES = 64 * 1024;
5302
+ var MAX_WAL_RESERVE_BYTES = 32 * 1024 * 1024;
5303
+ var WAL_AUTOCHECKPOINT_PAGES = 2e3;
5304
+ var WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024;
5305
+ var TRIM_SLACK_RATIO = 0.02;
5306
+ var MAX_TRIM_SLACK_EVENTS = 2e3;
5307
+ var ChronicleStorageQuotaError = class extends Error {
5308
+ currentBytes;
5309
+ batchBytes;
5310
+ maxBytes;
5311
+ path;
5312
+ constructor(details) {
5313
+ super(
5314
+ `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)`
5315
+ );
5316
+ this.name = "ChronicleStorageQuotaError";
5317
+ this.currentBytes = details.currentBytes;
5318
+ this.batchBytes = details.batchBytes;
5319
+ this.maxBytes = details.maxBytes;
5320
+ this.path = details.path;
5321
+ }
5322
+ };
5323
+ var Ctor;
5324
+ function loadDatabaseSync() {
5325
+ if (Ctor) return Ctor;
5326
+ if (Ctor === null) throw new Error("node:sqlite is unavailable in this runtime");
5327
+ try {
5328
+ Ctor = withSqliteExperimentalWarningSuppressed(
5329
+ () => createRequire(import.meta.url)("node:sqlite").DatabaseSync
5330
+ );
5331
+ return Ctor;
5332
+ } catch (error) {
5333
+ Ctor = null;
5334
+ throw new Error(
5335
+ "The Chronicle journal needs Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
5336
+ );
5337
+ }
5338
+ }
5339
+ var ChronicleSqliteJournal = class {
5340
+ db;
5341
+ dbPath;
5342
+ now;
5343
+ monotonicNow;
5344
+ idFactory;
5345
+ retentionDays;
5346
+ maxEvents;
5347
+ maxBytes;
5348
+ retentionCheckIntervalMs;
5349
+ /** Overshoot allowed above `maxEvents` before eviction runs; see TRIM_SLACK_RATIO. */
5350
+ trimSlack;
5351
+ nextRetentionCheckAt = 0;
5352
+ retainedEventCount = 0;
5353
+ /** Resolved lazily by `pageSizeBytes()`; constant for an open database. */
5354
+ cachedPageSize;
5659
5355
  /**
5660
- * Auto-compact body limits (the token-saving fallback used when a skill has
5661
- * no hand-crafted `SKILL.save.md`). The Overview and Rules sections are
5662
- * extracted and trimmed to these char budgets, then the total is capped.
5663
- *
5664
- * Consumer: `DefaultSkillLoader.compactSkillBody`.
5356
+ * Bytes the quota is known to have had spare at the last real measurement,
5357
+ * and the bytes appended since. `assertWithinByteQuota` re-measures only once
5358
+ * the second could plausibly have consumed the first see its comment.
5665
5359
  */
5666
- COMPACT_OVERVIEW_MAX: 200,
5667
- COMPACT_RULES_MAX: 350,
5668
- COMPACT_TOTAL_MAX: 450,
5360
+ quotaHeadroomBytes = 0;
5361
+ bytesSinceQuotaCheck = Number.POSITIVE_INFINITY;
5669
5362
  /**
5670
- * Max length of a skill name (agentskills.io spec). Enforced by the
5671
- * frontmatter validator's format regex plus this length bound.
5363
+ * Statements reused across appends.
5672
5364
  *
5673
- * Consumer: `isValidSkillNameFormat`.
5365
+ * `db.prepare` re-parses the SQL every call, and the append path used to
5366
+ * prepare five statements per batch. They are held rather than re-prepared
5367
+ * because the schema cannot change under an open journal.
5674
5368
  */
5675
- SKILL_NAME_MAX_LEN: 64,
5369
+ statements;
5676
5370
  /**
5677
- * Soft line limit for a SKILL.md body, per the bundled `skill-creator` rule.
5678
- * Not enforced in code (a skill can exceed it) surfaced by `/skill-gen
5679
- * validate` and the `skill-creator` skill as guidance to move deep material
5680
- * into `references/`.
5681
- *
5682
- * Consumer: `skill-creator` skill, `/skill-gen validate` advisory.
5371
+ * Cached chain head per day. Cleared wholesale on any write failure so the
5372
+ * next append rebuilds from the database rather than trusting a counter that
5373
+ * may have advanced past what actually committed.
5683
5374
  */
5684
- SKILL_BODY_LINE_LIMIT: 500
5685
- };
5686
-
5687
- // src/skills/frontmatter.ts
5688
- var SCALAR_KEYS = /* @__PURE__ */ new Set([
5689
- "name",
5690
- "description",
5691
- "trigger",
5692
- "version",
5693
- "license",
5694
- "compatibility"
5695
- ]);
5696
- function parseSkillFrontmatter(raw) {
5697
- const text = normalizeLineEndings(raw);
5698
- if (!text.startsWith("---")) return {};
5699
- const end = text.indexOf("\n---", 4);
5700
- if (end === -1) return {};
5701
- return parseFrontmatterBlock(text.slice(4, end));
5702
- }
5703
- function stripFrontmatter(raw) {
5704
- const text = normalizeLineEndings(raw);
5705
- if (!text.startsWith("---")) return text;
5706
- const end = text.indexOf("\n---", 4);
5707
- if (end === -1) return text;
5708
- let body = text.slice(end + 4);
5709
- if (body.startsWith("\n")) body = body.slice(1);
5710
- return body;
5711
- }
5712
- function normalizeLineEndings(s) {
5713
- return s.replace(/\r\n?/g, "\n");
5714
- }
5715
- function parseFrontmatterBlock(block) {
5716
- const out = {};
5717
- const lines = block.split("\n");
5718
- let i = 0;
5719
- while (i < lines.length) {
5720
- const line = lines[i] ?? "";
5721
- const m = /^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/.exec(line);
5722
- if (!m) {
5723
- i++;
5724
- continue;
5375
+ anchors = /* @__PURE__ */ new Map();
5376
+ counters = {
5377
+ acceptedEvents: 0,
5378
+ persistedEvents: 0,
5379
+ rejectedEvents: 0,
5380
+ failedEvents: 0,
5381
+ batches: 0,
5382
+ maxObservedPending: 0,
5383
+ largestBatch: 0
5384
+ };
5385
+ lastBatchDurationMs;
5386
+ constructor(options) {
5387
+ this.dbPath = path9.join(path9.resolve(options.directory), CHRONICLE_SQLITE_FILE);
5388
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
5389
+ this.monotonicNow = options.monotonicNow ?? (() => process.hrtime.bigint());
5390
+ this.idFactory = options.idFactory ?? (() => randomUUID3());
5391
+ if (options.retentionDays !== void 0 && (!Number.isFinite(options.retentionDays) || options.retentionDays <= 0)) {
5392
+ throw new RangeError("retentionDays must be a positive finite number");
5725
5393
  }
5726
- const key = m[1] ?? "";
5727
- const rest = (m[2] ?? "").trim();
5728
- if (key === "metadata") {
5729
- const map = {};
5730
- i++;
5731
- while (i < lines.length) {
5732
- const sub = lines[i] ?? "";
5733
- const sm = /^\s+([a-zA-Z0-9_.-]+):\s*(.*)$/.exec(sub);
5734
- if (!sm) break;
5735
- map[sm[1] ?? ""] = unquote((sm[2] ?? "").trim());
5736
- i++;
5737
- }
5738
- if (Object.keys(map).length > 0) out.metadata = map;
5739
- continue;
5394
+ if (options.retentionCheckIntervalMs !== void 0 && (!Number.isFinite(options.retentionCheckIntervalMs) || options.retentionCheckIntervalMs <= 0)) {
5395
+ throw new RangeError("retentionCheckIntervalMs must be a positive finite number");
5740
5396
  }
5741
- if (rest === "|" || rest === ">") {
5742
- const collected = [];
5743
- i++;
5744
- while (i < lines.length) {
5745
- const sub = lines[i] ?? "";
5746
- if (sub === "" || sub.startsWith(" ") || sub.startsWith(" ")) {
5747
- collected.push(sub.replace(/^\s+/, ""));
5748
- i++;
5749
- } else break;
5750
- }
5751
- out[normalizeKey2(key)] = collected.join("\n").trim();
5752
- continue;
5397
+ if (options.maxEvents !== void 0 && (!Number.isInteger(options.maxEvents) || options.maxEvents < 1)) {
5398
+ throw new RangeError("maxEvents must be a positive integer");
5753
5399
  }
5754
- if (key === "allowed-tools" || key === "allowedTools" || key === "required-capabilities" || key === "requiredCapabilities" || key === "required-tools" || key === "requiredTools" || key === "optional-capabilities" || key === "optionalCapabilities") {
5755
- const values = rest.replace(/^\[/, "").replace(/\]$/, "").split(/[\s,]+/).map(unquote).filter(Boolean);
5756
- out[normalizeKey2(key)] = values;
5757
- i++;
5758
- continue;
5400
+ if (options.maxBytes !== void 0 && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1)) {
5401
+ throw new RangeError("maxBytes must be a positive safe integer");
5759
5402
  }
5760
- if (SCALAR_KEYS.has(key)) {
5761
- out[key] = unquote(rest);
5762
- i++;
5763
- continue;
5403
+ const minimumQuotaBytes = SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES;
5404
+ if (options.maxBytes !== void 0 && options.maxBytes < minimumQuotaBytes) {
5405
+ throw new RangeError(`maxBytes must be at least ${minimumQuotaBytes}`);
5406
+ }
5407
+ this.retentionDays = options.retentionDays;
5408
+ this.maxEvents = options.maxEvents;
5409
+ this.maxBytes = options.maxBytes;
5410
+ this.retentionCheckIntervalMs = options.retentionCheckIntervalMs ?? 60 * 60 * 1e3;
5411
+ this.trimSlack = this.maxEvents === void 0 ? 0 : Math.min(MAX_TRIM_SLACK_EVENTS, Math.floor(this.maxEvents * TRIM_SLACK_RATIO));
5412
+ const Database = loadDatabaseSync();
5413
+ this.db = new Database(this.dbPath);
5414
+ this.db.exec("PRAGMA journal_mode = WAL");
5415
+ this.db.exec("PRAGMA synchronous = FULL");
5416
+ this.db.exec(`PRAGMA wal_autocheckpoint = ${WAL_AUTOCHECKPOINT_PAGES}`);
5417
+ this.db.exec(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES}`);
5418
+ this.ensureSchema();
5419
+ this.configureByteQuota();
5420
+ if (this.maxEvents !== void 0) {
5421
+ const row = this.db.prepare("SELECT COUNT(*) AS count FROM events").get();
5422
+ this.retainedEventCount = row.count;
5423
+ this.enforceEventLimitAtStartup();
5764
5424
  }
5765
- i++;
5766
5425
  }
5767
- return out;
5768
- }
5769
- function normalizeKey2(key) {
5770
- if (key === "allowed-tools") return "allowedTools";
5771
- if (key === "required-capabilities") return "requiredCapabilities";
5772
- if (key === "required-tools") return "requiredTools";
5773
- if (key === "optional-capabilities") return "optionalCapabilities";
5774
- return key;
5775
- }
5776
- function unquote(s) {
5777
- if (s.length >= 2 && (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'"))) {
5778
- return s.slice(1, -1);
5426
+ close() {
5427
+ this.db.close();
5779
5428
  }
5780
- return s;
5781
- }
5782
- function isValidSkillNameFormat(name) {
5783
- return name.length >= 1 && name.length <= 64 && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(name);
5784
- }
5785
- function validateSkillName(name, parentDirName) {
5786
- const errors = [];
5787
- if (!name || name.trim().length === 0) {
5788
- errors.push("name is empty");
5789
- return errors;
5429
+ stats() {
5430
+ return {
5431
+ ...this.counters,
5432
+ pendingEvents: 0,
5433
+ // Retained at zero for wire compatibility: partitions do not exist here,
5434
+ // but `ChronicleJournalStats` is part of the IPC health payload.
5435
+ partitionRolls: 0,
5436
+ ...this.lastBatchDurationMs !== void 0 ? { lastBatchDurationMs: this.lastBatchDurationMs } : {}
5437
+ };
5790
5438
  }
5791
- if (name.length > 64) errors.push(`name is ${name.length} characters (max 64)`);
5792
- if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) {
5793
- errors.push(
5794
- "name must be lowercase letters, digits, and single hyphens only (no leading/trailing/consecutive hyphens)"
5795
- );
5439
+ /**
5440
+ * Writes are synchronous and transactional, so there is nothing buffered to
5441
+ * flush. Kept to satisfy `ChronicleEventSink`.
5442
+ */
5443
+ async flush() {
5444
+ return Promise.resolve();
5796
5445
  }
5797
- if (parentDirName !== void 0 && name !== parentDirName) {
5798
- errors.push(`name "${name}" must match its parent directory "${parentDirName}"`);
5446
+ async append(input) {
5447
+ const [event] = await this.appendBatch([input]);
5448
+ return event;
5799
5449
  }
5800
- return errors;
5801
- }
5802
-
5803
- // src/core/system-prompt-skill-text.ts
5804
- function capSkillBody(body) {
5805
- const max = SKILL_LIMITS.MAX_SKILL_BODY_CHARS;
5806
- if (body.length <= max) return body;
5807
- const budget = max - 1;
5808
- const cut = body.lastIndexOf("\n\n", budget);
5809
- const truncated = cut > budget / 2 ? body.slice(0, cut) : body.slice(0, budget);
5810
- return truncated + "\u2026";
5811
- }
5812
-
5813
- // src/utils/instruction-file.ts
5814
- import { readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
5815
- import * as path10 from "node:path";
5816
- import { fileURLToPath } from "node:url";
5817
- var textCache = /* @__PURE__ */ new Map();
5818
- var rootCandidates;
5819
- function readBundledInstructionText(relativePath) {
5820
- const cached = textCache.get(relativePath);
5821
- if (cached !== void 0) return cached;
5822
- let resolved = "";
5823
- for (const root of instructionRootCandidates()) {
5450
+ /**
5451
+ * Append a batch as one transaction.
5452
+ *
5453
+ * The chain is computed in memory from a single anchor, so a partially
5454
+ * applied batch would leave a hole in `sequence` that verification can never
5455
+ * reconcile. Rollback plus anchor invalidation is what prevents that.
5456
+ */
5457
+ async appendBatch(inputs) {
5458
+ if (inputs.length === 0) return [];
5459
+ const started = performance.now();
5460
+ this.counters.acceptedEvents += inputs.length;
5461
+ this.counters.batches += 1;
5462
+ this.counters.largestBatch = Math.max(this.counters.largestBatch, inputs.length);
5463
+ const instant = this.now().toISOString();
5464
+ const day = instant.slice(0, 10);
5465
+ const events = [];
5466
+ let previous = this.readAnchor(day);
5467
+ for (const input of inputs) {
5468
+ const unhashed = {
5469
+ ...input,
5470
+ occurredAt: input.occurredAt ?? instant,
5471
+ monotonicNs: input.monotonicNs ?? this.monotonicNow().toString(),
5472
+ schemaVersion: CHRONICLE_SCHEMA_VERSION,
5473
+ eventId: this.idFactory(),
5474
+ observedAt: instant,
5475
+ persistedAt: instant,
5476
+ sequence: previous.sequence + 1,
5477
+ previousHash: previous.hash
5478
+ };
5479
+ const event = { ...unhashed, hash: hashValue(unhashed) };
5480
+ events.push(event);
5481
+ previous = { sequence: event.sequence, hash: event.hash };
5482
+ }
5483
+ const payloads = events.map((event) => JSON.stringify(event));
5484
+ let batchBytes = 0;
5485
+ for (const payload of payloads) batchBytes += Buffer.byteLength(payload, "utf8");
5486
+ let retainedCountAfterCommit = this.retainedEventCount;
5487
+ try {
5488
+ this.db.exec("BEGIN IMMEDIATE");
5489
+ this.assertWithinByteQuota(batchBytes);
5490
+ const { insert } = this.preparedStatements();
5491
+ for (const [index, event] of events.entries()) {
5492
+ const row = projectEvent(event);
5493
+ insert.run(
5494
+ day,
5495
+ event.sequence,
5496
+ event.eventId,
5497
+ event.hash,
5498
+ event.previousHash,
5499
+ row.occurredAt,
5500
+ event.eventType,
5501
+ row.outcome,
5502
+ row.projectId,
5503
+ row.sessionId,
5504
+ row.agentId,
5505
+ row.taskId,
5506
+ row.traceId,
5507
+ row.logicalRequestId,
5508
+ row.resourceKind,
5509
+ row.resourceId,
5510
+ row.resourcePath,
5511
+ row.durationNs,
5512
+ payloads[index]
5513
+ );
5514
+ }
5515
+ retainedCountAfterCommit = this.enforceEventLimitWithinTransaction(
5516
+ this.retainedEventCount + events.length
5517
+ );
5518
+ this.assertActualAllocationWithinQuota();
5519
+ this.db.exec("COMMIT");
5520
+ } catch (error) {
5521
+ try {
5522
+ this.db.exec("ROLLBACK");
5523
+ } catch {
5524
+ }
5525
+ this.anchors.clear();
5526
+ this.invalidateQuotaEstimate();
5527
+ this.counters.failedEvents += inputs.length;
5528
+ this.lastBatchDurationMs = performance.now() - started;
5529
+ throw this.normalizeQuotaError(error);
5530
+ }
5531
+ this.anchors.set(day, previous);
5532
+ this.counters.persistedEvents += events.length;
5533
+ this.retainedEventCount = retainedCountAfterCommit;
5534
+ this.bytesSinceQuotaCheck += batchBytes;
5535
+ this.lastBatchDurationMs = performance.now() - started;
5536
+ await this.enforceRetentionIfDue();
5537
+ return events;
5538
+ }
5539
+ async readAll() {
5540
+ const rows = this.db.prepare("SELECT payload FROM events ORDER BY day, sequence").all();
5541
+ return rows.map((row) => JSON.parse(row.payload));
5542
+ }
5543
+ /**
5544
+ * Walk every chain and prove none has been edited.
5545
+ *
5546
+ * Chronicle chains are scoped to a day, not to the journal: the JSONL writer
5547
+ * anchors each `<day>.events.jsonl` family at `GENESIS_HASH` independently,
5548
+ * so `sequence` restarts at 1 every day. Verification mirrors that — each day
5549
+ * is validated on its own, and a break in one does not implicate the others.
5550
+ *
5551
+ * Three independent properties per day, because each catches a different
5552
+ * failure: a dense `sequence` catches deletions from the middle, the
5553
+ * `previousHash` link catches reordering, and re-hashing the payload catches
5554
+ * an in-place edit that left the links intact.
5555
+ */
5556
+ async verify() {
5557
+ let entries = 0;
5558
+ let lastSequence = 0;
5559
+ let lastHash = GENESIS_HASH;
5560
+ for (const day of this.days()) {
5561
+ const checkpoint = this.readCheckpoint(day);
5562
+ let expectedSequence = (checkpoint?.sequence ?? 0) + 1;
5563
+ let previousHash = checkpoint?.hash ?? GENESIS_HASH;
5564
+ const rows = this.db.prepare(
5565
+ "SELECT sequence, hash, previous_hash, payload FROM events WHERE day = ? ORDER BY sequence"
5566
+ ).all(day);
5567
+ for (const row of rows) {
5568
+ if (row.sequence !== expectedSequence) {
5569
+ return {
5570
+ ok: false,
5571
+ entries,
5572
+ brokenAt: entries,
5573
+ reason: `sequence gap in ${day}: expected ${expectedSequence}, found ${row.sequence}`
5574
+ };
5575
+ }
5576
+ if (row.previous_hash !== previousHash) {
5577
+ return { ok: false, entries, brokenAt: entries, reason: "previous hash mismatch" };
5578
+ }
5579
+ let event;
5580
+ try {
5581
+ event = JSON.parse(row.payload);
5582
+ } catch {
5583
+ return { ok: false, entries, brokenAt: entries, reason: "invalid payload JSON" };
5584
+ }
5585
+ if (chronicleEventHash(event) !== row.hash) {
5586
+ return { ok: false, entries, brokenAt: entries, reason: "entry hash mismatch" };
5587
+ }
5588
+ entries += 1;
5589
+ expectedSequence = row.sequence + 1;
5590
+ previousHash = row.hash;
5591
+ }
5592
+ lastSequence = expectedSequence - 1;
5593
+ lastHash = previousHash;
5594
+ }
5595
+ return { ok: true, entries, lastSequence, lastHash };
5596
+ }
5597
+ async enforceRetentionIfDue() {
5598
+ if (this.retentionDays === void 0) return;
5599
+ const now = this.now();
5600
+ if (now.getTime() < this.nextRetentionCheckAt) return;
5601
+ this.nextRetentionCheckAt = now.getTime() + this.retentionCheckIntervalMs;
5824
5602
  try {
5825
- resolved = readFileSync2(path10.join(root, relativePath), "utf8").trimEnd();
5826
- break;
5603
+ await this.purge({ retentionDays: this.retentionDays });
5827
5604
  } catch {
5828
5605
  }
5829
5606
  }
5830
- textCache.set(relativePath, resolved);
5831
- return resolved;
5832
- }
5833
- function renderInstructionTemplate(template, values) {
5834
- return template.replace(
5835
- /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g,
5836
- (match, key) => Object.hasOwn(values, key) ? values[key] ?? "" : match
5837
- );
5838
- }
5839
- function instructionRootCandidates() {
5840
- if (rootCandidates !== void 0) return rootCandidates;
5841
- const here = path10.dirname(fileURLToPath(import.meta.url));
5842
- const candidates = [
5843
- path10.resolve(here, "../../instructions"),
5844
- path10.resolve(here, "../instructions"),
5845
- path10.resolve(here, "instructions")
5846
- ];
5847
- rootCandidates = candidates.sort((a, b) => Number(!isDirectory(a)) - Number(!isDirectory(b)));
5848
- return rootCandidates;
5849
- }
5850
- function isDirectory(candidate) {
5851
- try {
5852
- return statSync2(candidate).isDirectory();
5853
- } catch {
5854
- return false;
5855
- }
5856
- }
5857
-
5858
- // src/utils/ulid.ts
5859
- import { randomBytes } from "node:crypto";
5860
- var ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
5861
- var ENCODING_LEN = ENCODING.length;
5862
- var TIME_LEN = 10;
5863
- var RANDOM_LEN = 16;
5864
- function encodeTime(now, len) {
5865
- let mod;
5866
- let str = "";
5867
- for (let i = len - 1; i >= 0; i--) {
5868
- mod = now % ENCODING_LEN;
5869
- str = ENCODING[mod] + str;
5870
- now = (now - mod) / ENCODING_LEN;
5871
- }
5872
- return str;
5873
- }
5874
- function encodeRandom(len) {
5875
- const bytes = randomBytes(len);
5876
- let str = "";
5877
- for (let i = 0; i < len; i++) {
5878
- str += ENCODING[bytes[i] % ENCODING_LEN];
5607
+ configureByteQuota() {
5608
+ if (this.maxBytes === void 0) {
5609
+ this.db.exec("PRAGMA max_page_count = 2147483646");
5610
+ return;
5611
+ }
5612
+ const halfSplit = Math.floor((this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES) / 2);
5613
+ const sidecarReserve = Math.min(MAX_WAL_RESERVE_BYTES, halfSplit);
5614
+ const mainBudget = this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES - sidecarReserve;
5615
+ if (mainBudget < MIN_SQLITE_PAGE_BUDGET_BYTES) {
5616
+ throw new Error(
5617
+ `maxBytes must be at least ${SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES}`
5618
+ );
5619
+ }
5620
+ const maxPages = Math.max(1, Math.floor(mainBudget / this.pageSizeBytes()));
5621
+ this.db.exec(`PRAGMA max_page_count = ${maxPages}`);
5879
5622
  }
5880
- return str;
5881
- }
5882
- function ulid(seedTime = Date.now()) {
5883
- return encodeTime(seedTime, TIME_LEN) + encodeRandom(RANDOM_LEN);
5884
- }
5885
-
5886
- // src/utils/slug.ts
5887
- function slugify2(name, fallback = "prompt", maxLen = 64) {
5888
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, maxLen).replace(/-+$/g, "") || fallback;
5889
- }
5890
-
5891
- // src/chronicle/sqlite-query.ts
5892
- var MAX_LIMIT = 1e4;
5893
- function encodeCursor2(cursor) {
5894
- return Buffer.from(`${cursor.day}:${cursor.sequence}`, "utf8").toString("base64url");
5895
- }
5896
- function decodeCursor2(raw) {
5897
- if (!raw) return void 0;
5898
- try {
5899
- const [day, sequence] = Buffer.from(raw, "base64url").toString("utf8").split(":");
5900
- if (!day || sequence === void 0) return void 0;
5901
- const parsed = Number(sequence);
5902
- return Number.isSafeInteger(parsed) ? { day, sequence: parsed } : void 0;
5903
- } catch {
5904
- return void 0;
5623
+ /**
5624
+ * Bytes the journal actually occupies: live pages in the main database plus
5625
+ * whatever the rollback/WAL sidecars currently hold.
5626
+ *
5627
+ * The quota MUST be measured this way rather than by `statSync` on the main
5628
+ * database file. SQLite never returns freed pages to the filesystem — it
5629
+ * parks them on the freelist and reuses them — so a database that once grew
5630
+ * large keeps that size forever, even after retention and `maxEvents` have
5631
+ * evicted almost everything. Measuring the file instead wedged the journal
5632
+ * permanently: a 3.9 GB file whose live data was 243 MB failed a 512 MB
5633
+ * quota on EVERY append, and the only escape (`chronicle compact`) refuses
5634
+ * to run while the daemon is up — and the daemon respawns on demand. Live
5635
+ * pages shrink when retention deletes rows, so the quota can recover on its
5636
+ * own; allocated size cannot.
5637
+ *
5638
+ * The sidecars keep their on-disk size: they are transient and genuinely
5639
+ * bounded by `configureByteQuota`'s half-of-budget split.
5640
+ */
5641
+ aggregateLiveBytes() {
5642
+ let total = 0;
5643
+ for (const file of [`${this.dbPath}-journal`, `${this.dbPath}-wal`, `${this.dbPath}-shm`]) {
5644
+ try {
5645
+ total += fs6.statSync(file).size;
5646
+ } catch (error) {
5647
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
5648
+ }
5649
+ }
5650
+ return total + this.mainDatabaseLiveBytes();
5905
5651
  }
5906
- }
5907
- function pushDown(query) {
5908
- const clauses = [];
5909
- const params = [];
5910
- const eq = (column, value) => {
5911
- if (value === void 0) return;
5912
- clauses.push(`${column} = ?`);
5913
- params.push(value);
5914
- };
5915
- eq("event_id", query.eventId);
5916
- eq("project_id", query.projectId);
5917
- eq("session_id", query.sessionId);
5918
- eq("agent_id", query.agentId);
5919
- eq("task_id", query.taskId);
5920
- eq("trace_id", query.traceId);
5921
- eq("logical_request_id", query.logicalRequestId);
5922
- eq("resource_kind", query.resourceKind);
5923
- eq("resource_id", query.resourceId);
5924
- if (query.eventTypes?.length) {
5925
- clauses.push(`event_type IN (${query.eventTypes.map(() => "?").join(",")})`);
5926
- params.push(...query.eventTypes);
5652
+ /** Live (non-freelist) pages of the main database, in bytes. */
5653
+ mainDatabaseLiveBytes() {
5654
+ const pageCount = Number(
5655
+ this.db.prepare("PRAGMA page_count").get().page_count
5656
+ );
5657
+ const freelist = Number(
5658
+ this.db.prepare("PRAGMA freelist_count").get().freelist_count
5659
+ );
5660
+ return Math.max(0, pageCount - freelist) * this.pageSizeBytes();
5927
5661
  }
5928
- if (query.outcomes?.length) {
5929
- clauses.push(`outcome IN (${query.outcomes.map(() => "?").join(",")})`);
5930
- params.push(...query.outcomes);
5662
+ /** Page size never changes for an open database; resolve it once. */
5663
+ pageSizeBytes() {
5664
+ if (this.cachedPageSize === void 0) {
5665
+ this.cachedPageSize = Number(
5666
+ this.db.prepare("PRAGMA page_size").get().page_size
5667
+ );
5668
+ }
5669
+ return this.cachedPageSize;
5931
5670
  }
5932
- if (query.from) {
5933
- clauses.push("occurred_at >= ?");
5934
- params.push(query.from);
5671
+ normalizeQuotaError(error) {
5672
+ if (this.maxBytes === void 0 || !(error instanceof Error)) return error;
5673
+ const sqliteError = error;
5674
+ const quotaExhausted = sqliteError.code === "SQLITE_FULL" || sqliteError.code === 13 || sqliteError.errcode === 13 || /database or disk is full/i.test(sqliteError.message);
5675
+ if (!quotaExhausted) return error;
5676
+ return new ChronicleStorageQuotaError({
5677
+ currentBytes: this.aggregateLiveBytes(),
5678
+ batchBytes: 0,
5679
+ maxBytes: this.maxBytes,
5680
+ path: this.dbPath
5681
+ });
5935
5682
  }
5936
- if (query.to) {
5937
- clauses.push("occurred_at <= ?");
5938
- params.push(query.to);
5683
+ /** Drop the cached headroom so the next quota check measures for real. */
5684
+ invalidateQuotaEstimate() {
5685
+ this.quotaHeadroomBytes = 0;
5686
+ this.bytesSinceQuotaCheck = Number.POSITIVE_INFINITY;
5939
5687
  }
5940
- return { clause: clauses.length ? clauses.join(" AND ") : "1=1", params };
5941
- }
5942
- var ChronicleSqliteQueryEngine = class {
5943
- constructor(db, options = {}) {
5944
- this.db = db;
5945
- this.batchSize = Math.max(1, options.batchSize ?? 1e3);
5688
+ assertActualAllocationWithinQuota() {
5689
+ if (this.maxBytes === void 0) return;
5690
+ if (!this.shouldMeasureQuota()) return;
5691
+ const currentBytes = this.aggregateLiveBytes();
5692
+ this.recordQuotaHeadroom(currentBytes);
5693
+ if (currentBytes <= this.maxBytes) return;
5694
+ throw new ChronicleStorageQuotaError({
5695
+ currentBytes,
5696
+ batchBytes: 0,
5697
+ maxBytes: this.maxBytes,
5698
+ path: this.dbPath
5699
+ });
5946
5700
  }
5947
- db;
5948
- diagnostics = { sourceFiles: 1, invalidLines: 0 };
5949
- batchSize;
5950
- async query(query = {}) {
5951
- const order = query.order ?? "desc";
5952
- const limit = Math.max(1, Math.min(query.limit ?? 100, MAX_LIMIT));
5953
- const cursor = decodeCursor2(query.cursor);
5954
- const pushed = pushDown(query);
5955
- const direction = order === "asc" ? "ASC" : "DESC";
5956
- const comparison = order === "asc" ? ">" : "<";
5957
- const keyset = cursor ? ` AND (day, sequence) ${comparison} (?, ?)` : "";
5958
- const sql = `SELECT day, sequence, payload FROM events WHERE ${pushed.clause}${keyset} ORDER BY day ${direction}, sequence ${direction} LIMIT ? OFFSET ?`;
5959
- const summary2 = createSummaryAccumulator();
5960
- const page = [];
5961
- let total = 0;
5962
- let scannedEvents = 0;
5963
- let last;
5964
- let offset = 0;
5965
- for (; ; ) {
5966
- const params = [...pushed.params];
5967
- if (cursor) params.push(cursor.day, cursor.sequence);
5968
- params.push(this.batchSize, offset);
5969
- const rows = this.db.prepare(sql).all(...params);
5970
- if (rows.length === 0) break;
5971
- offset += rows.length;
5972
- for (const row of rows) {
5973
- scannedEvents++;
5974
- let event;
5975
- try {
5976
- event = JSON.parse(row.payload);
5977
- } catch {
5978
- this.diagnostics.invalidLines++;
5979
- continue;
5980
- }
5981
- if (!matches(event, query)) continue;
5982
- total++;
5983
- updateSummary(summary2, event);
5984
- if (page.length < limit) {
5985
- page.push(event);
5986
- last = { day: row.day, sequence: row.sequence };
5987
- }
5701
+ /**
5702
+ * Refuse a batch that would take the journal past its byte quota.
5703
+ *
5704
+ * This is the *courteous* bound, not the enforced one: `max_page_count` makes
5705
+ * SQLite itself fail the write with `SQLITE_FULL`, which `normalizeQuotaError`
5706
+ * turns into the same {@link ChronicleStorageQuotaError}. Its only job is to
5707
+ * produce that error before a doomed batch has been written, so it does not
5708
+ * have to run on every append — and it should not, because measuring costs two
5709
+ * PRAGMA round trips and three `statSync` calls, twice per transaction.
5710
+ *
5711
+ * So it measures adaptively instead of on a fixed cadence: after a real
5712
+ * measurement it knows the spare bytes, and it may skip until the bytes
5713
+ * appended since then could plausibly have consumed half of them. Far from the
5714
+ * ceiling that is thousands of appends; close to it, every one. The bound
5715
+ * tightens exactly where being wrong would matter.
5716
+ */
5717
+ assertWithinByteQuota(batchBytes) {
5718
+ if (this.maxBytes === void 0) return;
5719
+ if (!this.shouldMeasureQuota(batchBytes)) return;
5720
+ const currentBytes = this.aggregateLiveBytes();
5721
+ this.recordQuotaHeadroom(currentBytes);
5722
+ if (currentBytes + batchBytes <= this.maxBytes) return;
5723
+ throw new ChronicleStorageQuotaError({
5724
+ currentBytes,
5725
+ batchBytes,
5726
+ maxBytes: this.maxBytes,
5727
+ path: this.dbPath
5728
+ });
5729
+ }
5730
+ /**
5731
+ * Is the cached headroom still large enough to vouch for this batch?
5732
+ *
5733
+ * Half the headroom is the budget deliberately: rows carry index and page
5734
+ * overhead beyond their payload bytes, so `bytesSinceQuotaCheck` understates
5735
+ * real growth, and the factor absorbs that without needing to model it.
5736
+ */
5737
+ shouldMeasureQuota(batchBytes = 0) {
5738
+ return this.bytesSinceQuotaCheck + batchBytes >= this.quotaHeadroomBytes / 2;
5739
+ }
5740
+ recordQuotaHeadroom(currentBytes) {
5741
+ this.quotaHeadroomBytes = Math.max(0, (this.maxBytes ?? 0) - currentBytes);
5742
+ this.bytesSinceQuotaCheck = 0;
5743
+ }
5744
+ /**
5745
+ * Evict the oldest events once the row ceiling has been overshot by `trimSlack`.
5746
+ *
5747
+ * @param count Rows now in the table. The caller tracks this rather than the
5748
+ * method querying it: `SELECT COUNT(*)` walks the whole table, and at the
5749
+ * ceiling — where every long-lived journal lives — that was a full scan of
5750
+ * `maxEvents` rows on every append, to learn a number the append path already
5751
+ * knew. The count is re-derived from the database whenever it could have
5752
+ * drifted (open, import, purge), never accumulated blindly across those.
5753
+ * @param slack Overshoot tolerated before evicting; callers that run once,
5754
+ * rather than per append, pass 0 to land on the exact ceiling.
5755
+ */
5756
+ enforceEventLimitWithinTransaction(count, slack = this.trimSlack) {
5757
+ if (this.maxEvents === void 0 || count <= this.maxEvents + slack) return count;
5758
+ const excess = count - this.maxEvents;
5759
+ const statements = this.preparedStatements();
5760
+ const boundary = statements.trimBoundary.get(excess - 1);
5761
+ if (!boundary) return count;
5762
+ statements.writeCheckpoint.run(boundary.day, boundary.sequence, boundary.hash);
5763
+ statements.deletePrefix.run(boundary.day, boundary.day, boundary.sequence);
5764
+ statements.deleteCheckpoints.run(boundary.day);
5765
+ for (const day of this.anchors.keys()) {
5766
+ if (day <= boundary.day) this.anchors.delete(day);
5767
+ }
5768
+ return this.maxEvents;
5769
+ }
5770
+ enforceEventLimitAtStartup() {
5771
+ if (this.maxEvents === void 0 || this.retainedEventCount <= this.maxEvents) return;
5772
+ try {
5773
+ this.db.exec("BEGIN IMMEDIATE");
5774
+ const retainedCount = this.enforceEventLimitWithinTransaction(this.retainedEventCount, 0);
5775
+ this.db.exec("COMMIT");
5776
+ this.retainedEventCount = retainedCount;
5777
+ } catch (error) {
5778
+ try {
5779
+ this.db.exec("ROLLBACK");
5780
+ } catch {
5988
5781
  }
5989
- if (rows.length < this.batchSize) break;
5782
+ throw error;
5990
5783
  }
5991
- page.sort((left, right) => compareEvents(left, right) * (order === "asc" ? 1 : -1));
5992
- const result = {
5993
- events: page,
5994
- total,
5995
- scannedEvents,
5996
- sourceFiles: this.diagnostics.sourceFiles,
5997
- invalidLines: this.diagnostics.invalidLines,
5998
- summary: finalizeSummary(summary2)
5784
+ }
5785
+ /**
5786
+ * Drop events older than the retention window.
5787
+ *
5788
+ * Retention is day-granular and chains are day-scoped, so a purge removes
5789
+ * whole chains rather than truncating one. That is why nothing needs to be
5790
+ * checkpointed here: there is no surviving suffix left dangling without an
5791
+ * anchor. Any checkpoint imported from a partially-purged legacy day family
5792
+ * is dropped alongside its events.
5793
+ */
5794
+ async purge(options) {
5795
+ const empty = {
5796
+ deletedCount: 0,
5797
+ deletedBytes: 0,
5798
+ skippedCount: 0,
5799
+ errors: []
5999
5800
  };
6000
- if (total > page.length && last) {
6001
- return { ...result, nextCursor: encodeCursor2(last) };
5801
+ if (!Number.isFinite(options.retentionDays) || options.retentionDays <= 0) return empty;
5802
+ const cutoff = new Date(this.now().getTime() - options.retentionDays * 864e5).toISOString().slice(0, 10);
5803
+ const count = this.db.prepare("SELECT COUNT(*) AS n FROM events WHERE day < ?").get(cutoff).n;
5804
+ if (count === 0) return empty;
5805
+ if (options.dryRun) {
5806
+ const days = this.db.prepare("SELECT DISTINCT day FROM events WHERE day < ? ORDER BY day").all(cutoff);
5807
+ return { ...empty, deletedCount: count, candidates: days.map((row) => row.day) };
6002
5808
  }
6003
- return result;
5809
+ try {
5810
+ this.db.exec("BEGIN IMMEDIATE");
5811
+ this.db.prepare("DELETE FROM events WHERE day < ?").run(cutoff);
5812
+ this.db.prepare("DELETE FROM chain_checkpoint WHERE day < ?").run(cutoff);
5813
+ this.db.exec("COMMIT");
5814
+ } catch (error) {
5815
+ try {
5816
+ this.db.exec("ROLLBACK");
5817
+ } catch {
5818
+ }
5819
+ return {
5820
+ ...empty,
5821
+ errors: [
5822
+ {
5823
+ file: this.dbPath,
5824
+ reason: error instanceof Error ? error.message : String(error)
5825
+ }
5826
+ ]
5827
+ };
5828
+ }
5829
+ this.anchors.clear();
5830
+ this.retainedEventCount = this.countRows();
5831
+ this.invalidateQuotaEstimate();
5832
+ return { ...empty, deletedCount: count };
6004
5833
  }
6005
5834
  /**
6006
- * Value counts per facet field.
5835
+ * Run one day family's legacy import inside its own transaction.
6007
5836
  *
6008
- * Not a SQL `GROUP BY`: `facetValue()` reads fields that live inside the
6009
- * payload provider, model, tool call, tag so grouping in SQL would only
6010
- * work for the handful that happen to be columns and would need a second,
6011
- * divergent definition for the rest. The narrowing is still done by SQL; the
6012
- * counting uses the JSONL engine's own projection.
5837
+ * Deliberately separate from `appendBatch`: the append path *computes*
5838
+ * `sequence`, `previousHash` and `hash`, while an import must carry them over
5839
+ * untouched. Fusing the two would put a code path one refactor away from
5840
+ * re-hashing historical events, which is the one change that silently
5841
+ * destroys their tamper evidence.
5842
+ *
5843
+ * The transaction is scoped to a single family because chains are: `sequence`
5844
+ * restarts at 1 each day, so one day's break says nothing about the next
5845
+ * day's integrity. A whole-journal transaction made every future day hostage
5846
+ * to the worst day on disk — one corrupt family and the daemon could never
5847
+ * open its store again. The family is still all-or-nothing: a break rolls
5848
+ * back that day entirely, so no partial chain is ever visible.
6013
5849
  */
6014
- async facets(fields, query = {}, limit = 100) {
6015
- const uniqueFields = [...new Set(fields)];
6016
- if (uniqueFields.length === 0) return {};
6017
- const counts = new Map(uniqueFields.map((field) => [field, /* @__PURE__ */ new Map()]));
6018
- for (const event of this.eachMatch(query)) {
6019
- for (const field of uniqueFields) {
6020
- const value = facetValue(event, field);
6021
- if (value === void 0) continue;
6022
- const fieldCounts = counts.get(field);
6023
- fieldCounts?.set(value, (fieldCounts.get(value) ?? 0) + 1);
5850
+ async runFamilyImport(load) {
5851
+ const insert = this.db.prepare(
5852
+ `INSERT INTO events (
5853
+ day, sequence, event_id, hash, previous_hash, occurred_at, event_type, outcome,
5854
+ project_id, session_id, agent_id, task_id, trace_id, logical_request_id,
5855
+ resource_kind, resource_id, resource_path, duration_ns, payload
5856
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
5857
+ );
5858
+ const checkpoint = this.db.prepare(
5859
+ `INSERT INTO chain_checkpoint (day, sequence, hash) VALUES (?, ?, ?)
5860
+ ON CONFLICT(day) DO UPDATE SET sequence = excluded.sequence, hash = excluded.hash`
5861
+ );
5862
+ this.db.exec("BEGIN IMMEDIATE");
5863
+ let retainedCountAfterCommit = this.retainedEventCount;
5864
+ try {
5865
+ this.invalidateQuotaEstimate();
5866
+ this.assertWithinByteQuota(0);
5867
+ await load({
5868
+ insert: (day, event) => {
5869
+ const row = projectEvent(event);
5870
+ insert.run(
5871
+ day,
5872
+ event.sequence,
5873
+ event.eventId,
5874
+ event.hash,
5875
+ event.previousHash,
5876
+ row.occurredAt,
5877
+ event.eventType,
5878
+ row.outcome,
5879
+ row.projectId,
5880
+ row.sessionId,
5881
+ row.agentId,
5882
+ row.taskId,
5883
+ row.traceId,
5884
+ row.logicalRequestId,
5885
+ row.resourceKind,
5886
+ row.resourceId,
5887
+ row.resourcePath,
5888
+ row.durationNs,
5889
+ JSON.stringify(event)
5890
+ );
5891
+ },
5892
+ checkpoint: (day, sequence, hash) => {
5893
+ checkpoint.run(day, sequence, hash);
5894
+ }
5895
+ });
5896
+ retainedCountAfterCommit = this.enforceEventLimitWithinTransaction(this.countRows(), 0);
5897
+ this.invalidateQuotaEstimate();
5898
+ this.assertActualAllocationWithinQuota();
5899
+ this.db.exec("COMMIT");
5900
+ } catch (error) {
5901
+ try {
5902
+ this.db.exec("ROLLBACK");
5903
+ } catch {
6024
5904
  }
5905
+ this.invalidateQuotaEstimate();
5906
+ throw this.normalizeQuotaError(error);
5907
+ } finally {
5908
+ this.anchors.clear();
6025
5909
  }
6026
- const result = {};
6027
- for (const field of uniqueFields) {
6028
- result[field] = [...counts.get(field) ?? /* @__PURE__ */ new Map()].map(([value, count]) => ({ value, count })).sort((left, right) => right.count - left.count || left.value.localeCompare(right.value)).slice(0, Math.max(0, limit));
5910
+ this.retainedEventCount = retainedCountAfterCommit;
5911
+ }
5912
+ /**
5913
+ * A read engine over this journal's own connection.
5914
+ *
5915
+ * Sharing the connection rather than opening a second one keeps the
5916
+ * single-writer guarantee intact and means a reader can never observe a
5917
+ * half-applied batch: SQLite serialises statements on one handle.
5918
+ */
5919
+ queryEngine(options) {
5920
+ return new ChronicleSqliteQueryEngine(this.db, options);
5921
+ }
5922
+ /** Has the legacy JSONL import already run? */
5923
+ hasImportedLegacyJournal() {
5924
+ return this.readMeta(LEGACY_JSONL_MIGRATION_KEY) !== void 0;
5925
+ }
5926
+ markLegacyJournalImported() {
5927
+ this.db.prepare(
5928
+ `INSERT INTO chronicle_meta (key, value) VALUES (?, 'done')
5929
+ ON CONFLICT(key) DO UPDATE SET value = 'done'`
5930
+ ).run(LEGACY_JSONL_MIGRATION_KEY);
5931
+ }
5932
+ /**
5933
+ * Record the day families the import refused to move.
5934
+ *
5935
+ * Persisted rather than merely logged because the import runs once: after the
5936
+ * marker is set nothing re-reads the JSONL, so this row is the only surviving
5937
+ * evidence that a day was dropped. Health reports read it back to say
5938
+ * "degraded, and here is exactly what is missing" instead of quietly serving
5939
+ * a journal with a hole in it.
5940
+ */
5941
+ recordQuarantinedFamilies(families) {
5942
+ if (families.length === 0) return;
5943
+ this.db.prepare(
5944
+ `INSERT INTO chronicle_meta (key, value) VALUES (?, ?)
5945
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
5946
+ ).run(LEGACY_JSONL_QUARANTINE_KEY, JSON.stringify(families));
5947
+ }
5948
+ /**
5949
+ * Does this day already hold rows?
5950
+ *
5951
+ * Each family commits on its own, so an import interrupted between families
5952
+ * leaves a database that is complete for the days it reached. `(day,
5953
+ * sequence)` is the primary key, so re-inserting one of those days would
5954
+ * abort on a constraint violation rather than start over — this is what lets
5955
+ * the next run resume at the first day it never got to.
5956
+ */
5957
+ hasImportedDay(day) {
5958
+ const row = this.db.prepare("SELECT 1 AS present FROM events WHERE day = ? LIMIT 1").get(day);
5959
+ return row !== void 0;
5960
+ }
5961
+ /** Day families the legacy import refused to move, oldest first. */
5962
+ quarantinedFamilies() {
5963
+ const raw = this.readMeta(LEGACY_JSONL_QUARANTINE_KEY);
5964
+ if (!raw) return [];
5965
+ try {
5966
+ const parsed = JSON.parse(raw);
5967
+ return Array.isArray(parsed) ? parsed : [];
5968
+ } catch {
5969
+ return [];
6029
5970
  }
6030
- return result;
6031
5971
  }
6032
- async facet(field, query = {}, limit = 100) {
6033
- return (await this.facets([field], query, limit))[field] ?? [];
5972
+ // ─── internals ────────────────────────────────────────────────────────────
5973
+ preparedStatements() {
5974
+ this.statements ??= {
5975
+ insert: this.db.prepare(
5976
+ `INSERT INTO events (
5977
+ day, sequence, event_id, hash, previous_hash, occurred_at, event_type, outcome,
5978
+ project_id, session_id, agent_id, task_id, trace_id, logical_request_id,
5979
+ resource_kind, resource_id, resource_path, duration_ns, payload
5980
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
5981
+ ),
5982
+ trimBoundary: this.db.prepare(
5983
+ "SELECT day, sequence, hash FROM events ORDER BY day, sequence LIMIT 1 OFFSET ?"
5984
+ ),
5985
+ writeCheckpoint: this.db.prepare(
5986
+ `INSERT INTO chain_checkpoint (day, sequence, hash) VALUES (?, ?, ?)
5987
+ ON CONFLICT(day) DO UPDATE SET sequence = excluded.sequence, hash = excluded.hash`
5988
+ ),
5989
+ deletePrefix: this.db.prepare(
5990
+ "DELETE FROM events WHERE day < ? OR (day = ? AND sequence <= ?)"
5991
+ ),
5992
+ deleteCheckpoints: this.db.prepare("DELETE FROM chain_checkpoint WHERE day < ?")
5993
+ };
5994
+ return this.statements;
5995
+ }
5996
+ /** Rows currently in the table, straight from the database. */
5997
+ countRows() {
5998
+ return this.db.prepare("SELECT COUNT(*) AS count FROM events").get().count;
5999
+ }
6000
+ readMeta(key) {
6001
+ const row = this.db.prepare("SELECT value FROM chronicle_meta WHERE key = ?").get(key);
6002
+ return row?.value;
6003
+ }
6004
+ readCheckpoint(day) {
6005
+ return this.db.prepare("SELECT sequence, hash FROM chain_checkpoint WHERE day = ?").get(day);
6006
+ }
6007
+ /** Days that currently hold at least one event, oldest first. */
6008
+ days() {
6009
+ const rows = this.db.prepare("SELECT DISTINCT day FROM events ORDER BY day").all();
6010
+ return rows.map((row) => row.day);
6034
6011
  }
6035
6012
  /**
6036
- * Expand explicit and typed correlation edges around a seed set.
6037
- *
6038
- * The traversal is the JSONL engine's, moved onto rows: `relationKeys()`
6039
- * defines the edges, `compareEvents` orders the nodes, and each hop is
6040
- * another ordered pass. Order is load-bearing rather than cosmetic — both
6041
- * `maxNodes` truncations stop at whatever they reach first, so a differently
6042
- * ordered scan would return a different subgraph rather than the same one
6043
- * shuffled. `ORDER BY day, sequence` reproduces `comparePartitionPaths`
6044
- * (family, then rotation index) followed by line order within a partition.
6013
+ * Resolve the chain head, in the same precedence the JSONL journal uses: the
6014
+ * newest event, else the retention checkpoint (every event before it was
6015
+ * purged), else genesis.
6045
6016
  */
6046
- async graph(seed = {}, hops = 2, maxNodes = 1e3) {
6047
- const nodeLimit = Math.max(0, Math.floor(maxNodes));
6048
- const selected = /* @__PURE__ */ new Map();
6049
- let seedCount = 0;
6050
- for (const event of this.eachMatch(seed)) {
6051
- seedCount++;
6052
- if (selected.size < nodeLimit) selected.set(event.eventId, event);
6053
- }
6054
- let frontier = [...selected.values()];
6055
- const depthLimit = Math.max(0, Math.min(hops, 10));
6056
- for (let depth = 0; depth < depthLimit && frontier.length > 0 && selected.size < nodeLimit; depth++) {
6057
- const frontierKeys = new Set(
6058
- frontier.flatMap((event) => relationKeys(event).map((relation) => relation.key))
6017
+ readAnchor(day) {
6018
+ const cached = this.anchors.get(day);
6019
+ if (cached) return cached;
6020
+ const last = this.db.prepare("SELECT sequence, hash FROM events WHERE day = ? ORDER BY sequence DESC LIMIT 1").get(day);
6021
+ const anchor = last ?? this.readCheckpoint(day) ?? { sequence: 0, hash: GENESIS_HASH };
6022
+ this.anchors.set(day, anchor);
6023
+ return anchor;
6024
+ }
6025
+ ensureSchema() {
6026
+ const version = this.db.prepare("PRAGMA user_version").get().user_version;
6027
+ this.db.exec(`
6028
+ CREATE TABLE IF NOT EXISTS events (
6029
+ day TEXT NOT NULL,
6030
+ sequence INTEGER NOT NULL,
6031
+ event_id TEXT NOT NULL UNIQUE,
6032
+ hash TEXT NOT NULL,
6033
+ previous_hash TEXT NOT NULL,
6034
+ occurred_at TEXT NOT NULL,
6035
+ event_type TEXT NOT NULL,
6036
+ outcome TEXT,
6037
+ project_id TEXT,
6038
+ session_id TEXT,
6039
+ agent_id TEXT,
6040
+ task_id TEXT,
6041
+ trace_id TEXT,
6042
+ logical_request_id TEXT,
6043
+ resource_kind TEXT,
6044
+ resource_id TEXT,
6045
+ resource_path TEXT,
6046
+ duration_ns TEXT,
6047
+ payload TEXT NOT NULL,
6048
+ PRIMARY KEY (day, sequence)
6059
6049
  );
6060
- const next = [];
6061
- for (const event of this.eachMatch({})) {
6062
- if (selected.has(event.eventId)) continue;
6063
- if (!relationKeys(event).some((relation) => frontierKeys.has(relation.key))) continue;
6064
- selected.set(event.eventId, event);
6065
- next.push(event);
6066
- if (selected.size >= nodeLimit) break;
6067
- }
6068
- frontier = next;
6069
- }
6070
- const nodes = [...selected.values()].sort(compareEvents);
6071
- const byKey = /* @__PURE__ */ new Map();
6072
- for (const node of nodes) {
6073
- for (const relation of relationKeys(node)) {
6074
- const related = byKey.get(relation.key) ?? [];
6075
- related.push(node);
6076
- byKey.set(relation.key, related);
6077
- }
6078
- }
6079
- const edges = [];
6080
- const seen = /* @__PURE__ */ new Set();
6081
- for (const node of nodes) {
6082
- for (const relation of relationKeys(node)) {
6083
- for (const candidate of byKey.get(relation.key) ?? []) {
6084
- if (candidate.eventId === node.eventId) continue;
6085
- const [from, to] = compareEvents(node, candidate) <= 0 ? [node, candidate] : [candidate, node];
6086
- const id = `${from.eventId}:${to.eventId}:${relation.kind}`;
6087
- if (seen.has(id)) continue;
6088
- seen.add(id);
6089
- edges.push({
6090
- from: from.eventId,
6091
- to: to.eventId,
6092
- kind: relation.kind,
6093
- confidence: relation.confidence
6094
- });
6095
- }
6096
- }
6050
+ CREATE INDEX IF NOT EXISTS events_occurred_at ON events(occurred_at);
6051
+ CREATE INDEX IF NOT EXISTS events_type_outcome ON events(event_type, outcome);
6052
+ CREATE INDEX IF NOT EXISTS events_session ON events(session_id, day, sequence);
6053
+ CREATE INDEX IF NOT EXISTS events_trace ON events(trace_id);
6054
+ CREATE INDEX IF NOT EXISTS events_logical_request ON events(logical_request_id);
6055
+
6056
+ CREATE TABLE IF NOT EXISTS chain_checkpoint (
6057
+ day TEXT PRIMARY KEY,
6058
+ sequence INTEGER NOT NULL,
6059
+ hash TEXT NOT NULL
6060
+ );
6061
+
6062
+ CREATE TABLE IF NOT EXISTS chronicle_meta (
6063
+ key TEXT PRIMARY KEY,
6064
+ value TEXT NOT NULL
6065
+ );
6066
+ `);
6067
+ if (version < 2) {
6068
+ this.db.exec("DROP INDEX IF EXISTS events_resource_path");
6097
6069
  }
6098
- return {
6099
- nodes,
6100
- edges,
6101
- truncated: seedCount > nodeLimit || selected.size >= nodeLimit
6102
- };
6103
- }
6104
- /** Every event satisfying `query`, pulled in batches so a wide scan stays bounded. */
6105
- *eachMatch(query) {
6106
- const pushed = pushDown(query);
6107
- const sql = `SELECT payload FROM events WHERE ${pushed.clause} ORDER BY day, sequence LIMIT ? OFFSET ?`;
6108
- let offset = 0;
6109
- for (; ; ) {
6110
- const rows = this.db.prepare(sql).all(...pushed.params, this.batchSize, offset);
6111
- if (rows.length === 0) return;
6112
- offset += rows.length;
6113
- for (const row of rows) {
6114
- let event;
6115
- try {
6116
- event = JSON.parse(row.payload);
6117
- } catch {
6118
- this.diagnostics.invalidLines++;
6119
- continue;
6120
- }
6121
- if (matches(event, query)) yield event;
6122
- }
6123
- if (rows.length < this.batchSize) return;
6070
+ if (version !== SCHEMA_VERSION) {
6071
+ this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
6124
6072
  }
6125
6073
  }
6126
6074
  };
6075
+ function projectEvent(event) {
6076
+ const occurredAt = event.occurredAt ?? event.observedAt;
6077
+ return {
6078
+ occurredAt,
6079
+ outcome: event.outcome ?? null,
6080
+ projectId: event.scope.projectId ?? null,
6081
+ sessionId: event.scope.sessionId ?? null,
6082
+ agentId: event.scope.agentId ?? null,
6083
+ taskId: event.scope.taskId ?? null,
6084
+ traceId: event.correlation?.traceId ?? null,
6085
+ logicalRequestId: event.correlation?.logicalRequestId ?? null,
6086
+ resourceKind: event.resource?.kind ?? null,
6087
+ resourceId: event.resource?.id ?? null,
6088
+ resourcePath: event.resource?.path ?? null,
6089
+ durationNs: event.durationNs ?? null
6090
+ };
6091
+ }
6127
6092
 
6128
- // src/chronicle/sqlite-journal.ts
6129
- var CHRONICLE_SQLITE_FILE = "chronicle.sqlite";
6130
- var LEGACY_JSONL_MIGRATION_KEY = "legacy-jsonl-v1";
6131
- var LEGACY_JSONL_QUARANTINE_KEY = "legacy-jsonl-v1:quarantine";
6132
- var SCHEMA_VERSION2 = 1;
6133
- var SQLITE_FIXED_OVERHEAD_BYTES = 16 * 1024 * 1024;
6134
- var MIN_SQLITE_PAGE_BUDGET_BYTES = 64 * 1024;
6135
- var MAX_ROLLBACK_JOURNAL_RESERVE_BYTES = 64 * 1024 * 1024;
6136
- var ChronicleStorageQuotaError = class extends Error {
6137
- currentBytes;
6138
- batchBytes;
6139
- maxBytes;
6140
- path;
6141
- constructor(details) {
6142
- super(
6143
- `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)`
6144
- );
6145
- this.name = "ChronicleStorageQuotaError";
6146
- this.currentBytes = details.currentBytes;
6147
- this.batchBytes = details.batchBytes;
6148
- this.maxBytes = details.maxBytes;
6149
- this.path = details.path;
6150
- }
6093
+ // src/chronicle/metrics-store.ts
6094
+ var SCHEMA_VERSION2 = 3;
6095
+ var READ_CHUNK_BYTES = 1024 * 1024;
6096
+ var SQLITE_SOURCE_PREFIX = "sqlite:";
6097
+ var SQLITE_INGEST_BATCH = 2e3;
6098
+ var EMPTY_FAMILIES = {
6099
+ llm: 0,
6100
+ agent: 0,
6101
+ tool: 0,
6102
+ file: 0,
6103
+ memory: 0,
6104
+ task: 0,
6105
+ decision: 0,
6106
+ runtime: 0,
6107
+ finding: 0
6151
6108
  };
6152
6109
  var Ctor2;
6153
6110
  function loadDatabaseSync2() {
6154
- if (Ctor2) return Ctor2;
6155
- if (Ctor2 === null) throw new Error("node:sqlite is unavailable in this runtime");
6156
- try {
6157
- Ctor2 = withSqliteExperimentalWarningSuppressed(
6158
- () => createRequire2(import.meta.url)("node:sqlite").DatabaseSync
6159
- );
6160
- return Ctor2;
6161
- } catch (error) {
6162
- Ctor2 = null;
6163
- throw new Error(
6164
- "The Chronicle journal needs Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
6165
- );
6166
- }
6167
- }
6168
- var ChronicleSqliteJournal = class {
6169
- db;
6170
- dbPath;
6171
- now;
6172
- monotonicNow;
6173
- idFactory;
6174
- retentionDays;
6175
- maxEvents;
6176
- maxBytes;
6177
- retentionCheckIntervalMs;
6178
- nextRetentionCheckAt = 0;
6179
- retainedEventCount = 0;
6180
- /** Resolved lazily by `pageSizeBytes()`; constant for an open database. */
6181
- cachedPageSize;
6182
- /**
6183
- * Cached chain head per day. Cleared wholesale on any write failure so the
6184
- * next append rebuilds from the database rather than trusting a counter that
6185
- * may have advanced past what actually committed.
6186
- */
6187
- anchors = /* @__PURE__ */ new Map();
6188
- counters = {
6189
- acceptedEvents: 0,
6190
- persistedEvents: 0,
6191
- rejectedEvents: 0,
6192
- failedEvents: 0,
6193
- batches: 0,
6194
- maxObservedPending: 0,
6195
- largestBatch: 0
6196
- };
6197
- lastBatchDurationMs;
6198
- constructor(options) {
6199
- this.dbPath = path11.join(path11.resolve(options.directory), CHRONICLE_SQLITE_FILE);
6200
- this.now = options.now ?? (() => /* @__PURE__ */ new Date());
6201
- this.monotonicNow = options.monotonicNow ?? (() => process.hrtime.bigint());
6202
- this.idFactory = options.idFactory ?? (() => randomUUID3());
6203
- if (options.retentionDays !== void 0 && (!Number.isFinite(options.retentionDays) || options.retentionDays <= 0)) {
6204
- throw new RangeError("retentionDays must be a positive finite number");
6205
- }
6206
- if (options.retentionCheckIntervalMs !== void 0 && (!Number.isFinite(options.retentionCheckIntervalMs) || options.retentionCheckIntervalMs <= 0)) {
6207
- throw new RangeError("retentionCheckIntervalMs must be a positive finite number");
6208
- }
6209
- if (options.maxEvents !== void 0 && (!Number.isInteger(options.maxEvents) || options.maxEvents < 1)) {
6210
- throw new RangeError("maxEvents must be a positive integer");
6211
- }
6212
- if (options.maxBytes !== void 0 && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1)) {
6213
- throw new RangeError("maxBytes must be a positive safe integer");
6214
- }
6215
- const minimumQuotaBytes = SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES;
6216
- if (options.maxBytes !== void 0 && options.maxBytes < minimumQuotaBytes) {
6217
- throw new RangeError(`maxBytes must be at least ${minimumQuotaBytes}`);
6218
- }
6219
- this.retentionDays = options.retentionDays;
6220
- this.maxEvents = options.maxEvents;
6221
- this.maxBytes = options.maxBytes;
6222
- this.retentionCheckIntervalMs = options.retentionCheckIntervalMs ?? 60 * 60 * 1e3;
6111
+ if (Ctor2) return Ctor2;
6112
+ if (Ctor2 === null) throw new Error("node:sqlite is unavailable in this runtime");
6113
+ try {
6114
+ Ctor2 = withSqliteExperimentalWarningSuppressed(
6115
+ () => createRequire2(import.meta.url)("node:sqlite").DatabaseSync
6116
+ );
6117
+ return Ctor2;
6118
+ } catch (error) {
6119
+ Ctor2 = null;
6120
+ throw new Error(
6121
+ "Chronicle metrics need Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
6122
+ );
6123
+ }
6124
+ }
6125
+ var ChronicleMetricsStore = class _ChronicleMetricsStore {
6126
+ db;
6127
+ directory;
6128
+ dbPath;
6129
+ constructor(directory) {
6130
+ this.directory = path10.resolve(directory);
6131
+ this.dbPath = path10.join(this.directory, "metrics.db");
6223
6132
  const Database = loadDatabaseSync2();
6224
6133
  this.db = new Database(this.dbPath);
6225
- this.db.exec(`PRAGMA journal_mode = ${this.maxBytes === void 0 ? "WAL" : "DELETE"}`);
6134
+ this.db.exec("PRAGMA journal_mode = WAL");
6226
6135
  this.ensureSchema();
6227
- this.configureByteQuota();
6228
- if (this.maxEvents !== void 0) {
6229
- const row = this.db.prepare("SELECT COUNT(*) AS count FROM events").get();
6230
- this.retainedEventCount = row.count;
6231
- this.enforceEventLimitAtStartup();
6232
- }
6136
+ }
6137
+ static open(chronicleDirectory) {
6138
+ return new _ChronicleMetricsStore(chronicleDirectory);
6233
6139
  }
6234
6140
  close() {
6235
6141
  this.db.close();
6236
6142
  }
6237
- stats() {
6238
- return {
6239
- ...this.counters,
6240
- pendingEvents: 0,
6241
- // Retained at zero for wire compatibility: partitions do not exist here,
6242
- // but `ChronicleJournalStats` is part of the IPC health payload.
6243
- partitionRolls: 0,
6244
- ...this.lastBatchDurationMs !== void 0 ? { lastBatchDurationMs: this.lastBatchDurationMs } : {}
6143
+ /** Incrementally ingest journal bytes appended since the last refresh.
6144
+ * Safe across processes: guarded by a file lock on the database path. */
6145
+ async refresh() {
6146
+ const result = {
6147
+ ingestedEvents: 0,
6148
+ ingestedBytes: 0,
6149
+ sourceFiles: 0,
6150
+ invalidLines: 0
6245
6151
  };
6152
+ await withFileLock(this.dbPath, async () => {
6153
+ const files = await findChroniclePartitions(this.directory);
6154
+ const offsets = this.loadOffsets();
6155
+ for (const file of files) {
6156
+ const key = normalizeKey2(path10.relative(this.directory, file));
6157
+ const consumed = offsets.get(key) ?? 0;
6158
+ const ingested = await this.ingestFile(file, key, consumed, result);
6159
+ if (ingested) result.sourceFiles++;
6160
+ }
6161
+ this.pruneOffsets(files);
6162
+ this.ingestSqliteJournal(offsets, result);
6163
+ });
6164
+ return result;
6246
6165
  }
6247
- /**
6248
- * Writes are synchronous and transactional, so there is nothing buffered to
6249
- * flush. Kept to satisfy `ChronicleEventSink`.
6250
- */
6251
- async flush() {
6252
- return Promise.resolve();
6166
+ providerDaily(options = {}) {
6167
+ const clauses = [];
6168
+ const params = [];
6169
+ if (options.from) {
6170
+ clauses.push("day >= ?");
6171
+ params.push(options.from.slice(0, 10));
6172
+ }
6173
+ if (options.to) {
6174
+ clauses.push("day <= ?");
6175
+ params.push(options.to.slice(0, 10));
6176
+ }
6177
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
6178
+ const rows = this.db.prepare(
6179
+ `SELECT day, provider_id, model_id, attempts, completed, failed, retries, fallbacks,
6180
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
6181
+ duration_ms_total, duration_ms_max, duration_count
6182
+ FROM provider_daily${where} ORDER BY day DESC, provider_id, model_id`
6183
+ ).all(...params);
6184
+ return rows.map((row) => ({
6185
+ day: String(row.day),
6186
+ providerId: String(row.provider_id),
6187
+ modelId: String(row.model_id),
6188
+ attempts: Number(row.attempts),
6189
+ completed: Number(row.completed),
6190
+ failed: Number(row.failed),
6191
+ retries: Number(row.retries),
6192
+ fallbacks: Number(row.fallbacks),
6193
+ inputTokens: Number(row.input_tokens),
6194
+ outputTokens: Number(row.output_tokens),
6195
+ cacheReadTokens: Number(row.cache_read_tokens),
6196
+ cacheWriteTokens: Number(row.cache_write_tokens),
6197
+ avgDurationMs: Number(row.duration_count) > 0 ? Number(row.duration_ms_total) / Number(row.duration_count) : 0,
6198
+ maxDurationMs: Number(row.duration_ms_max)
6199
+ }));
6253
6200
  }
6254
- async append(input) {
6255
- const [event] = await this.appendBatch([input]);
6256
- return event;
6201
+ taskOutcomes(options = {}) {
6202
+ const clauses = [];
6203
+ const params = [];
6204
+ if (options.runId) {
6205
+ clauses.push("t.run_id = ?");
6206
+ params.push(options.runId);
6207
+ }
6208
+ if (options.boardId) {
6209
+ clauses.push("t.board_id = ?");
6210
+ params.push(options.boardId);
6211
+ }
6212
+ if (options.sessionId) {
6213
+ clauses.push("t.session_id = ?");
6214
+ params.push(options.sessionId);
6215
+ }
6216
+ if (options.status) {
6217
+ clauses.push("t.status = ?");
6218
+ params.push(options.status);
6219
+ }
6220
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
6221
+ params.push(clampLimit(options.limit, 100));
6222
+ const rows = this.db.prepare(
6223
+ `SELECT t.*, (SELECT COUNT(*) FROM file_lineage f WHERE f.task_id = t.task_id) AS files_touched
6224
+ FROM task_outcomes t${where}
6225
+ ORDER BY COALESCE(t.started_at, '') DESC LIMIT ?`
6226
+ ).all(...params);
6227
+ return rows.map((row) => ({
6228
+ taskId: String(row.task_id),
6229
+ runId: String(row.run_id),
6230
+ boardId: String(row.board_id),
6231
+ sessionId: String(row.session_id),
6232
+ agentId: String(row.agent_id),
6233
+ status: String(row.status),
6234
+ startedAt: row.started_at === null ? null : String(row.started_at),
6235
+ endedAt: row.ended_at === null ? null : String(row.ended_at),
6236
+ durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
6237
+ retries: Number(row.retries),
6238
+ verificationFailures: Number(row.verification_failures),
6239
+ filesTouched: Number(row.files_touched)
6240
+ }));
6257
6241
  }
6258
- /**
6259
- * Append a batch as one transaction.
6260
- *
6261
- * The chain is computed in memory from a single anchor, so a partially
6262
- * applied batch would leave a hole in `sequence` that verification can never
6263
- * reconcile. Rollback plus anchor invalidation is what prevents that.
6264
- */
6265
- async appendBatch(inputs) {
6266
- if (inputs.length === 0) return [];
6267
- const started = performance.now();
6268
- this.counters.acceptedEvents += inputs.length;
6269
- this.counters.batches += 1;
6270
- this.counters.largestBatch = Math.max(this.counters.largestBatch, inputs.length);
6271
- const instant = this.now().toISOString();
6272
- const day = instant.slice(0, 10);
6273
- const events = [];
6274
- let previous = this.readAnchor(day);
6275
- for (const input of inputs) {
6276
- const unhashed = {
6277
- ...input,
6278
- occurredAt: input.occurredAt ?? instant,
6279
- monotonicNs: input.monotonicNs ?? this.monotonicNow().toString(),
6280
- schemaVersion: CHRONICLE_SCHEMA_VERSION,
6281
- eventId: this.idFactory(),
6282
- observedAt: instant,
6283
- persistedAt: instant,
6284
- sequence: previous.sequence + 1,
6285
- previousHash: previous.hash
6286
- };
6287
- const event = { ...unhashed, hash: hashValue(unhashed) };
6288
- events.push(event);
6289
- previous = { sequence: event.sequence, hash: event.hash };
6242
+ fileLineage(options = {}) {
6243
+ const clauses = [];
6244
+ const params = [];
6245
+ if (options.path) {
6246
+ clauses.push("path_key = ?");
6247
+ params.push(normalizePathKey(options.path));
6290
6248
  }
6291
- let retainedCountAfterCommit = this.retainedEventCount;
6292
- try {
6293
- this.db.exec("BEGIN IMMEDIATE");
6294
- this.assertWithinByteQuota(events);
6295
- const insert = this.db.prepare(
6296
- `INSERT INTO events (
6297
- day, sequence, event_id, hash, previous_hash, occurred_at, event_type, outcome,
6298
- project_id, session_id, agent_id, task_id, trace_id, logical_request_id,
6299
- resource_kind, resource_id, resource_path, duration_ns, payload
6300
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
6301
- );
6302
- for (const event of events) {
6303
- const row = projectEvent(event);
6304
- insert.run(
6305
- day,
6306
- event.sequence,
6307
- event.eventId,
6308
- event.hash,
6309
- event.previousHash,
6310
- row.occurredAt,
6311
- event.eventType,
6312
- row.outcome,
6313
- row.projectId,
6314
- row.sessionId,
6315
- row.agentId,
6316
- row.taskId,
6317
- row.traceId,
6318
- row.logicalRequestId,
6319
- row.resourceKind,
6320
- row.resourceId,
6321
- row.resourcePath,
6322
- row.durationNs,
6323
- JSON.stringify(event)
6324
- );
6325
- }
6326
- retainedCountAfterCommit = this.enforceEventLimitWithinTransaction();
6327
- this.assertActualAllocationWithinQuota();
6328
- this.db.exec("COMMIT");
6329
- } catch (error) {
6330
- try {
6331
- this.db.exec("ROLLBACK");
6332
- } catch {
6333
- }
6334
- this.anchors.clear();
6335
- this.counters.failedEvents += inputs.length;
6336
- this.lastBatchDurationMs = performance.now() - started;
6337
- throw this.normalizeQuotaError(error);
6249
+ if (options.paths) {
6250
+ const pathKeys = [...new Set(options.paths.map(normalizePathKey))];
6251
+ if (pathKeys.length === 0) return [];
6252
+ clauses.push(`path_key IN (${pathKeys.map(() => "?").join(",")})`);
6253
+ params.push(...pathKeys);
6338
6254
  }
6339
- this.anchors.set(day, previous);
6340
- this.counters.persistedEvents += events.length;
6341
- this.retainedEventCount = retainedCountAfterCommit;
6342
- this.lastBatchDurationMs = performance.now() - started;
6343
- await this.enforceRetentionIfDue();
6344
- return events;
6255
+ if (options.taskId) {
6256
+ clauses.push("task_id = ?");
6257
+ params.push(options.taskId);
6258
+ }
6259
+ if (options.boardId) {
6260
+ clauses.push("board_id = ?");
6261
+ params.push(options.boardId);
6262
+ }
6263
+ if (options.sessionId) {
6264
+ clauses.push("session_id = ?");
6265
+ params.push(options.sessionId);
6266
+ }
6267
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
6268
+ params.push(clampLimit(options.limit, 200));
6269
+ const projection = `path, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
6270
+ tool_name, provider_id, model_id, source`;
6271
+ const sql = options.latestPerPath ? `SELECT ${projection} FROM (
6272
+ SELECT ${projection}, ROW_NUMBER() OVER (
6273
+ PARTITION BY path_key ORDER BY occurred_at DESC, event_id DESC
6274
+ ) AS path_rank
6275
+ FROM file_lineage${where}
6276
+ ) WHERE path_rank = 1 ORDER BY occurred_at DESC LIMIT ?` : `SELECT ${projection}
6277
+ FROM file_lineage${where} ORDER BY occurred_at DESC LIMIT ?`;
6278
+ const rows = this.db.prepare(sql).all(...params);
6279
+ return rows.map((row) => ({
6280
+ path: row.path,
6281
+ operation: row.operation,
6282
+ occurredAt: row.occurred_at,
6283
+ sessionId: row.session_id,
6284
+ agentId: row.agent_id,
6285
+ taskId: row.task_id,
6286
+ boardId: row.board_id,
6287
+ runId: row.run_id,
6288
+ toolName: row.tool_name,
6289
+ providerId: row.provider_id,
6290
+ modelId: row.model_id,
6291
+ source: row.source
6292
+ }));
6345
6293
  }
6346
- async readAll() {
6347
- const rows = this.db.prepare("SELECT payload FROM events ORDER BY day, sequence").all();
6348
- return rows.map((row) => JSON.parse(row.payload));
6294
+ summary() {
6295
+ const provider = this.db.prepare(
6296
+ "SELECT COALESCE(SUM(attempts),0) a, COALESCE(SUM(completed),0) c, COALESCE(SUM(failed),0) f FROM provider_daily"
6297
+ ).get();
6298
+ const tasks = {};
6299
+ for (const row of this.db.prepare("SELECT status, COUNT(*) n FROM task_outcomes GROUP BY status").all()) {
6300
+ tasks[row.status] = Number(row.n);
6301
+ }
6302
+ const files = this.db.prepare("SELECT COUNT(*) n, COUNT(DISTINCT path) p FROM file_lineage").get();
6303
+ const cost = this.db.prepare("SELECT COALESCE(SUM(cost),0) c FROM token_cost").get();
6304
+ const terminal = Number(provider.c) + Number(provider.f);
6305
+ return {
6306
+ providers: {
6307
+ attempts: Number(provider.a),
6308
+ completed: Number(provider.c),
6309
+ failed: Number(provider.f),
6310
+ successRate: terminal > 0 ? Number(provider.c) / terminal : 0
6311
+ },
6312
+ tasks,
6313
+ files: { mutations: Number(files.n), uniquePaths: Number(files.p) },
6314
+ estimatedCostUsd: Number(cost.c)
6315
+ };
6349
6316
  }
6350
6317
  /**
6351
- * Walk every chain and prove none has been edited.
6352
- *
6353
- * Chronicle chains are scoped to a day, not to the journal: the JSONL writer
6354
- * anchors each `<day>.events.jsonl` family at `GENESIS_HASH` independently,
6355
- * so `sequence` restarts at 1 every day. Verification mirrors that — each day
6356
- * is validated on its own, and a break in one does not implicate the others.
6357
- *
6358
- * Three independent properties per day, because each catches a different
6359
- * failure: a dense `sequence` catches deletions from the middle, the
6360
- * `previousHash` link catches reordering, and re-hashing the payload catches
6361
- * an in-place edit that left the links intact.
6318
+ * A `ChronicleSummary` for the default/unfiltered dashboard view only
6319
+ * `from`/`to` (day-precision) narrow it. Any other ad hoc filter (text,
6320
+ * path, provider, model, session) can't be answered from these
6321
+ * fixed-dimension aggregates; callers must fall back to query.ts's
6322
+ * raw-scan summary for those.
6362
6323
  */
6363
- async verify() {
6364
- let entries = 0;
6365
- let lastSequence = 0;
6366
- let lastHash = GENESIS_HASH;
6367
- for (const day of this.days()) {
6368
- const checkpoint = this.readCheckpoint(day);
6369
- let expectedSequence = (checkpoint?.sequence ?? 0) + 1;
6370
- let previousHash = checkpoint?.hash ?? GENESIS_HASH;
6371
- const rows = this.db.prepare(
6372
- "SELECT sequence, hash, previous_hash, payload FROM events WHERE day = ? ORDER BY sequence"
6373
- ).all(day);
6374
- for (const row of rows) {
6375
- if (row.sequence !== expectedSequence) {
6376
- return {
6377
- ok: false,
6378
- entries,
6379
- brokenAt: entries,
6380
- reason: `sequence gap in ${day}: expected ${expectedSequence}, found ${row.sequence}`
6381
- };
6382
- }
6383
- if (row.previous_hash !== previousHash) {
6384
- return { ok: false, entries, brokenAt: entries, reason: "previous hash mismatch" };
6385
- }
6386
- let event;
6387
- try {
6388
- event = JSON.parse(row.payload);
6389
- } catch {
6390
- return { ok: false, entries, brokenAt: entries, reason: "invalid payload JSON" };
6391
- }
6392
- if (chronicleEventHash(event) !== row.hash) {
6393
- return { ok: false, entries, brokenAt: entries, reason: "entry hash mismatch" };
6394
- }
6395
- entries += 1;
6396
- expectedSequence = row.sequence + 1;
6397
- previousHash = row.hash;
6324
+ defaultSummary(options = {}) {
6325
+ const fromDay = options.from?.slice(0, 10);
6326
+ const toDay = options.to?.slice(0, 10);
6327
+ const dayFilter = (column) => {
6328
+ const clauses = [];
6329
+ const params = [];
6330
+ if (fromDay) {
6331
+ clauses.push(`${column} >= ?`);
6332
+ params.push(fromDay);
6398
6333
  }
6399
- lastSequence = expectedSequence - 1;
6400
- lastHash = previousHash;
6401
- }
6402
- return { ok: true, entries, lastSequence, lastHash };
6403
- }
6404
- async enforceRetentionIfDue() {
6405
- if (this.retentionDays === void 0) return;
6406
- const now = this.now();
6407
- if (now.getTime() < this.nextRetentionCheckAt) return;
6408
- this.nextRetentionCheckAt = now.getTime() + this.retentionCheckIntervalMs;
6409
- try {
6410
- await this.purge({ retentionDays: this.retentionDays });
6411
- } catch {
6334
+ if (toDay) {
6335
+ clauses.push(`${column} <= ?`);
6336
+ params.push(toDay);
6337
+ }
6338
+ return { where: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "", params };
6339
+ };
6340
+ const providerRange = dayFilter("day");
6341
+ const provider = this.db.prepare(
6342
+ `SELECT COALESCE(SUM(attempts),0) attempts, COALESCE(SUM(completed),0) completed, COALESCE(SUM(failed),0) failed,
6343
+ COALESCE(SUM(retries),0) retries, COALESCE(SUM(fallbacks),0) fallbacks,
6344
+ COUNT(DISTINCT provider_id) providers, COUNT(DISTINCT model_id) models,
6345
+ COALESCE(SUM(input_tokens),0) inputTokens, COALESCE(SUM(output_tokens),0) outputTokens,
6346
+ COALESCE(SUM(cache_read_tokens),0) cacheReadTokens, COALESCE(SUM(cache_write_tokens),0) cacheWriteTokens,
6347
+ COALESCE(SUM(duration_ms_total),0) durationTotal, COALESCE(MAX(duration_ms_max),0) durationMax,
6348
+ COALESCE(SUM(duration_count),0) durationCount
6349
+ FROM provider_daily${providerRange.where}`
6350
+ ).get(...providerRange.params);
6351
+ const counterRange = dayFilter("day");
6352
+ const counters = this.db.prepare(
6353
+ `SELECT COALESCE(SUM(tool_calls),0) toolCalls, COALESCE(SUM(completed_tools),0) completedTools,
6354
+ COALESCE(SUM(failed_tools),0) failedTools, COALESCE(SUM(tool_duration_ms_total),0) toolDurationTotal,
6355
+ COALESCE(SUM(tool_duration_count),0) toolDurationCount, COALESCE(SUM(processes),0) processes,
6356
+ COALESCE(SUM(failed_processes),0) failedProcesses, COALESCE(SUM(file_events_all),0) fileEvents,
6357
+ COALESCE(SUM(decisions),0) decisions, COALESCE(SUM(escalations),0) escalations,
6358
+ COALESCE(SUM(agent_events),0) agentEvents, COALESCE(SUM(failures),0) failures,
6359
+ COALESCE(SUM(cancellations),0) cancellations
6360
+ FROM daily_counters${counterRange.where}`
6361
+ ).get(...counterRange.params);
6362
+ const familyRange = dayFilter("day");
6363
+ const familyRows = this.db.prepare(`SELECT family, count, failure_count FROM family_daily${familyRange.where}`).all(...familyRange.params);
6364
+ const families = { ...EMPTY_FAMILIES };
6365
+ const failuresByFamily = { ...EMPTY_FAMILIES };
6366
+ for (const row of familyRows) {
6367
+ const family = row.family;
6368
+ families[family] = Number(row.count);
6369
+ failuresByFamily[family] = Number(row.failure_count);
6412
6370
  }
6371
+ const agentRange = dayFilter("day");
6372
+ const uniqueAgents = this.db.prepare(`SELECT COUNT(DISTINCT agent_id) n FROM agent_daily${agentRange.where}`).get(...agentRange.params).n;
6373
+ const requestRange = dayFilter("day");
6374
+ const logicalRequests = this.db.prepare(
6375
+ `SELECT COUNT(DISTINCT logical_request_id) n FROM logical_request_daily${requestRange.where}`
6376
+ ).get(...requestRange.params).n;
6377
+ const fileRange = dayFilter("day");
6378
+ const uniqueFiles = this.db.prepare(`SELECT COUNT(DISTINCT path_key) n FROM file_seen_daily${fileRange.where}`).get(...fileRange.params).n;
6379
+ const costRange = dayFilter("day");
6380
+ const cost = this.db.prepare(`SELECT COALESCE(SUM(cost),0) c FROM token_cost${costRange.where}`).get(...costRange.params).c;
6381
+ return {
6382
+ logicalRequests: Number(logicalRequests),
6383
+ modelAttempts: Number(provider.attempts),
6384
+ completedAttempts: Number(provider.completed),
6385
+ failedAttempts: Number(provider.failed),
6386
+ scheduledRetries: Number(provider.retries),
6387
+ fallbacks: Number(provider.fallbacks),
6388
+ providers: Number(provider.providers),
6389
+ models: Number(provider.models),
6390
+ inputTokens: Number(provider.inputTokens),
6391
+ outputTokens: Number(provider.outputTokens),
6392
+ cacheReadTokens: Number(provider.cacheReadTokens),
6393
+ cacheWriteTokens: Number(provider.cacheWriteTokens),
6394
+ estimatedCostUsd: Number(cost),
6395
+ providerAvgDurationMs: Number(provider.durationCount) > 0 ? Number(provider.durationTotal) / Number(provider.durationCount) : 0,
6396
+ // True p95 needs a retained distribution; this per-day aggregate only
6397
+ // keeps sum/max/count, so approximate with the observed max rather
6398
+ // than adding a per-attempt histogram write (would add the same kind
6399
+ // of per-event overhead this whole effort is trying to remove).
6400
+ providerP95DurationMs: Number(provider.durationMax),
6401
+ toolCalls: Number(counters.toolCalls),
6402
+ completedTools: Number(counters.completedTools),
6403
+ failedTools: Number(counters.failedTools),
6404
+ toolAvgDurationMs: Number(counters.toolDurationCount) > 0 ? Number(counters.toolDurationTotal) / Number(counters.toolDurationCount) : 0,
6405
+ processes: Number(counters.processes),
6406
+ failedProcesses: Number(counters.failedProcesses),
6407
+ fileEvents: Number(counters.fileEvents),
6408
+ uniqueFiles: Number(uniqueFiles),
6409
+ agentEvents: Number(counters.agentEvents),
6410
+ uniqueAgents: Number(uniqueAgents),
6411
+ decisions: Number(counters.decisions),
6412
+ escalations: Number(counters.escalations),
6413
+ failures: Number(counters.failures),
6414
+ cancellations: Number(counters.cancellations),
6415
+ families,
6416
+ failuresByFamily
6417
+ };
6413
6418
  }
6414
- configureByteQuota() {
6415
- if (this.maxBytes === void 0) {
6416
- this.db.exec("PRAGMA max_page_count = 2147483646");
6417
- return;
6419
+ // ─── Ingest internals ─────────────────────────────────────────────────────
6420
+ ensureSchema() {
6421
+ const version = this.db.prepare("PRAGMA user_version").get().user_version;
6422
+ if (version !== 0 && version !== SCHEMA_VERSION2) {
6423
+ this.db.exec(
6424
+ "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;"
6425
+ );
6418
6426
  }
6419
- const halfSplit = Math.floor((this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES) / 2);
6420
- const journalReserve = Math.min(MAX_ROLLBACK_JOURNAL_RESERVE_BYTES, halfSplit);
6421
- const mainBudget = this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES - journalReserve;
6422
- if (mainBudget < MIN_SQLITE_PAGE_BUDGET_BYTES) {
6423
- throw new Error(
6424
- `maxBytes must be at least ${SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES}`
6427
+ this.db.exec(`
6428
+ CREATE TABLE IF NOT EXISTS ingest_state (
6429
+ file TEXT PRIMARY KEY,
6430
+ bytes INTEGER NOT NULL
6431
+ );
6432
+ CREATE TABLE IF NOT EXISTS provider_daily (
6433
+ day TEXT NOT NULL,
6434
+ provider_id TEXT NOT NULL,
6435
+ model_id TEXT NOT NULL,
6436
+ attempts INTEGER NOT NULL DEFAULT 0,
6437
+ completed INTEGER NOT NULL DEFAULT 0,
6438
+ failed INTEGER NOT NULL DEFAULT 0,
6439
+ retries INTEGER NOT NULL DEFAULT 0,
6440
+ fallbacks INTEGER NOT NULL DEFAULT 0,
6441
+ input_tokens INTEGER NOT NULL DEFAULT 0,
6442
+ output_tokens INTEGER NOT NULL DEFAULT 0,
6443
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
6444
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
6445
+ duration_ms_total REAL NOT NULL DEFAULT 0,
6446
+ duration_ms_max REAL NOT NULL DEFAULT 0,
6447
+ duration_count INTEGER NOT NULL DEFAULT 0,
6448
+ PRIMARY KEY (day, provider_id, model_id)
6449
+ );
6450
+ CREATE TABLE IF NOT EXISTS task_outcomes (
6451
+ task_id TEXT PRIMARY KEY,
6452
+ run_id TEXT NOT NULL DEFAULT '',
6453
+ board_id TEXT NOT NULL DEFAULT '',
6454
+ session_id TEXT NOT NULL DEFAULT '',
6455
+ agent_id TEXT NOT NULL DEFAULT '',
6456
+ status TEXT NOT NULL DEFAULT 'started',
6457
+ started_at TEXT,
6458
+ ended_at TEXT,
6459
+ duration_ms REAL,
6460
+ retries INTEGER NOT NULL DEFAULT 0,
6461
+ verification_failures INTEGER NOT NULL DEFAULT 0
6462
+ );
6463
+ CREATE TABLE IF NOT EXISTS file_lineage (
6464
+ event_id TEXT PRIMARY KEY,
6465
+ path TEXT NOT NULL,
6466
+ path_key TEXT NOT NULL,
6467
+ operation TEXT NOT NULL,
6468
+ occurred_at TEXT NOT NULL,
6469
+ session_id TEXT NOT NULL DEFAULT '',
6470
+ agent_id TEXT NOT NULL DEFAULT '',
6471
+ task_id TEXT NOT NULL DEFAULT '',
6472
+ board_id TEXT NOT NULL DEFAULT '',
6473
+ run_id TEXT NOT NULL DEFAULT '',
6474
+ tool_name TEXT NOT NULL DEFAULT '',
6475
+ provider_id TEXT NOT NULL DEFAULT '',
6476
+ model_id TEXT NOT NULL DEFAULT '',
6477
+ source TEXT NOT NULL DEFAULT ''
6478
+ );
6479
+ -- Lookups filter on the case-normalized path_key (matching the query
6480
+ -- engine); the path column retains original casing for display.
6481
+ CREATE INDEX IF NOT EXISTS idx_file_lineage_path ON file_lineage(path_key, occurred_at);
6482
+ CREATE INDEX IF NOT EXISTS idx_file_lineage_task ON file_lineage(task_id);
6483
+ CREATE TABLE IF NOT EXISTS token_cost (
6484
+ scope_key TEXT PRIMARY KEY,
6485
+ day TEXT NOT NULL,
6486
+ occurred_at TEXT NOT NULL,
6487
+ sequence INTEGER NOT NULL,
6488
+ cost REAL NOT NULL
6425
6489
  );
6426
- }
6427
- const maxPages = Math.max(1, Math.floor(mainBudget / this.pageSizeBytes()));
6428
- this.db.exec(`PRAGMA max_page_count = ${maxPages}`);
6429
- }
6430
- /**
6431
- * Bytes the journal actually occupies: live pages in the main database plus
6432
- * whatever the rollback/WAL sidecars currently hold.
6433
- *
6434
- * The quota MUST be measured this way rather than by `statSync` on the main
6435
- * database file. SQLite never returns freed pages to the filesystem — it
6436
- * parks them on the freelist and reuses them — so a database that once grew
6437
- * large keeps that size forever, even after retention and `maxEvents` have
6438
- * evicted almost everything. Measuring the file instead wedged the journal
6439
- * permanently: a 3.9 GB file whose live data was 243 MB failed a 512 MB
6440
- * quota on EVERY append, and the only escape (`chronicle compact`) refuses
6441
- * to run while the daemon is up — and the daemon respawns on demand. Live
6442
- * pages shrink when retention deletes rows, so the quota can recover on its
6443
- * own; allocated size cannot.
6444
- *
6445
- * The sidecars keep their on-disk size: they are transient and genuinely
6446
- * bounded by `configureByteQuota`'s half-of-budget split.
6447
- */
6448
- aggregateLiveBytes() {
6449
- let total = 0;
6450
- for (const file of [`${this.dbPath}-journal`, `${this.dbPath}-wal`, `${this.dbPath}-shm`]) {
6451
- try {
6452
- total += fs8.statSync(file).size;
6453
- } catch (error) {
6454
- if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
6455
- }
6456
- }
6457
- return total + this.mainDatabaseLiveBytes();
6458
- }
6459
- /** Live (non-freelist) pages of the main database, in bytes. */
6460
- mainDatabaseLiveBytes() {
6461
- const pageCount = Number(
6462
- this.db.prepare("PRAGMA page_count").get().page_count
6463
- );
6464
- const freelist = Number(
6465
- this.db.prepare("PRAGMA freelist_count").get().freelist_count
6466
- );
6467
- return Math.max(0, pageCount - freelist) * this.pageSizeBytes();
6468
- }
6469
- /** Page size never changes for an open database; resolve it once. */
6470
- pageSizeBytes() {
6471
- if (this.cachedPageSize === void 0) {
6472
- this.cachedPageSize = Number(
6473
- this.db.prepare("PRAGMA page_size").get().page_size
6490
+ -- Backing store for defaultSummary(): per-day scalar counters plus
6491
+ -- dedup sets, populated for every ingested event (not just the
6492
+ -- provider/task/file families above).
6493
+ CREATE TABLE IF NOT EXISTS daily_counters (
6494
+ day TEXT PRIMARY KEY,
6495
+ tool_calls INTEGER NOT NULL DEFAULT 0,
6496
+ completed_tools INTEGER NOT NULL DEFAULT 0,
6497
+ failed_tools INTEGER NOT NULL DEFAULT 0,
6498
+ tool_duration_ms_total REAL NOT NULL DEFAULT 0,
6499
+ tool_duration_ms_max REAL NOT NULL DEFAULT 0,
6500
+ tool_duration_count INTEGER NOT NULL DEFAULT 0,
6501
+ processes INTEGER NOT NULL DEFAULT 0,
6502
+ failed_processes INTEGER NOT NULL DEFAULT 0,
6503
+ file_events_all INTEGER NOT NULL DEFAULT 0,
6504
+ decisions INTEGER NOT NULL DEFAULT 0,
6505
+ escalations INTEGER NOT NULL DEFAULT 0,
6506
+ agent_events INTEGER NOT NULL DEFAULT 0,
6507
+ failures INTEGER NOT NULL DEFAULT 0,
6508
+ cancellations INTEGER NOT NULL DEFAULT 0
6474
6509
  );
6475
- }
6476
- return this.cachedPageSize;
6477
- }
6478
- normalizeQuotaError(error) {
6479
- if (this.maxBytes === void 0 || !(error instanceof Error)) return error;
6480
- const sqliteError = error;
6481
- const quotaExhausted = sqliteError.code === "SQLITE_FULL" || sqliteError.code === 13 || sqliteError.errcode === 13 || /database or disk is full/i.test(sqliteError.message);
6482
- if (!quotaExhausted) return error;
6483
- return new ChronicleStorageQuotaError({
6484
- currentBytes: this.aggregateLiveBytes(),
6485
- batchBytes: 0,
6486
- maxBytes: this.maxBytes,
6487
- path: this.dbPath
6488
- });
6489
- }
6490
- assertActualAllocationWithinQuota() {
6491
- if (this.maxBytes === void 0) return;
6492
- const currentBytes = this.aggregateLiveBytes();
6493
- if (currentBytes <= this.maxBytes) return;
6494
- throw new ChronicleStorageQuotaError({
6495
- currentBytes,
6496
- batchBytes: 0,
6497
- maxBytes: this.maxBytes,
6498
- path: this.dbPath
6499
- });
6500
- }
6501
- assertWithinByteQuota(events) {
6502
- if (this.maxBytes === void 0) return;
6503
- const currentBytes = this.aggregateLiveBytes();
6504
- const batchBytes = Buffer.byteLength(JSON.stringify(events), "utf8");
6505
- if (currentBytes + batchBytes <= this.maxBytes) return;
6506
- throw new ChronicleStorageQuotaError({
6507
- currentBytes,
6508
- batchBytes,
6509
- maxBytes: this.maxBytes,
6510
- path: this.dbPath
6511
- });
6510
+ CREATE TABLE IF NOT EXISTS family_daily (
6511
+ day TEXT NOT NULL,
6512
+ family TEXT NOT NULL,
6513
+ count INTEGER NOT NULL DEFAULT 0,
6514
+ failure_count INTEGER NOT NULL DEFAULT 0,
6515
+ PRIMARY KEY (day, family)
6516
+ );
6517
+ CREATE TABLE IF NOT EXISTS agent_daily (day TEXT NOT NULL, agent_id TEXT NOT NULL, PRIMARY KEY (day, agent_id));
6518
+ CREATE TABLE IF NOT EXISTS logical_request_daily (day TEXT NOT NULL, logical_request_id TEXT NOT NULL, PRIMARY KEY (day, logical_request_id));
6519
+ CREATE TABLE IF NOT EXISTS file_seen_daily (day TEXT NOT NULL, path_key TEXT NOT NULL, PRIMARY KEY (day, path_key));
6520
+ PRAGMA user_version = ${SCHEMA_VERSION2};
6521
+ `);
6512
6522
  }
6513
- enforceEventLimitWithinTransaction() {
6514
- const row = this.db.prepare("SELECT COUNT(*) AS count FROM events").get();
6515
- if (this.maxEvents === void 0 || row.count <= this.maxEvents) return row.count;
6516
- const excess = row.count - this.maxEvents;
6517
- const boundary = this.db.prepare("SELECT day, sequence, hash FROM events ORDER BY day, sequence LIMIT 1 OFFSET ?").get(excess - 1);
6518
- if (!boundary) return row.count;
6519
- this.db.prepare(
6520
- `INSERT INTO chain_checkpoint (day, sequence, hash) VALUES (?, ?, ?)
6521
- ON CONFLICT(day) DO UPDATE SET sequence = excluded.sequence, hash = excluded.hash`
6522
- ).run(boundary.day, boundary.sequence, boundary.hash);
6523
- this.db.prepare("DELETE FROM events WHERE day < ? OR (day = ? AND sequence <= ?)").run(boundary.day, boundary.day, boundary.sequence);
6524
- this.db.prepare("DELETE FROM chain_checkpoint WHERE day < ?").run(boundary.day);
6525
- this.anchors.clear();
6526
- return this.maxEvents;
6523
+ loadOffsets() {
6524
+ const rows = this.db.prepare("SELECT file, bytes FROM ingest_state").all();
6525
+ return new Map(rows.map((row) => [row.file, Number(row.bytes)]));
6527
6526
  }
6528
- enforceEventLimitAtStartup() {
6529
- if (this.maxEvents === void 0 || this.retainedEventCount <= this.maxEvents) return;
6530
- try {
6531
- this.db.exec("BEGIN IMMEDIATE");
6532
- const retainedCount = this.enforceEventLimitWithinTransaction();
6533
- this.db.exec("COMMIT");
6534
- this.retainedEventCount = retainedCount;
6535
- } catch (error) {
6536
- try {
6537
- this.db.exec("ROLLBACK");
6538
- } catch {
6539
- }
6540
- throw error;
6527
+ pruneOffsets(existingFiles) {
6528
+ const keep = new Set(
6529
+ existingFiles.map((file) => normalizeKey2(path10.relative(this.directory, file)))
6530
+ );
6531
+ for (const row of this.db.prepare("SELECT file FROM ingest_state").all()) {
6532
+ if (row.file.startsWith(SQLITE_SOURCE_PREFIX)) continue;
6533
+ if (!keep.has(row.file))
6534
+ this.db.prepare("DELETE FROM ingest_state WHERE file = ?").run(row.file);
6541
6535
  }
6542
6536
  }
6543
6537
  /**
6544
- * Drop events older than the retention window.
6538
+ * Fold everything the SQLite journal holds past this store's per-day cursor.
6545
6539
  *
6546
- * Retention is day-granular and chains are day-scoped, so a purge removes
6547
- * whole chains rather than truncating one. That is why nothing needs to be
6548
- * checkpointed here: there is no surviving suffix left dangling without an
6549
- * anchor. Any checkpoint imported from a partially-purged legacy day family
6550
- * is dropped alongside its events.
6540
+ * Opened read-only on its own connection: the journal runs in WAL, so this
6541
+ * never blocks the daemon writing to it, and metrics are best-effort a
6542
+ * journal that cannot be opened (mid-migration, absent, locked) leaves the
6543
+ * cursors untouched and the next refresh retries.
6544
+ *
6545
+ * Rows the journal has already evicted are simply not seen. That is the
6546
+ * intended split of responsibilities: the journal is a bounded ring, and this
6547
+ * store is where an aggregate outlives the raw event it came from — which
6548
+ * only holds if refresh runs more often than the ring turns over.
6551
6549
  */
6552
- async purge(options) {
6553
- const empty = {
6554
- deletedCount: 0,
6555
- deletedBytes: 0,
6556
- skippedCount: 0,
6557
- errors: []
6558
- };
6559
- if (!Number.isFinite(options.retentionDays) || options.retentionDays <= 0) return empty;
6560
- const cutoff = new Date(this.now().getTime() - options.retentionDays * 864e5).toISOString().slice(0, 10);
6561
- const count = this.db.prepare("SELECT COUNT(*) AS n FROM events WHERE day < ?").get(cutoff).n;
6562
- if (count === 0) return empty;
6563
- if (options.dryRun) {
6564
- const days = this.db.prepare("SELECT DISTINCT day FROM events WHERE day < ? ORDER BY day").all(cutoff);
6565
- return { ...empty, deletedCount: count, candidates: days.map((row) => row.day) };
6566
- }
6550
+ ingestSqliteJournal(offsets, result) {
6551
+ const journalPath = path10.join(this.directory, CHRONICLE_SQLITE_FILE);
6552
+ let source;
6567
6553
  try {
6568
- this.db.exec("BEGIN IMMEDIATE");
6569
- this.db.prepare("DELETE FROM events WHERE day < ?").run(cutoff);
6570
- this.db.prepare("DELETE FROM chain_checkpoint WHERE day < ?").run(cutoff);
6571
- this.db.exec("COMMIT");
6572
- } catch (error) {
6573
- try {
6574
- this.db.exec("ROLLBACK");
6575
- } catch {
6576
- }
6577
- return {
6578
- ...empty,
6579
- errors: [
6580
- {
6581
- file: this.dbPath,
6582
- reason: error instanceof Error ? error.message : String(error)
6583
- }
6584
- ]
6585
- };
6554
+ source = new (loadDatabaseSync2())(journalPath, { readOnly: true });
6555
+ } catch {
6556
+ return;
6586
6557
  }
6587
- this.anchors.clear();
6588
- this.retainedEventCount = Math.max(0, this.retainedEventCount - count);
6589
- return { ...empty, deletedCount: count };
6590
- }
6591
- /**
6592
- * Run one day family's legacy import inside its own transaction.
6593
- *
6594
- * Deliberately separate from `appendBatch`: the append path *computes*
6595
- * `sequence`, `previousHash` and `hash`, while an import must carry them over
6596
- * untouched. Fusing the two would put a code path one refactor away from
6597
- * re-hashing historical events, which is the one change that silently
6598
- * destroys their tamper evidence.
6599
- *
6600
- * The transaction is scoped to a single family because chains are: `sequence`
6601
- * restarts at 1 each day, so one day's break says nothing about the next
6602
- * day's integrity. A whole-journal transaction made every future day hostage
6603
- * to the worst day on disk — one corrupt family and the daemon could never
6604
- * open its store again. The family is still all-or-nothing: a break rolls
6605
- * back that day entirely, so no partial chain is ever visible.
6606
- */
6607
- async runFamilyImport(load) {
6608
- const insert = this.db.prepare(
6609
- `INSERT INTO events (
6610
- day, sequence, event_id, hash, previous_hash, occurred_at, event_type, outcome,
6611
- project_id, session_id, agent_id, task_id, trace_id, logical_request_id,
6612
- resource_kind, resource_id, resource_path, duration_ns, payload
6613
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
6614
- );
6615
- const checkpoint = this.db.prepare(
6616
- `INSERT INTO chain_checkpoint (day, sequence, hash) VALUES (?, ?, ?)
6617
- ON CONFLICT(day) DO UPDATE SET sequence = excluded.sequence, hash = excluded.hash`
6618
- );
6619
- this.db.exec("BEGIN IMMEDIATE");
6620
- let retainedCountAfterCommit = this.retainedEventCount;
6621
6558
  try {
6622
- this.assertWithinByteQuota([]);
6623
- await load({
6624
- insert: (day, event) => {
6625
- const row = projectEvent(event);
6626
- insert.run(
6627
- day,
6628
- event.sequence,
6629
- event.eventId,
6630
- event.hash,
6631
- event.previousHash,
6632
- row.occurredAt,
6633
- event.eventType,
6634
- row.outcome,
6635
- row.projectId,
6636
- row.sessionId,
6637
- row.agentId,
6638
- row.taskId,
6639
- row.traceId,
6640
- row.logicalRequestId,
6641
- row.resourceKind,
6642
- row.resourceId,
6643
- row.resourcePath,
6644
- row.durationNs,
6645
- JSON.stringify(event)
6646
- );
6647
- },
6648
- checkpoint: (day, sequence, hash) => {
6649
- checkpoint.run(day, sequence, hash);
6559
+ const days = source.prepare("SELECT DISTINCT day FROM events ORDER BY day").all();
6560
+ const read = source.prepare(
6561
+ "SELECT sequence, payload FROM events WHERE day = ? AND sequence > ? ORDER BY sequence LIMIT ?"
6562
+ );
6563
+ const writeCursor = this.db.prepare(
6564
+ "INSERT INTO ingest_state (file, bytes) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET bytes = excluded.bytes"
6565
+ );
6566
+ for (const { day } of days) {
6567
+ const key = `${SQLITE_SOURCE_PREFIX}${day}`;
6568
+ const from = offsets.get(key) ?? 0;
6569
+ let cursor = from;
6570
+ this.db.exec("BEGIN");
6571
+ try {
6572
+ for (; ; ) {
6573
+ const rows = read.all(day, cursor, SQLITE_INGEST_BATCH);
6574
+ if (rows.length === 0) break;
6575
+ for (const row of rows) {
6576
+ try {
6577
+ this.ingestEvent(JSON.parse(row.payload));
6578
+ result.ingestedEvents++;
6579
+ } catch {
6580
+ result.invalidLines++;
6581
+ }
6582
+ result.ingestedBytes += row.payload.length;
6583
+ cursor = Number(row.sequence);
6584
+ }
6585
+ if (rows.length < SQLITE_INGEST_BATCH) break;
6586
+ }
6587
+ if (cursor > from) writeCursor.run(key, cursor);
6588
+ this.db.exec("COMMIT");
6589
+ } catch (error) {
6590
+ this.db.exec("ROLLBACK");
6591
+ throw error;
6650
6592
  }
6651
- });
6652
- retainedCountAfterCommit = this.enforceEventLimitWithinTransaction();
6653
- this.assertActualAllocationWithinQuota();
6654
- this.db.exec("COMMIT");
6655
- } catch (error) {
6593
+ if (cursor > from) result.sourceFiles++;
6594
+ }
6595
+ } catch {
6596
+ } finally {
6597
+ source.close();
6598
+ }
6599
+ }
6600
+ /** Read complete lines appended after `consumed` bytes. The trailing
6601
+ * partial line of an actively-written partition is left for the next
6602
+ * refresh — `ingest_state.bytes` only ever advances past full lines. */
6603
+ async ingestFile(file, key, consumed, result) {
6604
+ let handle;
6605
+ try {
6606
+ handle = await fs7.open(file, "r");
6607
+ } catch {
6608
+ return false;
6609
+ }
6610
+ try {
6611
+ const size = (await handle.stat()).size;
6612
+ if (size <= consumed) return false;
6613
+ let position = consumed;
6614
+ let remainder = Buffer.alloc(0);
6615
+ let advanced = consumed;
6616
+ this.db.exec("BEGIN");
6656
6617
  try {
6618
+ while (position < size) {
6619
+ const length = Math.min(READ_CHUNK_BYTES, size - position);
6620
+ const buffer = Buffer.allocUnsafe(length);
6621
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
6622
+ if (bytesRead <= 0) break;
6623
+ position += bytesRead;
6624
+ const data = remainder.length > 0 ? Buffer.concat([remainder, buffer.subarray(0, bytesRead)]) : buffer.subarray(0, bytesRead);
6625
+ const lastNewline = data.lastIndexOf(10);
6626
+ if (lastNewline < 0) {
6627
+ remainder = Buffer.from(data);
6628
+ continue;
6629
+ }
6630
+ for (const line of data.subarray(0, lastNewline).toString("utf8").split("\n")) {
6631
+ const trimmed = line.trim();
6632
+ if (!trimmed) continue;
6633
+ try {
6634
+ this.ingestEvent(JSON.parse(trimmed));
6635
+ result.ingestedEvents++;
6636
+ } catch {
6637
+ result.invalidLines++;
6638
+ }
6639
+ }
6640
+ advanced += lastNewline + 1;
6641
+ remainder = Buffer.from(data.subarray(lastNewline + 1));
6642
+ }
6643
+ this.db.prepare(
6644
+ "INSERT INTO ingest_state (file, bytes) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET bytes = excluded.bytes"
6645
+ ).run(key, advanced);
6646
+ this.db.exec("COMMIT");
6647
+ } catch (error) {
6657
6648
  this.db.exec("ROLLBACK");
6658
- } catch {
6649
+ throw error;
6659
6650
  }
6660
- throw this.normalizeQuotaError(error);
6651
+ result.ingestedBytes += advanced - consumed;
6652
+ return advanced > consumed;
6661
6653
  } finally {
6662
- this.anchors.clear();
6654
+ await handle.close();
6663
6655
  }
6664
- this.retainedEventCount = retainedCountAfterCommit;
6665
- }
6666
- /**
6667
- * A read engine over this journal's own connection.
6668
- *
6669
- * Sharing the connection rather than opening a second one keeps the
6670
- * single-writer guarantee intact and means a reader can never observe a
6671
- * half-applied batch: SQLite serialises statements on one handle.
6672
- */
6673
- queryEngine(options) {
6674
- return new ChronicleSqliteQueryEngine(this.db, options);
6675
6656
  }
6676
- /** Has the legacy JSONL import already run? */
6677
- hasImportedLegacyJournal() {
6678
- return this.readMeta(LEGACY_JSONL_MIGRATION_KEY) !== void 0;
6657
+ ingestEvent(event) {
6658
+ if (typeof event?.eventType !== "string" || !event.scope) return;
6659
+ this.ingestDailyCounters(event);
6660
+ const type = event.eventType;
6661
+ if (type.startsWith("provider.attempt.") || type === "provider.fallback") {
6662
+ this.ingestProvider(event);
6663
+ } else if (type === "token.accounted") {
6664
+ this.ingestTokenCost(event);
6665
+ } else if (/^(?:sdd|subagent|kanban)\.task[._]/.test(type)) {
6666
+ this.ingestTask(event);
6667
+ } else if (type === "file.event" || /^file\.(?:tool|external)\./.test(type)) {
6668
+ this.ingestFileEvent(event);
6669
+ }
6679
6670
  }
6680
- markLegacyJournalImported() {
6671
+ /** Runs for every ingested event (not just the type-specific branches
6672
+ * below) — mirrors query.ts's updateSummary() closely enough that
6673
+ * defaultSummary() matches what a raw scan of the same window would say. */
6674
+ ingestDailyCounters(event) {
6675
+ const day = eventDay(event);
6676
+ this.db.prepare("INSERT OR IGNORE INTO daily_counters (day) VALUES (?)").run(day);
6677
+ const bump = (sql, ...params) => this.db.prepare(`UPDATE daily_counters SET ${sql} WHERE day = ?`).run(...params, day);
6678
+ const family = signalFamily(event);
6679
+ const failed = isTerminalFailure(event) ? 1 : 0;
6681
6680
  this.db.prepare(
6682
- `INSERT INTO chronicle_meta (key, value) VALUES (?, 'done')
6683
- ON CONFLICT(key) DO UPDATE SET value = 'done'`
6684
- ).run(LEGACY_JSONL_MIGRATION_KEY);
6681
+ `INSERT INTO family_daily (day, family, count, failure_count) VALUES (?, ?, 1, ?)
6682
+ ON CONFLICT(day, family) DO UPDATE SET count = count + 1, failure_count = failure_count + excluded.failure_count`
6683
+ ).run(day, family, failed);
6684
+ if (failed) bump("failures = failures + 1");
6685
+ if (event.outcome === "cancelled" || event.outcome === "abandoned")
6686
+ bump("cancellations = cancellations + 1");
6687
+ if (family === "agent") bump("agent_events = agent_events + 1");
6688
+ if (event.correlation.logicalRequestId) {
6689
+ this.db.prepare(
6690
+ "INSERT OR IGNORE INTO logical_request_daily (day, logical_request_id) VALUES (?, ?)"
6691
+ ).run(day, event.correlation.logicalRequestId);
6692
+ }
6693
+ if (event.scope.agentId) {
6694
+ this.db.prepare("INSERT OR IGNORE INTO agent_daily (day, agent_id) VALUES (?, ?)").run(day, event.scope.agentId);
6695
+ }
6696
+ const type = event.eventType;
6697
+ if (type === "decision.requested") bump("decisions = decisions + 1");
6698
+ else if (type === "decision.escalated") bump("escalations = escalations + 1");
6699
+ else if (type === "tool.started") bump("tool_calls = tool_calls + 1");
6700
+ else if (type === "tool.executed" || type === "tool.failed") {
6701
+ const dur = durationMs2(event);
6702
+ const durationCount = dur > 0 ? 1 : 0;
6703
+ bump(
6704
+ `${type === "tool.executed" ? "completed_tools" : "failed_tools"} = ${type === "tool.executed" ? "completed_tools" : "failed_tools"} + 1,
6705
+ tool_duration_ms_total = tool_duration_ms_total + ?, tool_duration_ms_max = MAX(tool_duration_ms_max, ?), tool_duration_count = tool_duration_count + ?`,
6706
+ dur,
6707
+ dur,
6708
+ durationCount
6709
+ );
6710
+ } else if (type === "process.started") bump("processes = processes + 1");
6711
+ else if (type === "process.completed" && event.outcome === "failure")
6712
+ bump("failed_processes = failed_processes + 1");
6713
+ if (event.resource?.kind === "file" || type.startsWith("file.")) {
6714
+ bump("file_events_all = file_events_all + 1");
6715
+ if (event.resource?.path) {
6716
+ this.db.prepare("INSERT OR IGNORE INTO file_seen_daily (day, path_key) VALUES (?, ?)").run(day, normalizePathKey(event.resource.path));
6717
+ }
6718
+ }
6685
6719
  }
6686
- /**
6687
- * Record the day families the import refused to move.
6688
- *
6689
- * Persisted rather than merely logged because the import runs once: after the
6690
- * marker is set nothing re-reads the JSONL, so this row is the only surviving
6691
- * evidence that a day was dropped. Health reports read it back to say
6692
- * "degraded, and here is exactly what is missing" instead of quietly serving
6693
- * a journal with a hole in it.
6694
- */
6695
- recordQuarantinedFamilies(families) {
6696
- if (families.length === 0) return;
6697
- this.db.prepare(
6698
- `INSERT INTO chronicle_meta (key, value) VALUES (?, ?)
6699
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`
6700
- ).run(LEGACY_JSONL_QUARANTINE_KEY, JSON.stringify(families));
6720
+ ingestProvider(event) {
6721
+ const day = eventDay(event);
6722
+ const providerId = event.runtime?.providerId ?? asString(readPath2(event.attributes ?? {}, "from.providerId")) ?? "";
6723
+ const modelId = event.runtime?.modelId ?? asString(readPath2(event.attributes ?? {}, "from.model")) ?? "";
6724
+ if (!providerId && !modelId) return;
6725
+ this.db.prepare("INSERT OR IGNORE INTO provider_daily (day, provider_id, model_id) VALUES (?, ?, ?)").run(day, providerId, modelId);
6726
+ const update = (sql, ...params) => this.db.prepare(
6727
+ `UPDATE provider_daily SET ${sql} WHERE day = ? AND provider_id = ? AND model_id = ?`
6728
+ ).run(...params, day, providerId, modelId);
6729
+ const duration = durationMs2(event);
6730
+ switch (event.eventType) {
6731
+ case "provider.attempt.started":
6732
+ update("attempts = attempts + 1");
6733
+ break;
6734
+ case "provider.attempt.completed":
6735
+ update(
6736
+ "completed = completed + 1, input_tokens = input_tokens + ?, output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?, cache_write_tokens = cache_write_tokens + ?, duration_ms_total = duration_ms_total + ?, duration_ms_max = MAX(duration_ms_max, ?), duration_count = duration_count + ?",
6737
+ numberAt2(event, "usage.input"),
6738
+ numberAt2(event, "usage.output"),
6739
+ numberAt2(event, "usage.cacheRead"),
6740
+ numberAt2(event, "usage.cacheWrite"),
6741
+ duration,
6742
+ duration,
6743
+ duration > 0 ? 1 : 0
6744
+ );
6745
+ break;
6746
+ case "provider.attempt.failed":
6747
+ update(
6748
+ "failed = failed + 1, retries = retries + ?, duration_ms_total = duration_ms_total + ?, duration_ms_max = MAX(duration_ms_max, ?), duration_count = duration_count + ?",
6749
+ event.attributes?.retryScheduled === true ? 1 : 0,
6750
+ duration,
6751
+ duration,
6752
+ duration > 0 ? 1 : 0
6753
+ );
6754
+ break;
6755
+ case "provider.fallback":
6756
+ update("fallbacks = fallbacks + 1");
6757
+ break;
6758
+ default:
6759
+ break;
6760
+ }
6701
6761
  }
6702
- /**
6703
- * Does this day already hold rows?
6704
- *
6705
- * Each family commits on its own, so an import interrupted between families
6706
- * leaves a database that is complete for the days it reached. `(day,
6707
- * sequence)` is the primary key, so re-inserting one of those days would
6708
- * abort on a constraint violation rather than start over this is what lets
6709
- * the next run resume at the first day it never got to.
6710
- */
6711
- hasImportedDay(day) {
6712
- const row = this.db.prepare("SELECT 1 AS present FROM events WHERE day = ? LIMIT 1").get(day);
6713
- return row !== void 0;
6762
+ ingestTokenCost(event) {
6763
+ const cost = readPath2(event.attributes ?? {}, "cost.total");
6764
+ if (typeof cost !== "number" || !Number.isFinite(cost)) return;
6765
+ const scopeKey2 = `${event.scope.projectId ?? ""}\0${event.scope.sessionId ?? ""}\0${event.scope.agentId ?? ""}`;
6766
+ const occurredAt = event.occurredAt ?? event.observedAt;
6767
+ this.db.prepare(
6768
+ `INSERT INTO token_cost (scope_key, day, occurred_at, sequence, cost) VALUES (?, ?, ?, ?, ?)
6769
+ ON CONFLICT(scope_key) DO UPDATE SET
6770
+ day = excluded.day, occurred_at = excluded.occurred_at,
6771
+ sequence = excluded.sequence, cost = excluded.cost
6772
+ WHERE excluded.occurred_at > token_cost.occurred_at
6773
+ OR (excluded.occurred_at = token_cost.occurred_at AND excluded.sequence > token_cost.sequence)`
6774
+ ).run(scopeKey2, eventDay(event), occurredAt, event.sequence, cost);
6714
6775
  }
6715
- /** Day families the legacy import refused to move, oldest first. */
6716
- quarantinedFamilies() {
6717
- const raw = this.readMeta(LEGACY_JSONL_QUARANTINE_KEY);
6718
- if (!raw) return [];
6719
- try {
6720
- const parsed = JSON.parse(raw);
6721
- return Array.isArray(parsed) ? parsed : [];
6722
- } catch {
6723
- return [];
6776
+ ingestTask(event) {
6777
+ const attributes = event.attributes ?? {};
6778
+ const taskId = event.scope.taskId ?? stringAt(attributes, "taskId");
6779
+ if (!taskId) return;
6780
+ const occurredAt = event.occurredAt ?? event.observedAt;
6781
+ this.db.prepare("INSERT OR IGNORE INTO task_outcomes (task_id) VALUES (?)").run(taskId);
6782
+ const set = (sql, ...params) => this.db.prepare(`UPDATE task_outcomes SET ${sql} WHERE task_id = ?`).run(...params, taskId);
6783
+ const lineage = [
6784
+ ["run_id", stringAt(attributes, "runId")],
6785
+ ["board_id", event.scope.kanbanBoardId ?? stringAt(attributes, "boardId")],
6786
+ ["session_id", event.scope.sessionId],
6787
+ ["agent_id", event.scope.agentId ?? stringAt(attributes, "subagentId")]
6788
+ ];
6789
+ for (const [column, value] of lineage) {
6790
+ if (value) set(`${column} = ?`, value);
6791
+ }
6792
+ const base = event.eventType.replace(/^(?:sdd|subagent|kanban)\.task[._]/, "");
6793
+ switch (base) {
6794
+ case "started":
6795
+ set("status = 'started', started_at = COALESCE(started_at, ?)", occurredAt);
6796
+ break;
6797
+ case "completed":
6798
+ set(
6799
+ "status = 'completed', ended_at = ?, duration_ms = ?",
6800
+ occurredAt,
6801
+ numberOrDuration(event, attributes)
6802
+ );
6803
+ break;
6804
+ case "failed":
6805
+ set("status = 'failed', ended_at = ?", occurredAt);
6806
+ break;
6807
+ case "retrying":
6808
+ set("retries = retries + 1");
6809
+ break;
6810
+ case "verification_failed":
6811
+ set("verification_failures = verification_failures + 1");
6812
+ break;
6813
+ case "merged":
6814
+ set("status = 'merged'");
6815
+ break;
6816
+ case "conflict":
6817
+ set("status = 'conflict'");
6818
+ break;
6819
+ default:
6820
+ break;
6724
6821
  }
6725
6822
  }
6726
- // ─── internals ────────────────────────────────────────────────────────────
6727
- readMeta(key) {
6728
- const row = this.db.prepare("SELECT value FROM chronicle_meta WHERE key = ?").get(key);
6729
- return row?.value;
6823
+ ingestFileEvent(event) {
6824
+ const attributes = event.attributes ?? {};
6825
+ const operation = stringAt(attributes, "operation") ?? "";
6826
+ if (!operation || operation === "read") return;
6827
+ const filePath = event.resource?.path ?? stringAt(attributes, "filePath");
6828
+ if (!filePath) return;
6829
+ this.db.prepare(
6830
+ `INSERT OR IGNORE INTO file_lineage
6831
+ (event_id, path, path_key, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
6832
+ tool_name, provider_id, model_id, source)
6833
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
6834
+ ).run(
6835
+ event.eventId,
6836
+ normalizeKey2(filePath),
6837
+ normalizePathKey(filePath),
6838
+ operation,
6839
+ event.occurredAt ?? event.observedAt,
6840
+ event.scope.sessionId ?? "",
6841
+ event.scope.agentId ?? "",
6842
+ event.scope.taskId ?? stringAt(attributes, "taskId") ?? "",
6843
+ event.scope.kanbanBoardId ?? stringAt(attributes, "boardId") ?? "",
6844
+ stringAt(attributes, "runId") ?? "",
6845
+ stringAt(attributes, "toolName") ?? "",
6846
+ event.runtime?.providerId ?? stringAt(attributes, "provider") ?? "",
6847
+ event.runtime?.modelId ?? stringAt(attributes, "model") ?? "",
6848
+ stringAt(attributes, "source") ?? (event.eventType === "file.event" ? "tool" : "external")
6849
+ );
6730
6850
  }
6731
- readCheckpoint(day) {
6732
- return this.db.prepare("SELECT sequence, hash FROM chain_checkpoint WHERE day = ?").get(day);
6851
+ };
6852
+ function eventDay(event) {
6853
+ return (event.occurredAt ?? event.observedAt).slice(0, 10);
6854
+ }
6855
+ function durationMs2(event) {
6856
+ const value = Number(event.durationNs ?? 0) / 1e6;
6857
+ return Number.isFinite(value) && value > 0 ? value : 0;
6858
+ }
6859
+ function numberOrDuration(event, attributes) {
6860
+ const explicit = attributes.durationMs;
6861
+ if (typeof explicit === "number" && Number.isFinite(explicit)) return explicit;
6862
+ return durationMs2(event);
6863
+ }
6864
+ function numberAt2(event, dotPath) {
6865
+ const value = readPath2(event.attributes ?? {}, dotPath);
6866
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
6867
+ }
6868
+ function readPath2(value, key) {
6869
+ return key.split(".").reduce(
6870
+ (current, part) => current && typeof current === "object" ? current[part] : void 0,
6871
+ value
6872
+ );
6873
+ }
6874
+ function stringAt(record, key) {
6875
+ const value = record[key];
6876
+ return typeof value === "string" && value.length > 0 ? value : void 0;
6877
+ }
6878
+ function asString(value) {
6879
+ return typeof value === "string" && value.length > 0 ? value : void 0;
6880
+ }
6881
+ function clampLimit(limit, fallback) {
6882
+ if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) return fallback;
6883
+ return Math.min(Math.floor(limit), 1e4);
6884
+ }
6885
+ function normalizeKey2(value) {
6886
+ return value.replaceAll("\\", "/");
6887
+ }
6888
+ function normalizePathKey(value) {
6889
+ return value.replaceAll("\\", "/").replace(/^\.\//, "").toLowerCase();
6890
+ }
6891
+
6892
+ // src/chronicle/legacy-journal-import.ts
6893
+ import * as fs8 from "node:fs/promises";
6894
+ import * as path11 from "node:path";
6895
+ var ChronicleImportError = class extends Error {
6896
+ constructor(message, day, sequence) {
6897
+ super(message);
6898
+ this.day = day;
6899
+ this.sequence = sequence;
6900
+ this.name = "ChronicleImportError";
6733
6901
  }
6734
- /** Days that currently hold at least one event, oldest first. */
6735
- days() {
6736
- const rows = this.db.prepare("SELECT DISTINCT day FROM events ORDER BY day").all();
6737
- return rows.map((row) => row.day);
6902
+ day;
6903
+ sequence;
6904
+ };
6905
+ async function discoverFamilies(directory) {
6906
+ let entries;
6907
+ try {
6908
+ entries = await fs8.readdir(directory);
6909
+ } catch {
6910
+ return [];
6738
6911
  }
6739
- /**
6740
- * Resolve the chain head, in the same precedence the JSONL journal uses: the
6741
- * newest event, else the retention checkpoint (every event before it was
6742
- * purged), else genesis.
6743
- */
6744
- readAnchor(day) {
6745
- const cached = this.anchors.get(day);
6746
- if (cached) return cached;
6747
- const last = this.db.prepare("SELECT sequence, hash FROM events WHERE day = ? ORDER BY sequence DESC LIMIT 1").get(day);
6748
- const anchor = last ?? this.readCheckpoint(day) ?? { sequence: 0, hash: GENESIS_HASH };
6749
- this.anchors.set(day, anchor);
6750
- return anchor;
6912
+ const bases = /* @__PURE__ */ new Set();
6913
+ for (const entry of entries) {
6914
+ const match = PARTITION_FILE_PATTERN.exec(entry);
6915
+ if (match?.[1]) bases.add(match[1]);
6751
6916
  }
6752
- ensureSchema() {
6753
- const version = this.db.prepare("PRAGMA user_version").get().user_version;
6754
- this.db.exec(`
6755
- CREATE TABLE IF NOT EXISTS events (
6756
- day TEXT NOT NULL,
6757
- sequence INTEGER NOT NULL,
6758
- event_id TEXT NOT NULL UNIQUE,
6759
- hash TEXT NOT NULL,
6760
- previous_hash TEXT NOT NULL,
6761
- occurred_at TEXT NOT NULL,
6762
- event_type TEXT NOT NULL,
6763
- outcome TEXT,
6764
- project_id TEXT,
6765
- session_id TEXT,
6766
- agent_id TEXT,
6767
- task_id TEXT,
6768
- trace_id TEXT,
6769
- logical_request_id TEXT,
6770
- resource_kind TEXT,
6771
- resource_id TEXT,
6772
- resource_path TEXT,
6773
- duration_ns TEXT,
6774
- payload TEXT NOT NULL,
6775
- PRIMARY KEY (day, sequence)
6776
- );
6777
- CREATE INDEX IF NOT EXISTS events_occurred_at ON events(occurred_at);
6778
- CREATE INDEX IF NOT EXISTS events_type_outcome ON events(event_type, outcome);
6779
- CREATE INDEX IF NOT EXISTS events_session ON events(session_id, day, sequence);
6780
- CREATE INDEX IF NOT EXISTS events_trace ON events(trace_id);
6781
- CREATE INDEX IF NOT EXISTS events_logical_request ON events(logical_request_id);
6782
- CREATE INDEX IF NOT EXISTS events_resource_path ON events(resource_path);
6783
-
6784
- CREATE TABLE IF NOT EXISTS chain_checkpoint (
6785
- day TEXT PRIMARY KEY,
6786
- sequence INTEGER NOT NULL,
6787
- hash TEXT NOT NULL
6788
- );
6789
-
6790
- CREATE TABLE IF NOT EXISTS chronicle_meta (
6791
- key TEXT PRIMARY KEY,
6792
- value TEXT NOT NULL
6793
- );
6794
- `);
6795
- if (version !== SCHEMA_VERSION2) {
6796
- this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION2}`);
6917
+ return [...bases].sort();
6918
+ }
6919
+ function dayOfFamily(familyBase) {
6920
+ return familyBase.replace(/\.events$/u, "");
6921
+ }
6922
+ async function importLegacyChronicleJournal(journal, directory) {
6923
+ if (journal.hasImportedLegacyJournal()) {
6924
+ return {
6925
+ alreadyImported: true,
6926
+ families: 0,
6927
+ events: 0,
6928
+ quarantined: journal.quarantinedFamilies()
6929
+ };
6930
+ }
6931
+ const families = await discoverFamilies(directory);
6932
+ const quarantined = [];
6933
+ let importedEvents = 0;
6934
+ let importedFamilies = 0;
6935
+ for (const familyBase of families) {
6936
+ const day = dayOfFamily(familyBase);
6937
+ const basePath = path11.join(directory, `${familyBase}.jsonl`);
6938
+ let familyEvents = 0;
6939
+ if (journal.hasImportedDay(day)) continue;
6940
+ try {
6941
+ await journal.runFamilyImport(async (sink) => {
6942
+ familyEvents = 0;
6943
+ const checkpointResult = await readRetentionCheckpoint(basePath);
6944
+ if (checkpointResult.error) {
6945
+ throw new ChronicleImportError(checkpointResult.error, day, 0);
6946
+ }
6947
+ const checkpoint = checkpointResult.checkpoint;
6948
+ if (checkpoint) sink.checkpoint(day, checkpoint.sequence, checkpoint.hash);
6949
+ let expectedSequence = (checkpoint?.sequence ?? 0) + 1;
6950
+ let previousHash = checkpoint?.hash ?? GENESIS_HASH;
6951
+ for (const partition of await collectPartitions(basePath)) {
6952
+ for await (const event of streamEntriesStrict(partition)) {
6953
+ if (event.sequence !== expectedSequence) {
6954
+ throw new ChronicleImportError(
6955
+ `sequence gap in ${day}: expected ${expectedSequence}, found ${event.sequence}`,
6956
+ day,
6957
+ event.sequence
6958
+ );
6959
+ }
6960
+ if (event.previousHash !== previousHash) {
6961
+ throw new ChronicleImportError(
6962
+ `previous hash mismatch in ${day} at sequence ${event.sequence}`,
6963
+ day,
6964
+ event.sequence
6965
+ );
6966
+ }
6967
+ if (chronicleEventHash(event) !== event.hash) {
6968
+ throw new ChronicleImportError(
6969
+ `entry hash mismatch in ${day} at sequence ${event.sequence}`,
6970
+ day,
6971
+ event.sequence
6972
+ );
6973
+ }
6974
+ sink.insert(day, event);
6975
+ familyEvents += 1;
6976
+ expectedSequence = event.sequence + 1;
6977
+ previousHash = event.hash;
6978
+ }
6979
+ }
6980
+ });
6981
+ } catch (error) {
6982
+ if (!(error instanceof ChronicleImportError)) throw error;
6983
+ quarantined.push({ day, sequence: error.sequence, reason: error.message });
6984
+ continue;
6797
6985
  }
6986
+ if (familyEvents > 0) importedFamilies += 1;
6987
+ importedEvents += familyEvents;
6798
6988
  }
6799
- };
6800
- function projectEvent(event) {
6801
- const occurredAt = event.occurredAt ?? event.observedAt;
6989
+ journal.recordQuarantinedFamilies(quarantined);
6990
+ journal.markLegacyJournalImported();
6802
6991
  return {
6803
- occurredAt,
6804
- outcome: event.outcome ?? null,
6805
- projectId: event.scope.projectId ?? null,
6806
- sessionId: event.scope.sessionId ?? null,
6807
- agentId: event.scope.agentId ?? null,
6808
- taskId: event.scope.taskId ?? null,
6809
- traceId: event.correlation?.traceId ?? null,
6810
- logicalRequestId: event.correlation?.logicalRequestId ?? null,
6811
- resourceKind: event.resource?.kind ?? null,
6812
- resourceId: event.resource?.id ?? null,
6813
- resourcePath: event.resource?.path ?? null,
6814
- durationNs: event.durationNs ?? null
6992
+ alreadyImported: false,
6993
+ families: importedFamilies,
6994
+ events: importedEvents,
6995
+ quarantined
6815
6996
  };
6816
6997
  }
6817
6998