knodin 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +590 -0
  3. package/dist/bin/cli.js +1704 -0
  4. package/dist/src/agent-integration.js +250 -0
  5. package/dist/src/artifact-refresh.js +81 -0
  6. package/dist/src/cli-args.js +267 -0
  7. package/dist/src/cli-model.js +324 -0
  8. package/dist/src/compact-structural.js +96 -0
  9. package/dist/src/competitive-constraints.js +20 -0
  10. package/dist/src/competitive-manifest.js +330 -0
  11. package/dist/src/competitive-measurement.js +183 -0
  12. package/dist/src/competitive-runner.js +453 -0
  13. package/dist/src/competitive-sandbox.js +108 -0
  14. package/dist/src/context-export.js +422 -0
  15. package/dist/src/context.js +102 -0
  16. package/dist/src/docs-sections.js +141 -0
  17. package/dist/src/doctor.js +380 -0
  18. package/dist/src/engine/ann-hnsw.js +271 -0
  19. package/dist/src/engine/embeddings.js +193 -0
  20. package/dist/src/engine/file-walker.js +43 -0
  21. package/dist/src/engine/index.js +13030 -0
  22. package/dist/src/engine/perf.js +115 -0
  23. package/dist/src/engine/prune.js +112 -0
  24. package/dist/src/engine/source-policy.js +69 -0
  25. package/dist/src/engine/sqlite.js +71 -0
  26. package/dist/src/engine/symbol-delete.js +58 -0
  27. package/dist/src/failure-diagnosis.js +590 -0
  28. package/dist/src/fleet.js +7 -0
  29. package/dist/src/git-executable.js +31 -0
  30. package/dist/src/graph-query-health.js +115 -0
  31. package/dist/src/index-activity.js +125 -0
  32. package/dist/src/init-progress-worker.js +107 -0
  33. package/dist/src/init-progress.js +155 -0
  34. package/dist/src/init.js +985 -0
  35. package/dist/src/lifecycle-health.js +213 -0
  36. package/dist/src/lsp-readonly.js +217 -0
  37. package/dist/src/output-compression.js +629 -0
  38. package/dist/src/output-telemetry.js +359 -0
  39. package/dist/src/pr-triage.js +638 -0
  40. package/dist/src/relationship-adapters.js +370 -0
  41. package/dist/src/release-attestation.js +533 -0
  42. package/dist/src/repair-progress-worker.js +121 -0
  43. package/dist/src/repair-progress.js +262 -0
  44. package/dist/src/repository-init-process.js +173 -0
  45. package/dist/src/repository-management.js +1089 -0
  46. package/dist/src/response-budget.js +184 -0
  47. package/dist/src/server.js +53 -0
  48. package/dist/src/system-config.js +615 -0
  49. package/dist/src/terminal-help.js +83 -0
  50. package/dist/src/tools/knodin-tools.js +1438 -0
  51. package/dist/src/tools/reckon-tools.js +5 -0
  52. package/dist/src/update-policy.js +944 -0
  53. package/dist/src/update-trust.js +503 -0
  54. package/dist/src/version.js +13 -0
  55. package/dist/src/visualization.js +162 -0
  56. package/dist/src/wait-for-fresh.js +98 -0
  57. package/dist/src/worktree-lifecycle.js +231 -0
  58. package/docs/CLI.md +39 -0
  59. package/docs/COMMAND-OUTPUT-COMPRESSION.md +194 -0
  60. package/docs/DEAD-CODE-AND-IMPACT.md +27 -0
  61. package/docs/DOCTOR-AND-UPDATES.md +84 -0
  62. package/docs/INDEXING-POLICY-AND-PROVENANCE.md +37 -0
  63. package/docs/INSTALLATION.md +208 -0
  64. package/docs/MCP.md +100 -0
  65. package/docs/PT-ACCESS-RECOMMENDATION.md +91 -0
  66. package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
  67. package/docs/REPOSITORIES-AND-WORKTREES.md +81 -0
  68. package/docs/SIGNED-UPDATES.md +146 -0
  69. package/docs/SYSTEMS-AND-RELATIONSHIPS.md +45 -0
  70. package/docs/TELEMETRY.md +42 -0
  71. package/docs/releases/0.3.0.md +46 -0
  72. package/docs/releases/0.4.0.md +68 -0
  73. package/docs/releases/0.4.1.md +28 -0
  74. package/docs/releases/0.4.2.md +27 -0
  75. package/docs/releases/0.4.3.md +23 -0
  76. package/docs/releases/0.5.0.md +29 -0
  77. package/package.json +110 -0
  78. package/schemas/release-attestation-v1.schema.json +210 -0
  79. package/tree-sitter-prisma.wasm +0 -0
  80. package/tree-sitter-sql.wasm +0 -0
  81. package/tree-sitter-xml.wasm +0 -0
