knodin 0.8.3 → 0.8.5

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 (42) hide show
  1. package/README.md +7 -1
  2. package/dist/bin/cli.js +489 -78
  3. package/dist/src/agent-events.js +128 -0
  4. package/dist/src/agent-hooks.js +156 -0
  5. package/dist/src/cli-model.js +42 -1
  6. package/dist/src/engine/candidate-database.js +167 -0
  7. package/dist/src/engine/embeddings.js +8 -5
  8. package/dist/src/engine/index.js +930 -262
  9. package/dist/src/engine/prune.js +8 -0
  10. package/dist/src/file-metrics.js +153 -0
  11. package/dist/src/output-telemetry.js +8 -3
  12. package/dist/src/repair-lease.js +8 -5
  13. package/dist/src/response-budget.js +2 -1
  14. package/dist/src/session-telemetry.js +163 -0
  15. package/dist/src/shared-index/artifact.js +65 -0
  16. package/dist/src/shared-index/cache.js +267 -0
  17. package/dist/src/shared-index/compatibility.js +70 -0
  18. package/dist/src/shared-index/config.js +224 -0
  19. package/dist/src/shared-index/contract.js +117 -0
  20. package/dist/src/shared-index/manifest.js +71 -0
  21. package/dist/src/shared-index/opportunistic.js +37 -0
  22. package/dist/src/shared-index/overlay.js +98 -0
  23. package/dist/src/shared-index/provenance.js +149 -0
  24. package/dist/src/shared-index/publisher.js +198 -0
  25. package/dist/src/shared-index/restore.js +209 -0
  26. package/dist/src/shared-index/s3-client.js +124 -0
  27. package/dist/src/shared-index/schemas.js +86 -0
  28. package/dist/src/shared-index/selection.js +111 -0
  29. package/dist/src/tools/knodin-tools.js +70 -3
  30. package/docs/CLI.md +56 -0
  31. package/docs/INSTALLATION.md +11 -0
  32. package/docs/MCP.md +12 -0
  33. package/docs/SHARED-INDEX-CONTRACT.md +132 -0
  34. package/docs/TELEMETRY.md +19 -1
  35. package/docs/releases/0.8.4.md +22 -0
  36. package/docs/releases/0.8.5.md +25 -0
  37. package/package.json +12 -3
  38. package/roadmap/competitive-roadmap.md +20 -0
  39. package/schemas/shared-index-branch-pointer-v1.schema.json +66 -0
  40. package/schemas/shared-index-config-v1.schema.json +97 -0
  41. package/schemas/shared-index-manifest-v1.schema.json +104 -0
  42. package/schemas/shared-index-provenance-v1.schema.json +100 -0
@@ -43,6 +43,14 @@ export const PRUNE_DIR_NAMES = new Set([
43
43
  "vendor",
44
44
  "__pycache__",
45
45
  ]);
46
+ /** Frozen input to shared-index compatibility; update with any pruning behavior change. */
47
+ export function prunePolicyDescriptor() {
48
+ return {
49
+ version: 1,
50
+ directories: [...PRUNE_DIR_NAMES].sort((left, right) => left.localeCompare(right)),
51
+ patterns: [".claude/worktrees"],
52
+ };
53
+ }
46
54
  /** True when a single path segment (one directory name) is a pruned directory. */
