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.
- package/README.md +7 -1
- package/dist/bin/cli.js +489 -78
- package/dist/src/agent-events.js +128 -0
- package/dist/src/agent-hooks.js +156 -0
- package/dist/src/cli-model.js +42 -1
- package/dist/src/engine/candidate-database.js +167 -0
- package/dist/src/engine/embeddings.js +8 -5
- package/dist/src/engine/index.js +930 -262
- package/dist/src/engine/prune.js +8 -0
- package/dist/src/file-metrics.js +153 -0
- package/dist/src/output-telemetry.js +8 -3
- package/dist/src/repair-lease.js +8 -5
- package/dist/src/response-budget.js +2 -1
- package/dist/src/session-telemetry.js +163 -0
- package/dist/src/shared-index/artifact.js +65 -0
- package/dist/src/shared-index/cache.js +267 -0
- package/dist/src/shared-index/compatibility.js +70 -0
- package/dist/src/shared-index/config.js +224 -0
- package/dist/src/shared-index/contract.js +117 -0
- package/dist/src/shared-index/manifest.js +71 -0
- package/dist/src/shared-index/opportunistic.js +37 -0
- package/dist/src/shared-index/overlay.js +98 -0
- package/dist/src/shared-index/provenance.js +149 -0
- package/dist/src/shared-index/publisher.js +198 -0
- package/dist/src/shared-index/restore.js +209 -0
- package/dist/src/shared-index/s3-client.js +124 -0
- package/dist/src/shared-index/schemas.js +86 -0
- package/dist/src/shared-index/selection.js +111 -0
- package/dist/src/tools/knodin-tools.js +70 -3
- package/docs/CLI.md +56 -0
- package/docs/INSTALLATION.md +11 -0
- package/docs/MCP.md +12 -0
- package/docs/SHARED-INDEX-CONTRACT.md +132 -0
- package/docs/TELEMETRY.md +19 -1
- package/docs/releases/0.8.4.md +22 -0
- package/docs/releases/0.8.5.md +25 -0
- package/package.json +12 -3
- package/roadmap/competitive-roadmap.md +20 -0
- package/schemas/shared-index-branch-pointer-v1.schema.json +66 -0
- package/schemas/shared-index-config-v1.schema.json +97 -0
- package/schemas/shared-index-manifest-v1.schema.json +104 -0
- package/schemas/shared-index-provenance-v1.schema.json +100 -0
package/dist/src/engine/prune.js
CHANGED
|
@@ -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 {
|
package/dist/src/repair-lease.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
+
}
|