knodin 0.7.5 → 0.8.2
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 +18 -3
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +371 -66
- package/dist/bin/launcher.js +16 -1
- package/dist/src/agent-integration.js +82 -16
- package/dist/src/artifact-refresh.js +2 -1
- package/dist/src/cli-args.js +19 -1
- package/dist/src/cli-model.js +28 -2
- package/dist/src/codeflow-replay.js +2 -1
- package/dist/src/compare.js +39 -0
- package/dist/src/competitive-constraints.js +2 -1
- package/dist/src/competitive-runner.js +4 -4
- package/dist/src/context-export.js +3 -2
- package/dist/src/context.js +1 -1
- package/dist/src/deterministic-random.js +34 -0
- package/dist/src/diagnostics-write-helper.js +473 -0
- package/dist/src/diagnostics.js +1160 -133
- package/dist/src/doctor.js +3 -1
- package/dist/src/engine/ann-hnsw.js +2 -12
- package/dist/src/engine/file-walker.js +8 -2
- package/dist/src/engine/git-history.js +12 -12
- package/dist/src/engine/index.js +1174 -313
- package/dist/src/engine/sarif-import.js +341 -0
- package/dist/src/engine/scip-import.js +28 -13
- package/dist/src/engine/source-policy.js +16 -0
- package/dist/src/engine/state-paths.js +175 -0
- package/dist/src/execution-profile.js +15 -10
- package/dist/src/failure-diagnosis.js +7 -1
- package/dist/src/graph-layout.js +173 -0
- package/dist/src/index-activity.js +2 -1
- package/dist/src/init.js +86 -45
- package/dist/src/lifecycle-health.js +41 -9
- package/dist/src/mcp-graph-worker.js +69 -0
- package/dist/src/mcp-reliability.js +154 -0
- package/dist/src/mcp-worker-supervisor.js +350 -0
- package/dist/src/mirror.js +290 -0
- package/dist/src/node-runtime.js +157 -0
- package/dist/src/output-compression.js +2 -1
- package/dist/src/output-telemetry.js +16 -11
- package/dist/src/progressive-evidence.js +30 -26
- package/dist/src/pure-compression-cli.js +4 -3
- package/dist/src/relationship-adapters.js +15 -8
- package/dist/src/release-preflight.js +13 -10
- package/dist/src/repair-lease.js +85 -0
- package/dist/src/repository-init-process.js +13 -9
- package/dist/src/repository-management.js +34 -4
- package/dist/src/response-budget.js +8 -6
- package/dist/src/server.js +80 -35
- package/dist/src/structural-fast-path.js +16 -10
- package/dist/src/structural-snapshot.js +6 -2
- package/dist/src/system-config.js +25 -2
- package/dist/src/tools/knodin-tools.js +142 -31
- package/dist/src/update-ceremony.js +9 -5
- package/dist/src/update-trust.js +5 -4
- package/dist/src/visualization.js +372 -19
- package/dist/src/worktree-lifecycle.js +5 -2
- package/docs/BEHAVIORAL-CONTRACT.md +72 -0
- package/docs/CLI.md +20 -1
- package/docs/COMPARISON.md +403 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/DIAGNOSTICS.md +46 -11
- package/docs/HANDOFF.md +180 -0
- package/docs/INSTALLATION.md +21 -2
- package/docs/MCP.md +59 -8
- package/docs/PT-ACCESS-RECOMMENDATION.md +5 -7
- package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
- package/docs/SCIP-IMPORT.md +5 -0
- package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
- package/docs/releases/0.5.1.md +4 -4
- package/docs/releases/0.8.0.md +74 -0
- package/docs/releases/0.8.2.md +34 -0
- package/package.json +17 -4
- package/roadmap/competitive-roadmap.md +3801 -0
- package/schemas/release-attestation-v1.schema.json +1 -1
- package/schemas/support-bundle-v2.schema.json +212 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import childProcess from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { mirrorEntryPath, mirrorSourcePath, mirrorStagingRoot, mirrorStatePath, readMirrorRegistry, writeMirrorRegistry, } from "./engine/state-paths.js";
|
|
5
|
+
import { gitExecutable } from "./git-executable.js";
|
|
6
|
+
import { repositoryIdentity } from "./repository-management.js";
|
|
7
|
+
export class MirrorError extends Error {
|
|
8
|
+
kind;
|
|
9
|
+
constructor(kind, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "MirrorError";
|
|
12
|
+
this.kind = kind;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Marker dropped at a mirror's root so it cannot be mistaken for a checkout. */
|
|
16
|
+
const MIRROR_README = `This directory is a knodin mirror: a read-only shallow clone of a remote
|
|
17
|
+
repository, kept only so knodin can answer graph questions about it.
|
|
18
|
+
|
|
19
|
+
This is not a working copy.
|
|
20
|
+
Local edits here are DISCARDED on the next \`knodin remote refresh\`.
|
|
21
|
+
Clone the repository yourself if you intend to change it.
|
|
22
|
+
`;
|
|
23
|
+
/**
|
|
24
|
+
* Strips credentials from a clone URL so nothing secret reaches the registry.
|
|
25
|
+
*
|
|
26
|
+
* The password always goes. The username goes only for HTTP(S), where it is
|
|
27
|
+
* routinely a token or half of a credential pair.
|
|
28
|
+
*
|
|
29
|
+
* For other schemes the username is structural, not secret: `ssh://git@host/x`
|
|
30
|
+
* means the SSH account, and clearing it yields `ssh://host/x`, which is not the
|
|
31
|
+
* same URL and cannot be used to re-add the mirror. The registry value is shown
|
|
32
|
+
* by `knodin remote list`, so a user copying it back would get a URL that does
|
|
33
|
+
* not work — the listing has to stay actionable, not merely safe.
|
|
34
|
+
*/
|
|
35
|
+
export function sanitizeCloneUrl(url) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
parsed.password = "";
|
|
39
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:")
|
|
40
|
+
parsed.username = "";
|
|
41
|
+
return parsed.toString();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// scp-style (git@host:org/repo.git) carries no secret to strip.
|
|
45
|
+
return url;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Classifies a failed git invocation.
|
|
50
|
+
*
|
|
51
|
+
* GitHub and GitHub Enterprise deliberately answer 404 for a private repository
|
|
52
|
+
* the caller cannot see, so "missing" and "forbidden" are indistinguishable over
|
|
53
|
+
* the wire. They are therefore reported as one kind, and the message never
|
|
54
|
+
* claims the repository does not exist — that would turn an auth problem into a
|
|
55
|
+
* false statement about the remote.
|
|
56
|
+
*/
|
|
57
|
+
export function classifyGitFailure(stderr, signal) {
|
|
58
|
+
if (signal)
|
|
59
|
+
return "interrupted";
|
|
60
|
+
const text = stderr.toLowerCase();
|
|
61
|
+
if (/no space left on device|disk quota|write error|out of memory/.test(text))
|
|
62
|
+
return "disk";
|
|
63
|
+
if (/could not resolve host|network is unreachable|connection timed out|failed to connect|temporary failure in name resolution|operation timed out/.test(text))
|
|
64
|
+
return "network";
|
|
65
|
+
if (/repository not found|not found|does not exist|authentication failed|permission denied|access denied|could not read username|terminal prompts disabled|403|401/.test(text))
|
|
66
|
+
return "not-found-or-forbidden";
|
|
67
|
+
return "unknown";
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Removes embedded credentials from text before it is shown or logged.
|
|
71
|
+
*
|
|
72
|
+
* Git is necessarily invoked with the *raw* clone URL, and it echoes that URL
|
|
73
|
+
* back in its own error text — `fatal: repository 'https://user:token@host/x'
|
|
74
|
+
* not found`. Everything we surface interpolates that stderr, so a token in the
|
|
75
|
+
* URL would land in terminal output, and from there into scrollback, CI logs and
|
|
76
|
+
* support bundles. Only the registry copy was being sanitized.
|
|
77
|
+
*
|
|
78
|
+
* Two passes: an exact substitution of the raw URL when we have it, and a
|
|
79
|
+
* generic `//user:secret@` scrub for anything git spells differently (trailing
|
|
80
|
+
* `.git/`, percent-encoding, a redirect target we never passed in).
|
|
81
|
+
*
|
|
82
|
+
* The username class also excludes `:`, which is what makes this linear. An
|
|
83
|
+
* earlier form used `[^/@\s]*:[^/@\s]*@` and I argued it was safe because
|
|
84
|
+
* neither class can span the `@` that terminates them — true, and beside the
|
|
85
|
+
* point: neither class excluded `:` either, so a run of colons could be split
|
|
86
|
+
* between them in as many ways as there were colons. Measured on `//` followed
|
|
87
|
+
* by `a:` repeated, with no `@` to succeed on: 3.7 / 18.8 / 65.9 / 226.8 ms at
|
|
88
|
+
* 4k / 8k / 16k / 32k chars — quadratic. Excluding `:` from the first class
|
|
89
|
+
* fixes the split at the first colon; flat at 0.03 ms across the same inputs.
|
|
90
|
+
*
|
|
91
|
+
* Third time today that a regex I reasoned was linear was not, so: the guard on
|
|
92
|
+
* the credential-scrubbing path is itself measured, not argued.
|
|
93
|
+
*/
|
|
94
|
+
const EMBEDDED_CREDENTIAL = /\/\/[^/@\s:]*:[^/@\s]*@/g;
|
|
95
|
+
/**
|
|
96
|
+
* Username-only userinfo on HTTP(S), e.g. `https://<token>@host/x`.
|
|
97
|
+
*
|
|
98
|
+
* Raised in review on #59: the pair form above misses a token passed as the
|
|
99
|
+
* username alone, which is a normal way to authenticate over HTTPS, so the
|
|
100
|
+
* scrub would have left it intact unless it happened to match `rawUrl` exactly.
|
|
101
|
+
*
|
|
102
|
+
* Restricted to `http:`/`https:` on purpose, and it is the same distinction
|
|
103
|
+
* `sanitizeCloneUrl` makes: over SSH the username is structural (`ssh://git@…`
|
|
104
|
+
* names the account), so blanket-redacting `//user@` would mangle SSH URLs in
|
|
105
|
+
* error text while protecting nothing. A *password* is secret under any scheme
|
|
106
|
+
* and is still caught by the pair pattern.
|
|
107
|
+
*
|
|
108
|
+
* Linear: the class excludes `/`, `@`, `:` and whitespace, so it cannot span
|
|
109
|
+
* either the `@` that terminates it or the `//` that begins the next attempt.
|
|
110
|
+
* Verified by measurement rather than by that argument — see the spec.
|
|
111
|
+
*/
|
|
112
|
+
const HTTP_USERINFO = /(https?:\/\/)[^/@\s:]*@/gi;
|
|
113
|
+
export function redactSecrets(text, rawUrl) {
|
|
114
|
+
let out = text;
|
|
115
|
+
if (rawUrl?.includes("@"))
|
|
116
|
+
out = out.split(rawUrl).join(sanitizeCloneUrl(rawUrl));
|
|
117
|
+
out = out.replace(HTTP_USERINFO, "$1***@");
|
|
118
|
+
return out.replace(EMBEDDED_CREDENTIAL, "//***:***@");
|
|
119
|
+
}
|
|
120
|
+
/** Human-facing message for a failure kind, naming the identity actually used. */
|
|
121
|
+
function failureMessage(kind, url, stderr, rawUrl) {
|
|
122
|
+
const detail = redactSecrets(stderr, rawUrl).trim().split("\n").slice(-3).join(" ").slice(0, 400);
|
|
123
|
+
switch (kind) {
|
|
124
|
+
case "not-found-or-forbidden": {
|
|
125
|
+
// Git's own wording asserts non-existence ("repository ... does not
|
|
126
|
+
// exist", "Repository not found"), which is precisely the claim that
|
|
127
|
+
// cannot be supported: the server answers identically whether the
|
|
128
|
+
// repository is absent or merely invisible to these credentials.
|
|
129
|
+
// Passing that text through would launder a guess into a finding.
|
|
130
|
+
const neutral = detail
|
|
131
|
+
.replace(/does ?n[o']t exist/gi, "was not accessible")
|
|
132
|
+
.replace(/repository not found/gi, "repository not accessible");
|
|
133
|
+
return `knodin remote: ${url} was not found, or is not accessible with the credentials in use. Git reports both cases identically, so this is NOT evidence the repository is missing — retry with an account that has access (\`gh-as <account>\`) or an SSH remote. git reported: ${neutral}`;
|
|
134
|
+
}
|
|
135
|
+
case "network":
|
|
136
|
+
return `knodin remote: could not reach ${url}. ${detail}`;
|
|
137
|
+
case "disk":
|
|
138
|
+
return `knodin remote: ran out of disk while acquiring ${url}. ${detail}`;
|
|
139
|
+
case "interrupted":
|
|
140
|
+
return `knodin remote: acquiring ${url} was interrupted before it completed. ${detail}`;
|
|
141
|
+
default:
|
|
142
|
+
return `knodin remote: failed to acquire ${url}. ${detail}`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Runs git, throwing a classified MirrorError on failure.
|
|
147
|
+
*
|
|
148
|
+
* `url` is the sanitized form used in messages. `rawUrl` is the credentialed
|
|
149
|
+
* form actually handed to git, supplied only so its secret can be scrubbed back
|
|
150
|
+
* out of git's own error text — never for display.
|
|
151
|
+
*/
|
|
152
|
+
function runGit(args, url, cwd, rawUrl) {
|
|
153
|
+
const result = childProcess.spawnSync(gitExecutable(), args, {
|
|
154
|
+
cwd,
|
|
155
|
+
encoding: "utf8",
|
|
156
|
+
env: {
|
|
157
|
+
...process.env,
|
|
158
|
+
// Never block on an interactive credential prompt: a hang is worse than
|
|
159
|
+
// a clean, classified failure.
|
|
160
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
if (result.error)
|
|
164
|
+
throw new MirrorError("unknown", `${redactSecrets(result.error.message, rawUrl)} (${url})`);
|
|
165
|
+
if (result.status !== 0) {
|
|
166
|
+
const kind = classifyGitFailure(result.stderr ?? "", result.signal);
|
|
167
|
+
throw new MirrorError(kind, failureMessage(kind, url, result.stderr ?? "", rawUrl));
|
|
168
|
+
}
|
|
169
|
+
return result.stdout ?? "";
|
|
170
|
+
}
|
|
171
|
+
/** Recursively removes a directory, ignoring absence. */
|
|
172
|
+
function discard(target) {
|
|
173
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Acquires a mirror of `url`.
|
|
177
|
+
*
|
|
178
|
+
* The clone lands in a staging directory and is renamed into place only once it
|
|
179
|
+
* has completed, so an interrupted acquisition can never leave a partial mirror
|
|
180
|
+
* that later reads as a complete one — the same failure shape as an empty graph
|
|
181
|
+
* that reads as "no results".
|
|
182
|
+
*/
|
|
183
|
+
export function addMirror(url) {
|
|
184
|
+
const sanitized = sanitizeCloneUrl(url);
|
|
185
|
+
const staging = path.join(mirrorStagingRoot(), `pending-${process.pid}`);
|
|
186
|
+
fs.mkdirSync(mirrorStagingRoot(), { recursive: true });
|
|
187
|
+
discard(staging);
|
|
188
|
+
try {
|
|
189
|
+
// The raw `url` goes to git because it may carry the credentials that make
|
|
190
|
+
// the clone work; `sanitized` is what any message may show, and passing the
|
|
191
|
+
// raw form as the last argument lets its secret be scrubbed from git's own
|
|
192
|
+
// error text rather than echoed back to the terminal.
|
|
193
|
+
runGit(["clone", "--depth", "1", "--single-branch", url, staging], sanitized, undefined, url);
|
|
194
|
+
// Git records the clone URL verbatim in `.git/config`, so a token embedded
|
|
195
|
+
// in `url` would persist on disk inside the mirror — in a directory whose
|
|
196
|
+
// path `knodin remote list` prints — and be reused implicitly by every
|
|
197
|
+
// later `refresh`. Sanitizing the registry copy while leaving the real
|
|
198
|
+
// secret in the clone's config would be sanitization in name only.
|
|
199
|
+
//
|
|
200
|
+
// The cost is deliberate: `refresh` can no longer authenticate from a
|
|
201
|
+
// token baked into the URL, and falls back to whatever git would use
|
|
202
|
+
// anyway — an SSH key or a credential helper. When that is not configured,
|
|
203
|
+
// refresh fails with the existing not-found-or-forbidden message, which
|
|
204
|
+
// already points at `gh-as` and SSH remotes. An honest auth failure is
|
|
205
|
+
// better than a credential quietly living in ~/.knodin.
|
|
206
|
+
if (sanitized !== url)
|
|
207
|
+
runGit(["remote", "set-url", "origin", sanitized], sanitized, staging);
|
|
208
|
+
const identity = repositoryIdentity(staging);
|
|
209
|
+
const entry = mirrorEntryPath(identity);
|
|
210
|
+
const source = mirrorSourcePath(identity);
|
|
211
|
+
const existing = readMirrorRegistry().mirrors.find((m) => m.identity === identity);
|
|
212
|
+
if (existing && fs.existsSync(source)) {
|
|
213
|
+
discard(staging);
|
|
214
|
+
return { record: existing, alreadyPresent: true };
|
|
215
|
+
}
|
|
216
|
+
const sha = runGit(["rev-parse", "HEAD"], sanitized, staging).trim();
|
|
217
|
+
fs.mkdirSync(entry, { recursive: true });
|
|
218
|
+
fs.mkdirSync(mirrorStatePath(identity), { recursive: true });
|
|
219
|
+
discard(source);
|
|
220
|
+
fs.renameSync(staging, source);
|
|
221
|
+
fs.writeFileSync(path.join(entry, "README"), MIRROR_README);
|
|
222
|
+
const record = {
|
|
223
|
+
identity,
|
|
224
|
+
url: sanitized,
|
|
225
|
+
path: source,
|
|
226
|
+
fetchedAt: new Date().toISOString(),
|
|
227
|
+
sha,
|
|
228
|
+
readOnly: true,
|
|
229
|
+
};
|
|
230
|
+
const registry = readMirrorRegistry();
|
|
231
|
+
writeMirrorRegistry({
|
|
232
|
+
version: 1,
|
|
233
|
+
mirrors: [...registry.mirrors.filter((m) => m.identity !== identity), record],
|
|
234
|
+
});
|
|
235
|
+
return { record, alreadyPresent: false };
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
discard(staging);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* All registered mirrors, newest fetch first.
|
|
243
|
+
*
|
|
244
|
+
* Byte order, not `localeCompare`. `fetchedAt` is ISO 8601, which sorts
|
|
245
|
+
* correctly lexicographically, and locale collation can treat punctuation as
|
|
246
|
+
* variable-weight — so the `-`, `:` and `T` separators are not guaranteed to be
|
|
247
|
+
* compared the same way on every host. Same reasoning as `comparePaths`: this
|
|
248
|
+
* output should not depend on the machine it ran on.
|
|
249
|
+
*/
|
|
250
|
+
export function listMirrors() {
|
|
251
|
+
return [...readMirrorRegistry().mirrors].sort((left, right) => {
|
|
252
|
+
if (left.fetchedAt > right.fetchedAt)
|
|
253
|
+
return -1;
|
|
254
|
+
return left.fetchedAt < right.fetchedAt ? 1 : 0;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/** Removes a mirror's clone, its graph, and its registry entry. */
|
|
258
|
+
export function removeMirror(identity) {
|
|
259
|
+
const registry = readMirrorRegistry();
|
|
260
|
+
if (!registry.mirrors.some((mirror) => mirror.identity === identity))
|
|
261
|
+
return false;
|
|
262
|
+
discard(mirrorEntryPath(identity));
|
|
263
|
+
writeMirrorRegistry({
|
|
264
|
+
version: 1,
|
|
265
|
+
mirrors: registry.mirrors.filter((m) => m.identity !== identity),
|
|
266
|
+
});
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Fetches the mirror's branch again and hard-resets the clone onto it. The graph
|
|
271
|
+
* is left alone; callers re-index. Returns the record with its new sha.
|
|
272
|
+
*/
|
|
273
|
+
export function refreshMirror(identity) {
|
|
274
|
+
const registry = readMirrorRegistry();
|
|
275
|
+
const record = registry.mirrors.find((m) => m.identity === identity);
|
|
276
|
+
if (!record)
|
|
277
|
+
throw new MirrorError("unknown", `knodin remote: no mirror named ${identity}`);
|
|
278
|
+
const source = record.path;
|
|
279
|
+
if (!fs.existsSync(source))
|
|
280
|
+
throw new MirrorError("unknown", `knodin remote: mirror ${identity} is registered but its clone is missing; remove and re-add it`);
|
|
281
|
+
runGit(["fetch", "--depth", "1", "origin"], record.url, source);
|
|
282
|
+
runGit(["reset", "--hard", "FETCH_HEAD"], record.url, source);
|
|
283
|
+
const sha = runGit(["rev-parse", "HEAD"], record.url, source).trim();
|
|
284
|
+
const updated = { ...record, sha, fetchedAt: new Date().toISOString() };
|
|
285
|
+
writeMirrorRegistry({
|
|
286
|
+
version: 1,
|
|
287
|
+
mirrors: [...registry.mirrors.filter((m) => m.identity !== identity), updated],
|
|
288
|
+
});
|
|
289
|
+
return updated;
|
|
290
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { compareBytesDescending } from "./compare.js";
|
|
6
|
+
const MINIMUM_NODE_MAJOR = 24;
|
|
7
|
+
function nodeMajor(version) {
|
|
8
|
+
const match = version?.trim().match(/^v?(\d+)(?:\.|$)/);
|
|
9
|
+
if (!match)
|
|
10
|
+
return undefined;
|
|
11
|
+
const major = Number(match[1]);
|
|
12
|
+
return Number.isSafeInteger(major) ? major : undefined;
|
|
13
|
+
}
|
|
14
|
+
function isSupported(version) {
|
|
15
|
+
return (nodeMajor(version) ?? 0) >= MINIMUM_NODE_MAJOR;
|
|
16
|
+
}
|
|
17
|
+
function sortVersionsDescending(left, right) {
|
|
18
|
+
const leftParts = left.replace(/^v/, "").split(".").map(Number);
|
|
19
|
+
const rightParts = right.replace(/^v/, "").split(".").map(Number);
|
|
20
|
+
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
|
|
21
|
+
const difference = (rightParts[index] ?? 0) - (leftParts[index] ?? 0);
|
|
22
|
+
if (difference !== 0)
|
|
23
|
+
return difference;
|
|
24
|
+
}
|
|
25
|
+
// Byte order: this tie-break decides which runtime the handoff selects, so it
|
|
26
|
+
// must not vary with the host locale. Descending, matching the numeric
|
|
27
|
+
// comparison above.
|
|
28
|
+
return compareBytesDescending(left, right);
|
|
29
|
+
}
|
|
30
|
+
function addPathCandidates(options, executableName, add) {
|
|
31
|
+
for (const directory of (options.env.PATH ?? "").split(options.pathApi.delimiter)) {
|
|
32
|
+
if (directory && options.pathApi.isAbsolute(directory))
|
|
33
|
+
add(options.pathApi.join(directory, executableName));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function defaultFnmRoot(options, appDataRoot) {
|
|
37
|
+
if (options.platform === "darwin")
|
|
38
|
+
return options.pathApi.join(options.homeDirectory, "Library", "Application Support", "fnm");
|
|
39
|
+
if (options.platform === "win32" && appDataRoot)
|
|
40
|
+
return options.pathApi.join(appDataRoot, "fnm");
|
|
41
|
+
return options.pathApi.join(options.homeDirectory, ".local", "share", "fnm");
|
|
42
|
+
}
|
|
43
|
+
function addPlatformCandidates(options, executableName, absoluteEnvRoot, add, addVersioned) {
|
|
44
|
+
if (options.platform === "darwin") {
|
|
45
|
+
add("/opt/homebrew/opt/node@24/bin/node");
|
|
46
|
+
add("/opt/homebrew/opt/node/bin/node");
|
|
47
|
+
add("/usr/local/opt/node@24/bin/node");
|
|
48
|
+
add("/usr/local/opt/node/bin/node");
|
|
49
|
+
}
|
|
50
|
+
if (options.platform === "win32") {
|
|
51
|
+
addVersioned(absoluteEnvRoot(options.env.NVM_HOME), []);
|
|
52
|
+
const programFilesRoot = absoluteEnvRoot(options.env.ProgramFiles);
|
|
53
|
+
add(programFilesRoot
|
|
54
|
+
? options.pathApi.join(programFilesRoot, "nodejs", executableName)
|
|
55
|
+
: undefined);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function runtimeCandidates(options) {
|
|
59
|
+
const { env, homeDirectory, pathApi, platform } = options;
|
|
60
|
+
const executableName = platform === "win32" ? "node.exe" : "node";
|
|
61
|
+
const candidates = [];
|
|
62
|
+
const seen = new Set([options.currentExecutable]);
|
|
63
|
+
const add = (candidate) => {
|
|
64
|
+
if (!candidate || seen.has(candidate))
|
|
65
|
+
return;
|
|
66
|
+
seen.add(candidate);
|
|
67
|
+
candidates.push(candidate);
|
|
68
|
+
};
|
|
69
|
+
const addVersioned = (root, suffix) => {
|
|
70
|
+
if (!root || !pathApi.isAbsolute(root))
|
|
71
|
+
return;
|
|
72
|
+
for (const version of options.listDirectories(root).sort(sortVersionsDescending))
|
|
73
|
+
add(pathApi.join(root, version, ...suffix, executableName));
|
|
74
|
+
};
|
|
75
|
+
if (env.KNODIN_NODE_RUNTIME && !pathApi.isAbsolute(env.KNODIN_NODE_RUNTIME))
|
|
76
|
+
throw new Error("KNODIN_NODE_RUNTIME must be an absolute path to a Node.js executable.");
|
|
77
|
+
add(env.KNODIN_NODE_RUNTIME);
|
|
78
|
+
addPathCandidates(options, executableName, add);
|
|
79
|
+
const absoluteEnvRoot = (value) => value && pathApi.isAbsolute(value) ? value : undefined;
|
|
80
|
+
const miseRoot = absoluteEnvRoot(env.MISE_DATA_DIR) ?? pathApi.join(homeDirectory, ".local", "share", "mise");
|
|
81
|
+
const managerBinaryDirectory = platform === "win32" ? [] : ["bin"];
|
|
82
|
+
addVersioned(pathApi.join(miseRoot, "installs", "node"), managerBinaryDirectory);
|
|
83
|
+
addVersioned(pathApi.join(homeDirectory, ".mise", "installs", "node"), managerBinaryDirectory);
|
|
84
|
+
const nvmRoot = absoluteEnvRoot(env.NVM_DIR);
|
|
85
|
+
addVersioned(nvmRoot ? pathApi.join(nvmRoot, "versions", "node") : undefined, managerBinaryDirectory);
|
|
86
|
+
addVersioned(pathApi.join(homeDirectory, ".nvm", "versions", "node"), managerBinaryDirectory);
|
|
87
|
+
const appDataRoot = absoluteEnvRoot(env.APPDATA);
|
|
88
|
+
const fnmDefaultRoot = defaultFnmRoot(options, appDataRoot);
|
|
89
|
+
const fnmRoot = absoluteEnvRoot(env.FNM_DIR);
|
|
90
|
+
addVersioned(fnmRoot ? pathApi.join(fnmRoot, "node-versions") : undefined, platform === "win32" ? ["installation"] : ["installation", "bin"]);
|
|
91
|
+
addVersioned(pathApi.join(fnmDefaultRoot, "node-versions"), platform === "win32" ? ["installation"] : ["installation", "bin"]);
|
|
92
|
+
const asdfRoot = absoluteEnvRoot(env.ASDF_DATA_DIR) ?? pathApi.join(homeDirectory, ".asdf");
|
|
93
|
+
addVersioned(pathApi.join(asdfRoot, "installs", "nodejs"), managerBinaryDirectory);
|
|
94
|
+
const voltaRoot = absoluteEnvRoot(env.VOLTA_HOME) ?? pathApi.join(homeDirectory, ".volta");
|
|
95
|
+
addVersioned(pathApi.join(voltaRoot, "tools", "image", "node"), managerBinaryDirectory);
|
|
96
|
+
addPlatformCandidates(options, executableName, absoluteEnvRoot, add, addVersioned);
|
|
97
|
+
return candidates;
|
|
98
|
+
}
|
|
99
|
+
export function handoffToSupportedNodeRuntime(options) {
|
|
100
|
+
if (isSupported(options.currentVersion))
|
|
101
|
+
return { kind: "current" };
|
|
102
|
+
const explicitRuntime = options.env.KNODIN_NODE_RUNTIME;
|
|
103
|
+
if (explicitRuntime === options.currentExecutable)
|
|
104
|
+
throw new Error(`KNODIN_NODE_RUNTIME points to the active Node.js ${options.currentVersion} executable; Node.js 24 or newer is required.`);
|
|
105
|
+
for (const candidate of runtimeCandidates(options)) {
|
|
106
|
+
const version = options.probe(candidate);
|
|
107
|
+
if (!isSupported(version)) {
|
|
108
|
+
if (candidate === explicitRuntime) {
|
|
109
|
+
throw new Error(`KNODIN_NODE_RUNTIME points to ${candidate}, but it reports ${version ?? "no usable Node.js version"}; Node.js 24 or newer is required.`);
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const result = options.spawn(candidate, options.launcherArguments, {
|
|
114
|
+
env: options.env,
|
|
115
|
+
stdio: "inherit",
|
|
116
|
+
});
|
|
117
|
+
if (result.error)
|
|
118
|
+
throw new Error(`Unable to launch Knodin with ${candidate}: ${result.error.message}`);
|
|
119
|
+
if (result.status === null && result.signal === null)
|
|
120
|
+
throw new Error(`Knodin's Node.js 24+ runtime at ${candidate} exited without a status.`);
|
|
121
|
+
return { kind: "relaunched", status: result.status, signal: result.signal };
|
|
122
|
+
}
|
|
123
|
+
throw new Error(`Knodin requires Node.js 24 or newer, but the active runtime is ${options.currentVersion}. Install Node.js 24+ with Homebrew or your version manager, or set KNODIN_NODE_RUNTIME=/absolute/path/to/node. Your project may remain pinned to Node.js 20.`);
|
|
124
|
+
}
|
|
125
|
+
function listDirectories(root) {
|
|
126
|
+
try {
|
|
127
|
+
return fs
|
|
128
|
+
.readdirSync(root, { withFileTypes: true })
|
|
129
|
+
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
|
130
|
+
.map((entry) => entry.name);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function probeNodeVersion(candidate) {
|
|
137
|
+
const result = spawnSync(candidate, ["-p", "process.versions.node"], {
|
|
138
|
+
encoding: "utf8",
|
|
139
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
140
|
+
timeout: 2_000,
|
|
141
|
+
});
|
|
142
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
143
|
+
}
|
|
144
|
+
export function handoffCurrentProcessToSupportedNodeRuntime() {
|
|
145
|
+
return handoffToSupportedNodeRuntime({
|
|
146
|
+
currentExecutable: process.execPath,
|
|
147
|
+
currentVersion: process.versions.node,
|
|
148
|
+
env: process.env,
|
|
149
|
+
homeDirectory: os.homedir(),
|
|
150
|
+
platform: process.platform,
|
|
151
|
+
launcherArguments: process.argv.slice(1),
|
|
152
|
+
probe: probeNodeVersion,
|
|
153
|
+
spawn: (candidate, args, options) => spawnSync(candidate, args, options),
|
|
154
|
+
pathApi: path,
|
|
155
|
+
listDirectories,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { resolveStateDir } from "./engine/state-paths.js";
|
|
4
5
|
const DEFAULT_LINE_BUDGET = 200;
|
|
5
6
|
const DEFAULT_BYTE_BUDGET = 16_384;
|
|
6
7
|
const DEFAULT_INPUT_LIMIT = 16 * 1024 * 1024;
|
|
@@ -335,7 +336,7 @@ function percentReduction(input, output) {
|
|
|
335
336
|
}
|
|
336
337
|
function artifactDirectory(repo) {
|
|
337
338
|
const resolvedRepo = fs.realpathSync(repo);
|
|
338
|
-
const knodinDirectory =
|
|
339
|
+
const knodinDirectory = resolveStateDir(resolvedRepo);
|
|
339
340
|
const outputDirectory = path.join(knodinDirectory, "output");
|
|
340
341
|
for (const candidate of [knodinDirectory, outputDirectory]) {
|
|
341
342
|
if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink())
|
|
@@ -2,6 +2,8 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { encode } from "gpt-tokenizer/encoding/o200k_base";
|
|
5
|
+
import { compareBytes } from "./compare.js";
|
|
6
|
+
import { resolveDbPath } from "./engine/state-paths.js";
|
|
5
7
|
export const TELEMETRY_TOKENIZER = "gpt-tokenizer@3.4.0:o200k_base";
|
|
6
8
|
export const countOutputTokens = (value) => encode(value).length;
|
|
7
9
|
export const DEFAULT_TELEMETRY_RETENTION_DAYS = 30;
|
|
@@ -56,11 +58,10 @@ function percent(baseline, optimized) {
|
|
|
56
58
|
function baselineFiles(repo, operation, output) {
|
|
57
59
|
if (!["query:file_summary", "query:batch_outline"].includes(operation))
|
|
58
60
|
return [];
|
|
59
|
-
const
|
|
60
|
-
?
|
|
61
|
-
:
|
|
62
|
-
|
|
63
|
-
: [];
|
|
61
|
+
const rows = Array.isArray(output.results)
|
|
62
|
+
? output.results.map((row) => row.file)
|
|
63
|
+
: [];
|
|
64
|
+
const candidates = operation === "query:file_summary" ? [output.target] : rows;
|
|
64
65
|
const root = fs.realpathSync(repo);
|
|
65
66
|
const files = new Set();
|
|
66
67
|
for (const candidate of candidates) {
|
|
@@ -79,7 +80,7 @@ function baselineFiles(repo, operation, output) {
|
|
|
79
80
|
if (fs.statSync(real).isFile())
|
|
80
81
|
files.add(real);
|
|
81
82
|
}
|
|
82
|
-
return [...files].sort();
|
|
83
|
+
return [...files].sort(compareBytes);
|
|
83
84
|
}
|
|
84
85
|
export function measureOutput(options) {
|
|
85
86
|
const serialized = JSON.stringify(options.output);
|
|
@@ -104,7 +105,7 @@ export function measureOutput(options) {
|
|
|
104
105
|
const availability = options.output.availability;
|
|
105
106
|
const freshness = options.output.freshness;
|
|
106
107
|
const fidelity = options.output.fidelity;
|
|
107
|
-
const database =
|
|
108
|
+
const database = resolveDbPath(fs.realpathSync(options.repo));
|
|
108
109
|
const latencyMs = Math.round((performance.now() - options.startedAt) * 100) / 100;
|
|
109
110
|
const outcome = (status !== undefined &&
|
|
110
111
|
["error", "failed", "unavailable", "blocked", "timeout"].includes(status)) ||
|
|
@@ -189,8 +190,10 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
|
|
|
189
190
|
group.push(record);
|
|
190
191
|
groups.set(record.operation, group);
|
|
191
192
|
}
|
|
193
|
+
// Byte order throughout this report: the HTML is written to disk as an
|
|
194
|
+
// artifact, so its bytes must not depend on the host locale.
|
|
192
195
|
const rows = [...groups]
|
|
193
|
-
.sort(([left], [right]) => left
|
|
196
|
+
.sort(([left], [right]) => compareBytes(left, right))
|
|
194
197
|
.map(([operation, values]) => {
|
|
195
198
|
const measured = values.filter(({ savings }) => savings !== null);
|
|
196
199
|
const tokens = measured.reduce((sum, value) => sum + (value.savings?.tokens ?? 0), 0);
|
|
@@ -207,7 +210,7 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
|
|
|
207
210
|
timeSeries.set(day, current);
|
|
208
211
|
}
|
|
209
212
|
const seriesRows = [...timeSeries]
|
|
210
|
-
.sort(([left], [right]) => left
|
|
213
|
+
.sort(([left], [right]) => compareBytes(left, right))
|
|
211
214
|
.map(([day, value]) => `<tr><td>${escapeHtml(day)}</td><td>${value.calls}</td><td>${value.tokens}</td></tr>`)
|
|
212
215
|
.join("");
|
|
213
216
|
const repositories = new Map();
|
|
@@ -215,7 +218,7 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
|
|
|
215
218
|
if (record.repositoryId)
|
|
216
219
|
repositories.set(record.repositoryId, (repositories.get(record.repositoryId) ?? 0) + 1);
|
|
217
220
|
const repositoryRows = [...repositories]
|
|
218
|
-
.sort(([left], [right]) => left
|
|
221
|
+
.sort(([left], [right]) => compareBytes(left, right))
|
|
219
222
|
.map(([id, calls]) => `<tr><td>${escapeHtml(id)}</td><td>${calls}</td></tr>`)
|
|
220
223
|
.join("");
|
|
221
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>`;
|
|
@@ -315,7 +318,9 @@ export function telemetryStatus(repoPath, inputPath = DEFAULT_INPUT, retentionDa
|
|
|
315
318
|
const dates = records
|
|
316
319
|
.map(({ at }) => (typeof at === "string" ? at : null))
|
|
317
320
|
.filter((value) => value !== null)
|
|
318
|
-
|
|
321
|
+
// Byte order: `[0]` and `.at(-1)` below report the oldest and newest
|
|
322
|
+
// record, so this sort selects values rather than presenting them.
|
|
323
|
+
.sort(compareBytes);
|
|
319
324
|
return {
|
|
320
325
|
schemaVersion: 1,
|
|
321
326
|
enabledByDefault: false,
|