47
55
  export function isPrunedSegment(segment) {
48
56
  return PRUNE_DIR_NAMES.has(segment);
@@ -0,0 +1,153 @@
1
+ import { execFileSync } from "node:child_process";
2
+ const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?|py|java|cls|trigger|go|rs|rb|php|cs)$/i;
3
+ const TEST_PATH = /(?:(?:^|\/)(?:__tests__|test|tests|spec|specs)(?:\/|$)|[._](?:test|spec)\.[^/]+$)/i;
4
+ function git(repo, args, input) {
5
+ try {
6
+ return execFileSync("git", ["-C", repo, ...args], {
7
+ encoding: "buffer",
8
+ input,
9
+ maxBuffer: 512 * 1024 * 1024,
10
+ stdio: [input ? "pipe" : "ignore", "pipe", "pipe"],
11
+ });
12
+ }
13
+ catch (error) {
14
+ const stderr = error.stderr;
15
+ const message = Buffer.isBuffer(stderr) ? stderr.toString("utf8").trim() : String(stderr ?? "");
16
+ throw new Error(`knodin query file_metrics: git ${args[0]} failed${message ? `: ${message}` : ""}`);
17
+ }
18
+ }
19
+ function resolveCommit(repo, ref) {
20
+ try {
21
+ return git(repo, ["rev-parse", "--verify", `${ref}^{commit}`])
22
+ .toString("utf8")
23
+ .trim();
24
+ }
25
+ catch {
26
+ throw new Error(`knodin query file_metrics: invalid or unreachable Git ref ${ref}`);
27
+ }
28
+ }
29
+ function tree(repo, commit) {
30
+ const listing = git(repo, ["ls-tree", "-r", "-z", commit]);
31
+ const entries = listing
32
+ .toString("utf8")
33
+ .split("\0")
34
+ .filter(Boolean)
35
+ .flatMap((entry) => {
36
+ const match = /^(\d+) blob ([a-f0-9]+)\t([\s\S]+)$/.exec(entry);
37
+ if (!match || !SOURCE_EXTENSION.test(match[3]))
38
+ return [];
39
+ return [{ path: match[3], hash: match[2] }];
40
+ });
41
+ const hashes = [...new Set(entries.map((entry) => entry.hash))];
42
+ const batch = hashes.length
43
+ ? git(repo, ["cat-file", "--batch"], Buffer.from(`${hashes.join("\n")}\n`))
44
+ : Buffer.alloc(0);
45
+ const contents = new Map();
46
+ let offset = 0;
47
+ for (const expected of hashes) {
48
+ const newline = batch.indexOf(0x0a, offset);
49
+ if (newline < 0)
50
+ throw new Error("knodin query file_metrics: truncated git object batch");
51
+ const header = batch.subarray(offset, newline).toString("utf8");
52
+ const match = /^([a-f0-9]+) blob (\d+)$/.exec(header);
53
+ if (!match || match[1] !== expected)
54
+ throw new Error("knodin query file_metrics: unexpected git object batch response");
55
+ const size = Number(match[2]);
56
+ const start = newline + 1;
57
+ const end = start + size;
58
+ if (end >= batch.length || batch[end] !== 0x0a)
59
+ throw new Error("knodin query file_metrics: malformed git object batch response");
60
+ contents.set(expected, batch.subarray(start, end));
61
+ offset = end + 1;
62
+ }
63
+ return new Map(entries.map((entry) => {
64
+ const content = contents.get(entry.hash);
65
+ if (!content)
66
+ throw new Error("knodin query file_metrics: missing Git object content");
67
+ return [entry.path, { hash: entry.hash, content }];
68
+ }));
69
+ }
70
+ function text(buffer) {
71
+ if (buffer.includes(0))
72
+ return null;
73
+ const decoded = buffer.toString("utf8");
74
+ return Buffer.from(decoded, "utf8").equals(buffer) ? decoded : null;
75
+ }
76
+ export function measureFile(content) {
77
+ const lines = content.length === 0
78
+ ? 0
79
+ : content.split(/\r\n|\n|\r/).length - (/[\r\n]$/.test(content) ? 1 : 0);
80
+ const imports = (content.match(/^\s*(?:import\b|export\s+[^\n;]*\sfrom\b|(?:const|let|var)\s+[^=\n]+?=\s*require\s*\(|from\s+\S+\s+import\b|import\s+\S+)/gm) ?? []).length;
81
+ const routes = (content.match(/\b(?:app|router)\s*\.\s*(?:get|post|put|patch|delete|options|head|use)\s*\(|@(?:Get|Post|Put|Patch|Delete|RequestMapping|GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping)\b|@app\.(?:get|post|put|patch|delete|route)\s*\(/g) ?? []).length;
82
+ const effects = (content.match(/\b(?:fetch|axios\.(?:get|post|put|patch|delete)|console\.(?:log|warn|error)|(?:read|write|append|unlink|mkdir|rm)FileSync|(?:read|write|append|unlink|mkdir|rm)File|process\.env|\.query|\.execute|\.save|\.send|\.publish)\s*[[(]/g) ?? []).length;
83
+ return { lines, imports, routes, effects };
84
+ }
85
+ function subtract(head, base) {
86
+ return {
87
+ lines: head.lines - base.lines,
88
+ imports: head.imports - base.imports,
89
+ routes: head.routes - base.routes,
90
+ effects: head.effects - base.effects,
91
+ };
92
+ }
93
+ export function collectFileMetrics(repo, options = {}) {
94
+ const head = resolveCommit(repo, "HEAD");
95
+ let base;
96
+ if (options.base) {
97
+ resolveCommit(repo, options.base);
98
+ const mergeBase = git(repo, ["merge-base", "HEAD", options.base]).toString("utf8").trim();
99
+ if (!mergeBase)
100
+ throw new Error(`knodin query file_metrics: cannot resolve merge-base for ${options.base}`);
101
+ base = { requested: options.base, mergeBase };
102
+ }
103
+ const testScope = options.testScope ?? "all";
104
+ const headTree = tree(repo, head);
105
+ const baseTree = base ? tree(repo, base.mergeBase) : undefined;
106
+ const paths = [...new Set([...headTree.keys(), ...(baseTree?.keys() ?? [])])]
107
+ .filter((file) => testScope === "all" || (testScope === "test") === TEST_PATH.test(file))
108
+ .sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
109
+ const files = [];
110
+ for (const file of paths) {
111
+ const headEntry = headTree.get(file);
112
+ const baseEntry = baseTree?.get(file);
113
+ const headText = headEntry ? text(headEntry.content) : null;
114
+ const baseText = baseEntry ? text(baseEntry.content) : null;
115
+ // Binary or invalid UTF-8 inputs are explicitly skipped rather than reported as zero.
116
+ if ((headEntry && headText === null) || (baseEntry && baseText === null))
117
+ continue;
118
+ const headMetrics = headText === null ? null : measureFile(headText);
119
+ const baseMetrics = baseText === null ? null : measureFile(baseText);
120
+ const status = !headEntry
121
+ ? "removed"
122
+ : !baseTree
123
+ ? "unchanged"
124
+ : !baseEntry
125
+ ? "added"
126
+ : headEntry.hash === baseEntry.hash
127
+ ? "unchanged"
128
+ : "changed";
129
+ files.push({
130
+ file,
131
+ status,
132
+ head: headMetrics,
133
+ ...(baseTree
134
+ ? {
135
+ base: baseMetrics,
136
+ delta: status === "changed" && headMetrics && baseMetrics
137
+ ? subtract(headMetrics, baseMetrics)
138
+ : null,
139
+ }
140
+ : {}),
141
+ });
142
+ }
143
+ return {
144
+ schemaVersion: 1,
145
+ status: "ok",
146
+ head,
147
+ ...(base ? { base } : {}),
148
+ testScope,
149
+ files,
150
+ confidentiality: "paths-and-integer-counts-only",
151
+ policy: "measurements-only",
152
+ };
153
+ }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { encode } from "gpt-tokenizer/encoding/o200k_base";
5
5
  import { compareBytes } from "./compare.js";
6
6
  import { resolveDbPath } from "./engine/state-paths.js";
7
+ import { summarizeAdoption } from "./session-telemetry.js";
7
8
  export const TELEMETRY_TOKENIZER = "gpt-tokenizer@3.4.0:o200k_base";
8
9
  export const countOutputTokens = (value) => encode(value).length;
9
10
  export const DEFAULT_TELEMETRY_RETENTION_DAYS = 30;
@@ -182,7 +183,7 @@ function telemetrySummary(records) {
182
183
  indexingMs: records.reduce((sum, record) => sum + (record.indexingMs ?? 0), 0),
183
184
  };
184
185
  }
185
- export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/telemetry-report.html") {
186
+ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/telemetry-report.html", sessionEvents = []) {
186
187
  const { repo, target: output } = privatePath(repoPath, outputPath, "report");
187
188
  const groups = new Map();
188
189
  for (const record of records) {
@@ -201,6 +202,7 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
201
202
  })
202
203
  .join("");
203
204
  const summary = telemetrySummary(records);
205
+ const adoption = summarizeAdoption(sessionEvents);
204
206
  const timeSeries = new Map();
205
207
  for (const record of records) {
206
208
  const day = typeof record.at === "string" ? record.at.slice(0, 10) : "unknown";
@@ -221,12 +223,13 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
221
223
  .sort(([left], [right]) => compareBytes(left, right))
222
224
  .map(([id, calls]) => `<tr><td>${escapeHtml(id)}</td><td>${calls}</td></tr>`)
223
225
  .join("");
224
- const html = `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark"><title>knodin telemetry</title><style>:root{color-scheme:light dark;--line:#d0d7de;--card:#f6f8fa}body{font:14px system-ui;margin:2rem;max-width:76rem}section{background:var(--card);padding:1rem;margin:1rem 0;border-radius:.5rem}table{border-collapse:collapse;width:100%}th,td{padding:.55rem;border-bottom:1px solid var(--line);text-align:left}@media(prefers-color-scheme:dark){:root{--line:#30363d;--card:#161b22}}</style><h1>knodin local telemetry</h1><p>Tokenizer: ${TELEMETRY_TOKENIZER}. Local, opt-in, metadata-only; source, raw output, command text, usernames, and paths are not persisted.</p><section><h2>Summary</h2><p>Total measured tokens avoided: ${summary.measuredTokensAvoided}</p><p>Calls: ${summary.calls}; successes: ${summary.successes}; errors: ${summary.errors}; measured baselines: ${summary.measuredBaselines}</p><p>Latency p50 / p95: ${summary.latencyP50Ms} / ${summary.latencyP95Ms} ms</p><p>Freshness failures: ${summary.freshnessFailures}; compression fidelity samples / p50: ${summary.compressionFidelitySamples} / ${summary.compressionFidelityP50}; indexing time: ${summary.indexingMs} ms; index storage: ${summary.indexStorageBytes} bytes</p></section><section><h2>Operation breakdown</h2><table><thead><tr><th>Operation</th><th>Calls</th><th>Errors</th><th>Measured baselines</th><th>Net tokens saved</th><th>p50 ms</th><th>p95 ms</th></tr></thead><tbody>${rows}</tbody></table></section><section><h2>Time series</h2><table><thead><tr><th>Day</th><th>Calls</th><th>Tokens avoided</th></tr></thead><tbody>${seriesRows}</tbody></table></section><section><h2>Repository breakdown</h2><table><thead><tr><th>Private repository id</th><th>Calls</th></tr></thead><tbody>${repositoryRows}</tbody></table></section><p>Use <code>knodin telemetry export</code> for the machine-readable evidence bundle and <code>knodin telemetry clear</code> to delete persisted records.</p>`;
226
+ const html = `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark"><title>knodin telemetry</title><style>:root{color-scheme:light dark;--line:#d0d7de;--card:#f6f8fa}body{font:14px system-ui;margin:2rem;max-width:76rem}section{background:var(--card);padding:1rem;margin:1rem 0;border-radius:.5rem}table{border-collapse:collapse;width:100%}th,td{padding:.55rem;border-bottom:1px solid var(--line);text-align:left}@media(prefers-color-scheme:dark){:root{--line:#30363d;--card:#161b22}}</style><h1>knodin local telemetry</h1><p>Tokenizer: ${TELEMETRY_TOKENIZER}. Local, opt-in, metadata-only; source, raw output, command text, prompts, usernames, and paths are not persisted.</p><section><h2>Summary</h2><p>Total measured tokens avoided: ${summary.measuredTokensAvoided}</p><p>Calls: ${summary.calls}; successes: ${summary.successes}; errors: ${summary.errors}; measured baselines: ${summary.measuredBaselines}</p><p>Latency p50 / p95: ${summary.latencyP50Ms} / ${summary.latencyP95Ms} ms</p><p>Freshness failures: ${summary.freshnessFailures}; compression fidelity samples / p50: ${summary.compressionFidelitySamples} / ${summary.compressionFidelityP50}; indexing time: ${summary.indexingMs} ms; index storage: ${summary.indexStorageBytes} bytes</p></section><section><h2>Adoption evidence</h2><p>Sessions: ${adoption.sessions}; context delivered / degraded: ${adoption.contextDelivered} / ${adoption.contextDegraded}; sessions using knodin: ${adoption.knodinSessions}; observed turns: ${adoption.turns}</p><p>Fallback traversal after knodin: ${adoption.fallbackTraversalAfterKnodin}; edits preceded by knodin evidence in the same turn: ${adoption.evidenceBeforeEdit}</p><p>Corrective round trips: unavailable. Ordinary hook events do not prove why a follow-up occurred.</p></section><section><h2>Operation breakdown</h2><table><thead><tr><th>Operation</th><th>Calls</th><th>Errors</th><th>Measured baselines</th><th>Net tokens saved</th><th>p50 ms</th><th>p95 ms</th></tr></thead><tbody>${rows}</tbody></table></section><section><h2>Time series</h2><table><thead><tr><th>Day</th><th>Calls</th><th>Tokens avoided</th></tr></thead><tbody>${seriesRows}</tbody></table></section><section><h2>Repository breakdown</h2><table><thead><tr><th>Private repository id</th><th>Calls</th></tr></thead><tbody>${repositoryRows}</tbody></table></section><p>Use <code>knodin telemetry export</code> for the machine-readable evidence bundle and <code>knodin telemetry clear</code> to delete persisted records.</p>`;
225
227
  atomicPrivateWrite(output, html);
226
228
  return {
227
229
  outputPath: path.relative(repo, output),
228
230
  records: records.length,
229
231
  operations: groups.size,
232
+ sessionEvents: sessionEvents.length,
230
233
  };
231
234
  }
232
235
  function sanitizeTelemetryRecord(record) {
@@ -335,7 +338,7 @@ export function telemetryStatus(repoPath, inputPath = DEFAULT_INPUT, retentionDa
335
338
  tokenizer: TELEMETRY_TOKENIZER,
336
339
  };
337
340
  }
338
- export function exportTelemetry(repoPath, records, outputPath = ".knodin/telemetry-export.json") {
341
+ export function exportTelemetry(repoPath, records, outputPath = ".knodin/telemetry-export.json", sessionEvents = []) {
339
342
  const { repo, target } = privatePath(repoPath, outputPath, "export");
340
343
  const bundle = {
341
344
  schemaVersion: 1,
@@ -349,6 +352,8 @@ export function exportTelemetry(repoPath, records, outputPath = ".knodin/telemet
349
352
  },
350
353
  summary: telemetrySummary(records),
351
354
  records,
355
+ adoption: summarizeAdoption(sessionEvents),
356
+ sessionEvents,
352
357
  };
353
358
  atomicPrivateWrite(target, `${JSON.stringify(bundle, null, 2)}\n`);
354
359
  return {
@@ -41,10 +41,13 @@ function writeLease(target, record) {
41
41
  mode: 0o600,
42
42
  });
43
43
  }
44
- function removeStaleLeaseOrThrow(target) {
44
+ function removeStaleLeaseOrThrow(target, operation) {
45
45
  const current = readLease(target);
46
46
  if (current && processIsAlive(current.pid)) {
47
- throw new Error(`knodin repair: another repair is already in progress (pid ${current.pid}, started ${current.startedAt}); wait for it to finish, then run \`knodin status\``);
47
+ const contention = operation === "repair"
48
+ ? "another repair is already in progress"
49
+ : "another lifecycle mutation is already in progress";
50
+ throw new Error(`knodin ${operation}: ${contention} (pid ${current.pid}, started ${current.startedAt}); wait for it to finish, then run \`knodin status\``);
48
51
  }
49
52
  try {
50
53
  fs.unlinkSync(target);
@@ -55,7 +58,7 @@ function removeStaleLeaseOrThrow(target) {
55
58
  }
56
59
  }
57
60
  /** Atomically serialize repair post-processing across independent CLI/MCP processes. */
58
- export function acquireRepairLease(repo) {
61
+ export function acquireRepairLease(repo, operation = "repair") {
59
62
  const target = leasePath(repo);
60
63
  fs.mkdirSync(path.dirname(target), { recursive: true });
61
64
  const record = {
@@ -78,8 +81,8 @@ export function acquireRepairLease(repo) {
78
81
  catch (error) {
79
82
  if (error.code !== "EEXIST")
80
83
  throw error;
81
- removeStaleLeaseOrThrow(target);
84
+ removeStaleLeaseOrThrow(target, operation);
82
85
  }
83
86
  }
84
- throw new Error("knodin repair: could not acquire the repository repair lease; retry shortly");
87
+ throw new Error(`knodin ${operation}: could not acquire the repository lifecycle lease; retry shortly`);
85
88
  }
@@ -46,7 +46,8 @@ const PROTECTED_CONTRACT_KEYS = new Set([
46
46
  "to",
47
47
  ]);
48
48
  function isTruncatableDetail(entry) {
49
- return typeof entry.key !== "string" || !PROTECTED_CONTRACT_KEYS.has(entry.key);
49
+ return (!entry.path.startsWith("$/sharedIndex/") &&
50
+ (typeof entry.key !== "string" || !PROTECTED_CONTRACT_KEYS.has(entry.key)));
50
51
  }
51
52
  function synchronizeNestedCapabilityBudget(root, metadata, operation) {
52
53
  const cross = root.crossSubstratePath;
@@ -0,0 +1,163 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const CONFIG = ".knodin/telemetry-config.json";
5
+ const EVENTS = ".knodin/session-events.jsonl";
6
+ const KEY = ".knodin/telemetry-key";
7
+ function target(repo, relative) {
8
+ const root = fs.realpathSync(repo);
9
+ const value = path.resolve(root, relative);
10
+ if (!value.startsWith(`${root}${path.sep}`))
11
+ throw new Error("knodin telemetry path escaped repository");
12
+ let candidate = root;
13
+ for (const segment of path.relative(root, value).split(path.sep)) {
14
+ candidate = path.join(candidate, segment);
15
+ try {
16
+ if (fs.lstatSync(candidate).isSymbolicLink())
17
+ throw new Error("knodin telemetry refuses symlinks");
18
+ }
19
+ catch (error) {
20
+ if (error.code !== "ENOENT")
21
+ throw error;
22
+ }
23
+ }
24
+ return value;
25
+ }
26
+ function atomic(filePath, value) {
27
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
28
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
29
+ fs.writeFileSync(temporary, value, { mode: 0o600, flag: "wx" });
30
+ fs.renameSync(temporary, filePath);
31
+ }
32
+ function enabled(repo) {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(target(repo, CONFIG), "utf8")).enabled === true;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ export function enableSessionTelemetry(repo) {
41
+ atomic(target(repo, CONFIG), `${JSON.stringify({ schemaVersion: 1, enabled: true })}\n`);
42
+ const key = target(repo, KEY);
43
+ if (!fs.existsSync(key))
44
+ atomic(key, crypto.randomBytes(32).toString("hex"));
45
+ return { schemaVersion: 1, enabled: true, localOnly: true };
46
+ }
47
+ export function disableSessionTelemetry(repo) {
48
+ atomic(target(repo, CONFIG), `${JSON.stringify({ schemaVersion: 1, enabled: false })}\n`);
49
+ return { schemaVersion: 1, enabled: false, retained: readSessionEvents(repo).length };
50
+ }
51
+ function sessionHash(repo, sessionId) {
52
+ const secret = fs.readFileSync(target(repo, KEY), "utf8").trim();
53
+ return `session_${crypto.createHmac("sha256", secret).update(sessionId).digest("hex").slice(0, 20)}`;
54
+ }
55
+ export function appendSessionEvent(repo, input) {
56
+ if (!enabled(repo))
57
+ return false;
58
+ if (!input.sessionId || input.sessionId.length > 256)
59
+ return false;
60
+ const record = {
61
+ schemaVersion: 1,
62
+ event: input.event,
63
+ session: sessionHash(repo, input.sessionId),
64
+ at: input.at ?? new Date().toISOString(),
65
+ client: input.client ?? "claude",
66
+ ...(input.context ? { context: input.context } : {}),
67
+ ...(input.toolCategory ? { toolCategory: input.toolCategory } : {}),
68
+ ...(input.operation && /^[a-z][a-z0-9_-]{0,63}$/.test(input.operation)
69
+ ? { operation: input.operation }
70
+ : {}),
71
+ ...(input.freshness ? { freshness: input.freshness } : {}),
72
+ ...(typeof input.latencyMs === "number" ? { latencyMs: input.latencyMs } : {}),
73
+ ...(typeof input.cacheHit === "boolean" ? { cacheHit: input.cacheHit } : {}),
74
+ };
75
+ const filePath = target(repo, EVENTS);
76
+ fs.appendFileSync(filePath, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
77
+ return true;
78
+ }
79
+ export function readSessionEvents(repo, retentionDays, now = new Date()) {
80
+ const filePath = target(repo, EVENTS);
81
+ if (!fs.existsSync(filePath))
82
+ return [];
83
+ const cutoff = retentionDays === undefined ? undefined : now.getTime() - retentionDays * 24 * 60 * 60 * 1000;
84
+ return fs
85
+ .readFileSync(filePath, "utf8")
86
+ .split(/\r?\n/)
87
+ .filter(Boolean)
88
+ .flatMap((line) => {
89
+ try {
90
+ const value = JSON.parse(line);
91
+ if (value.schemaVersion !== 1 || typeof value.session !== "string")
92
+ return [];
93
+ if (cutoff !== undefined) {
94
+ const timestamp = typeof value.at === "string" ? Date.parse(value.at) : Number.NaN;
95
+ if (!Number.isFinite(timestamp) || timestamp < cutoff)
96
+ return [];
97
+ }
98
+ return [value];
99
+ }
100
+ catch {
101
+ return [];
102
+ }
103
+ });
104
+ }
105
+ export function summarizeAdoption(events) {
106
+ const sessions = new Set(events.map(({ session }) => session));
107
+ const evidenceOperations = new Set(["context", "explain", "review", "map", "search", "query"]);
108
+ const knodinSessions = new Set(events
109
+ .filter(({ event, toolCategory, operation }) => event === "tool_success" &&
110
+ toolCategory === "knodin" &&
111
+ operation !== undefined &&
112
+ evidenceOperations.has(operation))
113
+ .map(({ session }) => session));
114
+ let fallbackTraversalAfterKnodin = 0;
115
+ let evidenceBeforeEdit = 0;
116
+ const seenKnodin = new Set();
117
+ for (const event of events) {
118
+ if (event.event === "turn_start")
119
+ seenKnodin.delete(event.session);
120
+ if (event.toolCategory === "knodin" &&
121
+ event.event === "tool_success" &&
122
+ event.operation !== undefined &&
123
+ evidenceOperations.has(event.operation))
124
+ seenKnodin.add(event.session);
125
+ if (event.event === "tool_success" &&
126
+ event.toolCategory === "manual_traversal" &&
127
+ seenKnodin.has(event.session))
128
+ fallbackTraversalAfterKnodin += 1;
129
+ if (event.event === "tool_success" &&
130
+ event.toolCategory === "edit" &&
131
+ seenKnodin.has(event.session))
132
+ evidenceBeforeEdit += 1;
133
+ if (event.event === "turn_end")
134
+ seenKnodin.delete(event.session);
135
+ }
136
+ return {
137
+ sessions: sessions.size,
138
+ contextDelivered: events.filter(({ event, context }) => event === "session_start" && context === "delivered").length,
139
+ contextDegraded: events.filter(({ event, context }) => event === "session_start" && context === "degraded").length,
140
+ knodinSessions: knodinSessions.size,
141
+ turns: events.filter(({ event }) => event === "turn_start").length,
142
+ fallbackTraversalAfterKnodin,
143
+ evidenceBeforeEdit,
144
+ correctiveRoundTrips: null,
145
+ };
146
+ }
147
+ export function sessionTelemetryStatus(repo, retentionDays) {
148
+ const records = readSessionEvents(repo, retentionDays);
149
+ return {
150
+ schemaVersion: 1,
151
+ enabled: enabled(repo),
152
+ localOnly: true,
153
+ records: records.length,
154
+ adoption: summarizeAdoption(records),
155
+ };
156
+ }
157
+ export function clearSessionTelemetry(repo) {
158
+ const filePath = target(repo, EVENTS);
159
+ const records = readSessionEvents(repo).length;
160
+ const bytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
161
+ fs.rmSync(filePath, { force: true });
162
+ return { removed: bytes > 0, records, bytes };
163
+ }
@@ -0,0 +1,65 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { createZstdDecompress } from "node:zlib";
4
+ import { assertCandidate } from "../engine/candidate-database.js";
5
+ import { SharedIndexError } from "./s3-client.js";
6
+ const SQLITE_HEADER = Buffer.from("SQLite format 3\0", "binary");
7
+ function writeAll(descriptor, bytes) {
8
+ let offset = 0;
9
+ while (offset < bytes.byteLength)
10
+ offset += fs.writeSync(descriptor, bytes, offset, bytes.byteLength - offset);
11
+ }
12
+ export async function decompressArtifactToCandidate(options) {
13
+ assertCandidate(options.repo, options.candidate);
14
+ if (fs.existsSync(options.candidate.databasePath))
15
+ throw new SharedIndexError("integrity", "shared-index candidate database already exists");
16
+ const source = fs.createReadStream(options.artifact.path);
17
+ const decompressor = createZstdDecompress();
18
+ const output = fs.openSync(options.candidate.databasePath, "wx", 0o600);
19
+ const hash = crypto.createHash("sha256");
20
+ let received = 0;
21
+ try {
22
+ if (options.signal) {
23
+ if (options.signal.aborted)
24
+ throw new SharedIndexError("timeout", "shared-index decompression was cancelled");
25
+ options.signal.addEventListener("abort", () => decompressor.destroy(), { once: true });
26
+ }
27
+ source.pipe(decompressor);
28
+ for await (const chunk of decompressor) {
29
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
30
+ received += bytes.byteLength;
31
+ if (received > options.manifest.graph.uncompressedBytes)
32
+ throw new SharedIndexError("integrity", "shared-index decompressed object exceeded manifest size");
33
+ hash.update(bytes);
34
+ writeAll(output, bytes);
35
+ }
36
+ if (decompressor.bytesWritten !== options.manifest.graph.compressedBytes)
37
+ throw new SharedIndexError("integrity", "shared-index compressed object contains trailing data");
38
+ fs.fsyncSync(output);
39
+ if (received !== options.manifest.graph.uncompressedBytes ||
40
+ hash.digest("hex") !== options.manifest.graph.uncompressedSha256)
41
+ throw new SharedIndexError("integrity", "shared-index decompressed object failed size or digest verification");
42
+ }
43
+ catch (error) {
44
+ fs.closeSync(output);
45
+ fs.rmSync(options.candidate.databasePath, { force: true });
46
+ if (options.signal?.aborted)
47
+ throw new SharedIndexError("timeout", "shared-index decompression was cancelled");
48
+ if (error instanceof SharedIndexError)
49
+ throw error;
50
+ throw new SharedIndexError("integrity", "shared-index compressed object is not valid zstd");
51
+ }
52
+ finally {
53
+ source.destroy();
54
+ decompressor.destroy();
55
+ }
56
+ fs.closeSync(output);
57
+ const descriptor = fs.openSync(options.candidate.databasePath, "r");
58
+ const header = Buffer.alloc(SQLITE_HEADER.byteLength);
59
+ const headerBytes = fs.readSync(descriptor, header, 0, header.byteLength, 0);
60
+ fs.closeSync(descriptor);
61
+ if (headerBytes !== header.byteLength || !header.equals(SQLITE_HEADER)) {
62
+ fs.rmSync(options.candidate.databasePath, { force: true });
63
+ throw new SharedIndexError("integrity", "shared-index artifact is not a SQLite database");
64
+ }
65
+ }