@@ -0,0 +1,380 @@
1
+ import child_process from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { detectSupportedAgents } from "./agent-integration.js";
6
+ import { inspectLifecycleHealth } from "./lifecycle-health.js";
7
+ import { trustedUpdateStatus } from "./update-policy.js";
8
+ function defaultRun(command, args, options = {}) {
9
+ const result = child_process.spawnSync(command, args, {
10
+ encoding: "utf-8",
11
+ input: options.input,
12
+ timeout: 5_000,
13
+ stdio: ["pipe", "pipe", "pipe"],
14
+ });
15
+ return {
16
+ status: result.status,
17
+ stdout: result.stdout ?? "",
18
+ stderr: result.stderr ?? result.error?.message ?? "",
19
+ };
20
+ }
21
+ function managerEvidence(executable, env) {
22
+ const explicit = [
23
+ ["mise", env.MISE_DATA_DIR ?? env.MISE_ENV, "MISE_DATA_DIR or MISE_ENV is set"],
24
+ ["volta", env.VOLTA_HOME, "VOLTA_HOME is set"],
25
+ ["nvm", env.NVM_BIN ?? env.NVM_DIR, "NVM_BIN or NVM_DIR is set"],
26
+ ["fnm", env.FNM_DIR ?? env.FNM_MULTISHELL_PATH, "FNM_DIR or FNM_MULTISHELL_PATH is set"],
27
+ ["asdf", env.ASDF_DIR ?? env.ASDF_DATA_DIR, "ASDF_DIR or ASDF_DATA_DIR is set"],
28
+ ["homebrew", env.HOMEBREW_PREFIX, "HOMEBREW_PREFIX is set"],
29
+ ];
30
+ for (const [name, value, evidence] of explicit) {
31
+ if (value)
32
+ return { name, confidence: "high", evidence: [evidence] };
33
+ }
34
+ const lower = executable.toLowerCase();
35
+ for (const [needle, name] of [
36
+ ["/.local/share/mise/", "mise"],
37
+ ["/.volta/", "volta"],
38
+ ["/.nvm/", "nvm"],
39
+ ["/fnm", "fnm"],
40
+ ["/.asdf/", "asdf"],
41
+ ["/homebrew/", "homebrew"],
42
+ ]) {
43
+ if (lower.includes(needle)) {
44
+ return {
45
+ name,
46
+ confidence: "medium",
47
+ evidence: [`resolved executable path contains ${needle}; path heuristic only`],
48
+ };
49
+ }
50
+ }
51
+ return {
52
+ name: "unknown",
53
+ confidence: "low",
54
+ evidence: ["no manager-owned environment evidence; executable path alone is inconclusive"],
55
+ };
56
+ }
57
+ function symlinkChain(executable) {
58
+ const chain = [path.resolve(executable)];
59
+ for (let count = 0; count < 16; count++) {
60
+ const current = chain.at(-1);
61
+ if (!current)
62
+ break;
63
+ try {
64
+ if (!fs.lstatSync(current).isSymbolicLink()) {
65
+ const canonical = fs.realpathSync(current);
66
+ if (canonical !== current)
67
+ chain.push(canonical);
68
+ break;
69
+ }
70
+ const target = fs.readlinkSync(current);
71
+ const resolved = path.resolve(path.dirname(current), target);
72
+ if (chain.includes(resolved))
73
+ break;
74
+ chain.push(resolved);
75
+ }
76
+ catch {
77
+ break;
78
+ }
79
+ }
80
+ return chain;
81
+ }
82
+ function executableCandidates(env) {
83
+ const candidates = [];
84
+ for (const directory of (env.PATH ?? "").split(path.delimiter)) {
85
+ if (!directory)
86
+ continue;
87
+ const candidate = path.join(directory, process.platform === "win32" ? "knodin.cmd" : "knodin");
88
+ try {
89
+ fs.accessSync(candidate, fs.constants.X_OK);
90
+ const resolved = fs.realpathSync(candidate);
91
+ if (!candidates.includes(resolved))
92
+ candidates.push(resolved);
93
+ }
94
+ catch {
95
+ // Not an executable candidate.
96
+ }
97
+ }
98
+ return candidates;
99
+ }
100
+ function runtimeTarget(command) {
101
+ const [executable, ...arguments_] = command;
102
+ if (!executable)
103
+ return "";
104
+ const basename = path.basename(executable).toLowerCase();
105
+ if (basename === "node" ||
106
+ basename === "node.exe" ||
107
+ basename === "bun" ||
108
+ basename === "tsx" ||
109
+ basename === "tsx.cmd") {
110
+ const valueFlags = new Set([
111
+ "--import",
112
+ "--loader",
113
+ "--experimental-loader",
114
+ "--require",
115
+ "-r",
116
+ ]);
117
+ for (let index = 0; index < arguments_.length; index++) {
118
+ const argument = arguments_[index];
119
+ if (valueFlags.has(argument)) {
120
+ index++;
121
+ continue;
122
+ }
123
+ if (argument.startsWith("-"))
124
+ continue;
125
+ return argument;
126
+ }
127
+ }
128
+ return executable;
129
+ }
130
+ function sanitizeRegistry(raw) {
131
+ const value = raw.trim();
132
+ try {
133
+ const parsed = new URL(value);
134
+ parsed.username = "";
135
+ parsed.password = "";
136
+ parsed.search = "";
137
+ parsed.hash = "";
138
+ return parsed.toString();
139
+ }
140
+ catch {
141
+ return "unknown";
142
+ }
143
+ }
144
+ function readAgentConfig(agent, file) {
145
+ try {
146
+ const content = fs.readFileSync(file, "utf-8");
147
+ if (agent === "claude" && path.basename(file) === ".claude.json") {
148
+ const present = content.includes('"knodin"');
149
+ return { agent, file, present, command: present ? "knodin" : null, args: ["serve"] };
150
+ }
151
+ if (file.endsWith(".toml")) {
152
+ const sectionStart = content.indexOf('[mcp_servers."knodin"]');
153
+ const sectionTail = sectionStart >= 0 ? content.slice(sectionStart) : "";
154
+ const nextSection = sectionTail.indexOf("\n[", 1);
155
+ const block = nextSection >= 0 ? sectionTail.slice(0, nextSection) : sectionTail;
156
+ const command = /^[ \t]*command[ \t]*=[ \t]*"([^"\r\n]+)"/m.exec(block)?.[1];
157
+ const args = /^[ \t]*args[ \t]*=[ \t]*\[([^\]]*)\]/m
158
+ .exec(block)?.[1]
159
+ ?.split(",")
160
+ .map((value) => value.trim().replace(/^"|"$/g, ""))
161
+ .filter(Boolean);
162
+ return {
163
+ agent,
164
+ file,
165
+ present: sectionStart >= 0,
166
+ command: command ?? null,
167
+ args: args ?? [],
168
+ };
169
+ }
170
+ const document = JSON.parse(content);
171
+ const registration = document.mcpServers?.knodin;
172
+ return {
173
+ agent,
174
+ file,
175
+ present: registration !== undefined,
176
+ command: typeof registration?.command === "string" ? registration.command : null,
177
+ args: Array.isArray(registration?.args)
178
+ ? registration.args.filter((value) => typeof value === "string")
179
+ : [],
180
+ };
181
+ }
182
+ catch {
183
+ return { agent, file, present: false, command: null, args: [] };
184
+ }
185
+ }
186
+ function configuredAgentCommands(repo) {
187
+ const candidates = [
188
+ { agent: "claude", file: ".mcp.json" },
189
+ { agent: "codex", file: ".codex/config.toml" },
190
+ { agent: "gemini", file: ".gemini/settings.json" },
191
+ { agent: "antigravity", file: ".agents/mcp_config.json" },
192
+ ];
193
+ return candidates.map(({ agent, file }) => readAgentConfig(agent, path.join(repo, file)));
194
+ }
195
+ function personalAgentCommands(homeDir) {
196
+ return [
197
+ readAgentConfig("claude", path.join(homeDir, ".claude.json")),
198
+ readAgentConfig("codex", path.join(homeDir, ".codex", "config.toml")),
199
+ readAgentConfig("gemini", path.join(homeDir, ".gemini", "settings.json")),
200
+ readAgentConfig("antigravity", path.join(homeDir, ".agents", "mcp_config.json")),
201
+ ];
202
+ }
203
+ function installedPackageVersion(executable) {
204
+ let directory = path.dirname(executable);
205
+ for (let depth = 0; depth < 6; depth++) {
206
+ try {
207
+ const manifest = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf-8"));
208
+ if (manifest.name === "knodin" && typeof manifest.version === "string")
209
+ return manifest.version;
210
+ }
211
+ catch {
212
+ // Continue toward the package root.
213
+ }
214
+ const parent = path.dirname(directory);
215
+ if (parent === directory)
216
+ break;
217
+ directory = parent;
218
+ }
219
+ return "unknown";
220
+ }
221
+ function managerRemediation(manager, version) {
222
+ if (manager === "mise")
223
+ return `mise use --global npm:knodin@${version}`;
224
+ if (manager === "volta")
225
+ return `volta install knodin@${version}`;
226
+ if (manager === "homebrew")
227
+ return "brew update && brew upgrade knodin";
228
+ if (manager === "nvm")
229
+ return `nvm use 24 && npm install --global --ignore-scripts knodin@${version}`;
230
+ if (manager === "fnm")
231
+ return `fnm use 24 && npm install --global --ignore-scripts knodin@${version}`;
232
+ if (manager === "asdf")
233
+ return `asdf set nodejs 24 && npm install --global --ignore-scripts knodin@${version}`;
234
+ return `npm install --global --ignore-scripts knodin@${version}`;
235
+ }
236
+ function parseMcp(output) {
237
+ const messages = output
238
+ .split(/\r?\n/)
239
+ .filter(Boolean)
240
+ .flatMap((line) => {
241
+ try {
242
+ return [JSON.parse(line)];
243
+ }
244
+ catch {
245
+ return [];
246
+ }
247
+ });
248
+ const initialize = messages.find(({ id }) => id === 1);
249
+ const tools = messages.find(({ id }) => id === 2);
250
+ const initializeResult = initialize?.result;
251
+ const toolsResult = tools?.result;
252
+ const names = (toolsResult?.tools ?? []).flatMap(({ name }) => typeof name === "string" ? [name] : []);
253
+ return {
254
+ initialize: initializeResult?.serverInfo ? "ok" : "failed",
255
+ toolsList: names.length > 0 ? "ok" : "failed",
256
+ serverVersion: typeof initializeResult?.serverInfo?.version === "string"
257
+ ? initializeResult.serverInfo.version
258
+ : undefined,
259
+ toolNames: names,
260
+ };
261
+ }
262
+ function mcpProbe(command, run) {
263
+ const input = [
264
+ JSON.stringify({
265
+ jsonrpc: "2.0",
266
+ id: 1,
267
+ method: "initialize",
268
+ params: {
269
+ protocolVersion: "2025-11-25",
270
+ capabilities: {},
271
+ clientInfo: { name: "knodin-doctor", version: "1" },
272
+ },
273
+ }),
274
+ JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }),
275
+ JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }),
276
+ "",
277
+ ].join("\n");
278
+ const [executable, ...args] = command;
279
+ if (!executable)
280
+ return { initialize: "failed", toolsList: "failed", toolNames: [], stderr: "no command" };
281
+ const result = run(executable, args, { input });
282
+ return { ...parseMcp(result.stdout), stderr: result.status === 0 ? undefined : result.stderr };
283
+ }
284
+ export async function diagnoseInstallation(repoPath, options) {
285
+ const repo = path.resolve(repoPath);
286
+ const env = options.env ?? process.env;
287
+ const run = options.run ?? defaultRun;
288
+ const executable = runtimeTarget(options.runtimeCommand);
289
+ const chain = symlinkChain(executable);
290
+ const candidates = executableCandidates(env);
291
+ const npmPrefix = run("npm", ["config", "get", "prefix"]);
292
+ const npmRegistry = run("npm", ["config", "get", "registry"]);
293
+ const mcp = mcpProbe(options.runtimeCommand, run);
294
+ const runtimeVersion = mcp.serverVersion;
295
+ const lifecycle = inspectLifecycleHealth(repo);
296
+ const detectedAgents = detectSupportedAgents();
297
+ const repositoryCommands = configuredAgentCommands(repo);
298
+ const personalCommands = personalAgentCommands(options.homeDir ?? os.homedir());
299
+ const clients = repositoryCommands
300
+ .filter(({ agent }) => options.client === undefined || options.client === agent)
301
+ .map((repository) => {
302
+ const personal = personalCommands.find(({ agent }) => agent === repository.agent);
303
+ const configured = repository.present || personal?.present === true;
304
+ return {
305
+ client: repository.agent,
306
+ detected: detectedAgents.includes(repository.agent),
307
+ repository,
308
+ personal,
309
+ server: {
310
+ initialize: mcp.initialize,
311
+ toolsList: mcp.toolsList,
312
+ toolNames: mcp.toolNames,
313
+ },
314
+ activeSessionExposure: "unknown",
315
+ note: repository.agent === "claude"
316
+ ? ".mcp.json is Claude project configuration, not a universal MCP registration."
317
+ : "Configuration presence does not prove that the active client session loaded it.",
318
+ remediation: configured
319
+ ? "Restart or reload the client, then verify its active tools list."
320
+ : `Run \`knodin configure --scope personal\` or \`knodin configure --scope team\` for ${repository.agent}.`,
321
+ };
322
+ });
323
+ const manager = managerEvidence(chain.at(-1) ?? executable, env);
324
+ const packageVersion = installedPackageVersion(chain.at(-1) ?? executable);
325
+ return {
326
+ schemaVersion: 1,
327
+ status: options.graph.status === "healthy" &&
328
+ mcp.initialize === "ok" &&
329
+ mcp.toolsList === "ok" &&
330
+ candidates.length <= 1
331
+ ? "healthy"
332
+ : "attention-required",
333
+ versions: {
334
+ package: packageVersion,
335
+ cli: runtimeVersion ?? "unknown",
336
+ source: options.currentVersion,
337
+ agree: runtimeVersion === options.currentVersion &&
338
+ (packageVersion === "unknown" || packageVersion === options.currentVersion),
339
+ },
340
+ runtime: {
341
+ node: process.version,
342
+ platform: process.platform,
343
+ arch: process.arch,
344
+ },
345
+ npm: {
346
+ prefix: npmPrefix.status === 0 ? npmPrefix.stdout.trim() : "unknown",
347
+ registry: npmRegistry.status === 0 ? sanitizeRegistry(npmRegistry.stdout) : "unknown",
348
+ },
349
+ manager,
350
+ executable: {
351
+ resolved: chain.at(-1) ?? executable,
352
+ chain,
353
+ candidates,
354
+ conflicts: candidates.filter((candidate) => candidate !== chain.at(-1)),
355
+ },
356
+ agents: {
357
+ detected: detectedAgents,
358
+ configuredCommands: repositoryCommands,
359
+ clients,
360
+ },
361
+ mcp,
362
+ lifecycle,
363
+ graph: options.graph,
364
+ update: trustedUpdateStatus({
365
+ currentVersion: options.currentVersion,
366
+ stateHome: options.cacheHome,
367
+ installMethod: manager.name,
368
+ env,
369
+ }),
370
+ remediation: {
371
+ manager: managerRemediation(manager.name, options.currentVersion),
372
+ duplicates: candidates.length > 1
373
+ ? "Remove only the unintended manager-owned installation, then rerun `knodin doctor`."
374
+ : null,
375
+ graph: options.graph.status === "healthy"
376
+ ? null
377
+ : "Run `knodin repair`, then `knodin status --deep`.",
378
+ },
379
+ };
380
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Pure-TypeScript, dependency-free HNSW approximate nearest-neighbor index for
3
+ * the vector half of `search()` (R18).
4
+ *
5
+ * WHY THIS EXISTS, AND WHY IT IS OPT-IN. R16 removed R11(a)'s random-hyperplane
6
+ * LSH path after measuring it slower AND far less accurate (37% top-5 recall)
7
+ * than the exact cosine scan on a real 18.6k-symbol corpus. R11(a)'s only test
8
+ * passed because its 30-symbol fixture never exceeded the candidate floor, so it
9
+ * degenerated to an exact scan and never proved anything about real pruning.
10
+ *
11
+ * The durable lesson, now a hard gate: no approximate search path may engage BY
12
+ * DEFAULT without a measured >=95% top-10 recall vs the exact scan on a real
13
+ * corpus, with the method stated. This module is therefore OPT-IN ONLY, behind
14
+ * `RECKON_ANN=1`; `search()`'s default remains the exact scan at every corpus
15
+ * size. See scripts/ann-bench.ts for the recall/latency harness.
16
+ *
17
+ * MEASURED, GATE NOT CLEARED (so it stays opt-in). scripts/ann-bench.ts on
18
+ * /path/to/large-repository (18,639 symbols / 18,639 embeddings / schema 14), 20 queries,
19
+ * limit=10, two separate processes, one untimed warm-up, cold first-timed query
20
+ * excluded from the warm median, ef=200:
21
+ * Exact scan: warm median 38.11ms, min 34.97ms
22
+ * ANN ef=200: warm median 34.91ms, min 27.17ms
23
+ * Recall vs exact: mean top-5 91.0%, mean top-10 88.0% (one query,
24
+ * "resolve import path", scored 0%).
25
+ * 88.0% top-10 is far better than R11(a)'s LSH (43.3% top-10, AND slower) — the
26
+ * three queries LSH scored 0% on ("session token", "webhook handler", "metrics
27
+ * collection") here score 80/80/90% top-10 — but it misses the hard 95% floor,
28
+ * so the exact scan REMAINS the default. Recall is tunable via RECKON_ANN_EF (a
29
+ * larger ef trades latency for recall); default-engagement would require a fresh
30
+ * measurement clearing 95% at the corpus size where the path engages. Given the
31
+ * exact scan is 38ms at 18.6k and extrapolates sub-second to ~250k symbols,
32
+ * there is no corpus today where enabling this by default is worth doing.
33
+ *
34
+ * REMOVABILITY. The entire algorithm lives in this one file. It holds no stored
35
+ * state — the index is built in memory from the already-fetched embedding rows
36
+ * and cached per (repoPath, indexGeneration) by the caller, invalidated exactly
37
+ * like mapCache/flowsCache. If a future measurement finds it also fails the
38
+ * gate, deleting this file + the one guarded `if/else` seam + the cache Map in
39
+ * index.ts restores the exact scan with zero residue and no schema migration —
40
+ * the same clean-revert shape R11(a) had.
41
+ *
42
+ * DISTANCE KERNEL. Similarity is `embeddings.computeSimilarity` (dot product of
43
+ * L2-normalized vectors; higher = nearer), called via the namespace import so a
44
+ * degenerate "scan everything" implementation is observable at the integration
45
+ * layer (the gate test counts these calls and asserts real pruning).
46
+ */
47
+ import * as embeddings from "./embeddings.js";
48
+ const DEFAULT_M = 16;
49
+ const DEFAULT_EF_CONSTRUCTION = 200;
50
+ const DEFAULT_EF_SEARCH = 200;
51
+ const DEFAULT_SEED = 0x9e3779b9;
52
+ /** Deterministic PRNG so index topology (and thus recall) is reproducible. */
53
+ function mulberry32(seed) {
54
+ let a = seed >>> 0;
55
+ return () => {
56
+ a |= 0;
57
+ a = (a + 0x6d2b79f5) | 0;
58
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
59
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
60
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
61
+ };
62
+ }
63
+ class Hnsw {
64
+ nodes = [];
65
+ entryPoint = -1;
66
+ maxLevel = -1;
67
+ M;
68
+ Mmax0;
69
+ efConstruction;
70
+ mL;
71
+ rand;
72
+ constructor(params) {
73
+ this.M = params.M ?? DEFAULT_M;
74
+ this.Mmax0 = this.M * 2;
75
+ this.efConstruction = params.efConstruction ?? DEFAULT_EF_CONSTRUCTION;
76
+ this.mL = 1 / Math.log(this.M);
77
+ this.rand = mulberry32(params.seed ?? DEFAULT_SEED);
78
+ }
79
+ get size() {
80
+ return this.nodes.length;
81
+ }
82
+ randomLevel() {
83
+ return Math.floor(-Math.log(this.rand() || Number.MIN_VALUE) * this.mL);
84
+ }
85
+ sim(a, b) {
86
+ return embeddings.computeSimilarity(a, b);
87
+ }
88
+ insert(id, vec) {
89
+ const level = this.randomLevel();
90
+ const idx = this.nodes.length;
91
+ const node = {
92
+ id,
93
+ vec,
94
+ level,
95
+ neighbors: Array.from({ length: level + 1 }, () => []),
96
+ };
97
+ this.nodes.push(node);
98
+ if (this.entryPoint === -1) {
99
+ this.entryPoint = idx;
100
+ this.maxLevel = level;
101
+ return;
102
+ }
103
+ let ep = this.entryPoint;
104
+ // Descend from the top down to level+1 with a greedy 1-NN walk.
105
+ for (let lc = this.maxLevel; lc > level; lc--) {
106
+ ep = this.greedyDescend(vec, ep, lc).node;
107
+ }
108
+ // From min(level, maxLevel) down to 0, run the ef beam and connect.
109
+ for (let lc = Math.min(level, this.maxLevel); lc >= 0; lc--) {
110
+ const candidates = this.searchLayer(vec, [ep], this.efConstruction, lc).candidates;
111
+ const Mlevel = lc === 0 ? this.Mmax0 : this.M;
112
+ const selected = this.selectNeighbors(candidates, Mlevel);
113
+ node.neighbors[lc] = selected.slice();
114
+ // Add reciprocal links, pruning the neighbor's list back to its cap.
115
+ for (const nbr of selected) {
116
+ const nbrNode = this.nodes[nbr];
117
+ nbrNode.neighbors[lc].push(idx);
118
+ const cap = lc === 0 ? this.Mmax0 : this.M;
119
+ if (nbrNode.neighbors[lc].length > cap) {
120
+ const rescored = nbrNode.neighbors[lc].map((n) => ({
121
+ idx: n,
122
+ score: this.sim(nbrNode.vec, this.nodes[n].vec),
123
+ }));
124
+ rescored.sort((x, y) => y.score - x.score);
125
+ nbrNode.neighbors[lc] = rescored.slice(0, cap).map((r) => r.idx);
126
+ }
127
+ }
128
+ ep = candidates.length > 0 ? candidates[0].idx : ep;
129
+ }
130
+ if (level > this.maxLevel) {
131
+ this.maxLevel = level;
132
+ this.entryPoint = idx;
133
+ }
134
+ }
135
+ /**
136
+ * Greedy single-nearest walk at one layer. Returns the local optimum node
137
+ * index and the exact number of distance computations it performed (so the
138
+ * caller can report `stats.visited` as a true distance-call count).
139
+ */
140
+ greedyDescend(query, entry, level) {
141
+ let current = entry;
142
+ let currentScore = this.sim(query, this.nodes[current].vec);
143
+ let distanceComputations = 1;
144
+ let improved = true;
145
+ while (improved) {
146
+ improved = false;
147
+ for (const nbr of this.nodes[current].neighbors[level] ?? []) {
148
+ const s = this.sim(query, this.nodes[nbr].vec);
149
+ distanceComputations++;
150
+ if (s > currentScore) {
151
+ currentScore = s;
152
+ current = nbr;
153
+ improved = true;
154
+ }
155
+ }
156
+ }
157
+ return { node: current, distanceComputations };
158
+ }
159
+ /**
160
+ * ef-wide best-first beam search at one layer from the given entry points.
161
+ * Returns the ef best candidates (sorted nearest-first) and the count of
162
+ * distance computations performed (the pruning instrumentation).
163
+ */
164
+ searchLayer(query, entries, ef, level) {
165
+ const visited = new Set();
166
+ // Max-heap-ish behavior via sorted arrays; corpora at this layer are small.
167
+ const candidateHeap = []; // frontier, best-first
168
+ const resultHeap = []; // ef best so far, worst-first
169
+ let distanceComputations = 0;
170
+ for (const e of entries) {
171
+ if (visited.has(e))
172
+ continue;
173
+ visited.add(e);
174
+ const s = this.sim(query, this.nodes[e].vec);
175
+ distanceComputations++;
176
+ candidateHeap.push({ idx: e, score: s });
177
+ resultHeap.push({ idx: e, score: s });
178
+ }
179
+ candidateHeap.sort((a, b) => b.score - a.score);
180
+ resultHeap.sort((a, b) => a.score - b.score);
181
+ while (candidateHeap.length > 0) {
182
+ const c = candidateHeap.shift();
183
+ if (!c)
184
+ break;
185
+ const worst = resultHeap[0];
186
+ if (worst && c.score < worst.score && resultHeap.length >= ef)
187
+ break;
188
+ for (const nbr of this.nodes[c.idx].neighbors[level] ?? []) {
189
+ if (visited.has(nbr))
190
+ continue;
191
+ visited.add(nbr);
192
+ const s = this.sim(query, this.nodes[nbr].vec);
193
+ distanceComputations++;
194
+ const currentWorst = resultHeap[0];
195
+ if (resultHeap.length < ef || (currentWorst && s > currentWorst.score)) {
196
+ insertSorted(candidateHeap, { idx: nbr, score: s }, true);
197
+ insertSorted(resultHeap, { idx: nbr, score: s }, false);
198
+ if (resultHeap.length > ef)
199
+ resultHeap.shift();
200
+ }
201
+ }
202
+ }
203
+ const candidates = resultHeap.slice().sort((a, b) => b.score - a.score);
204
+ return { candidates, visited: distanceComputations };
205
+ }
206
+ selectNeighbors(candidates, M) {
207
+ return candidates
208
+ .slice()
209
+ .sort((a, b) => b.score - a.score)
210
+ .slice(0, M)
211
+ .map((c) => c.idx);
212
+ }
213
+ search(queryVec, efSearch, k) {
214
+ if (this.entryPoint === -1)
215
+ return { results: [], stats: { visited: 0 } };
216
+ const ef = Math.max(efSearch, k);
217
+ let ep = this.entryPoint;
218
+ let visited = 0;
219
+ for (let lc = this.maxLevel; lc > 0; lc--) {
220
+ // Upper-layer greedy-walk distance calls count toward the search budget.
221
+ const descent = this.greedyDescend(queryVec, ep, lc);
222
+ ep = descent.node;
223
+ visited += descent.distanceComputations;
224
+ }
225
+ const { candidates, visited: layer0Visited } = this.searchLayer(queryVec, [ep], ef, 0);
226
+ visited += layer0Visited;
227
+ const results = candidates
228
+ .slice(0, k)
229
+ .map((c) => ({ id: this.nodes[c.idx].id, score: c.score }));
230
+ return { results, stats: { visited } };
231
+ }
232
+ }
233
+ /** Inserts into a score-sorted array; `bestFirst` = descending, else ascending. */
234
+ function insertSorted(arr, item, bestFirst) {
235
+ let lo = 0;
236
+ let hi = arr.length;
237
+ while (lo < hi) {
238
+ const mid = (lo + hi) >> 1;
239
+ const cmp = bestFirst ? arr[mid].score > item.score : arr[mid].score < item.score;
240
+ if (cmp)
241
+ lo = mid + 1;
242
+ else
243
+ hi = mid;
244
+ }
245
+ arr.splice(lo, 0, item);
246
+ }
247
+ /** Builds an in-memory HNSW index over the given embedding rows. */
248
+ export function buildHnswIndex(rows, params = {}) {
249
+ const index = new Hnsw(params);
250
+ for (const row of rows)
251
+ index.insert(row.id, row.vec);
252
+ return index;
253
+ }
254
+ /**
255
+ * Whether the ANN path is engaged. OPT-IN ONLY: the exact scan is the default at
256
+ * every corpus size until the >=95% top-10 recall gate is measured-and-cleared
257
+ * at a corpus size where the path would actually engage (see module header).
258
+ */
259
+ export function annEnabled() {
260
+ return process.env.RECKON_ANN === "1";
261
+ }
262
+ /** efSearch beam width, overridable via RECKON_ANN_EF (a benchmark knob). */
263
+ export function annEfSearch() {
264
+ const raw = process.env.RECKON_ANN_EF;
265
+ if (raw) {
266
+ const n = Number.parseInt(raw, 10);
267
+ if (Number.isFinite(n) && n > 0)
268
+ return n;
269
+ }
270
+ return DEFAULT_EF_SEARCH;
271
+ }