knodin 0.7.6 → 0.8.3
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 +41 -12
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +2181 -108
- package/dist/bin/launcher.js +25 -3
- package/dist/src/agent-integration.js +304 -0
- package/dist/src/artifact-refresh.js +82 -0
- package/dist/src/cli-args.js +292 -0
- package/dist/src/cli-model.js +384 -0
- package/dist/src/codeflow-replay.js +81 -0
- package/dist/src/compact-structural.js +96 -0
- package/dist/src/compare.js +39 -0
- package/dist/src/competitive-cold-mcp.js +40 -0
- package/dist/src/competitive-constraints.js +21 -0
- package/dist/src/competitive-manifest.js +411 -0
- package/dist/src/competitive-measurement.js +183 -0
- package/dist/src/competitive-runner.js +487 -0
- package/dist/src/competitive-sandbox.js +108 -0
- package/dist/src/context-export.js +423 -0
- package/dist/src/context.js +102 -0
- package/dist/src/deterministic-random.js +34 -0
- package/dist/src/diagnostics-write-helper.js +473 -0
- package/dist/src/diagnostics.js +1476 -0
- package/dist/src/docs-sections.js +142 -0
- package/dist/src/doctor.js +382 -0
- package/dist/src/engine/ann-hnsw.js +261 -0
- package/dist/src/engine/embeddings.js +193 -0
- package/dist/src/engine/file-walker.js +49 -0
- package/dist/src/engine/git-history.js +289 -0
- package/dist/src/engine/index.js +15094 -0
- package/dist/src/engine/perf.js +115 -0
- package/dist/src/engine/prune.js +112 -0
- package/dist/src/engine/sarif-import.js +341 -0
- package/dist/src/engine/scip-import.js +423 -0
- package/dist/src/engine/source-policy.js +85 -0
- package/dist/src/engine/sqlite.js +71 -0
- package/dist/src/engine/state-paths.js +175 -0
- package/dist/src/engine/symbol-delete.js +58 -0
- package/dist/src/execution-profile.js +208 -0
- package/dist/src/failure-diagnosis.js +655 -0
- package/dist/src/fleet.js +7 -0
- package/dist/src/git-executable.js +31 -0
- package/dist/src/graph-layout.js +173 -0
- package/dist/src/graph-query-health.js +115 -0
- package/dist/src/hook-manager-integration.js +156 -0
- package/dist/src/index-activity.js +126 -0
- package/dist/src/init-progress-worker.js +70 -2
- package/dist/src/init-progress.js +155 -0
- package/dist/src/init.js +1295 -0
- package/dist/src/lifecycle-health.js +282 -0
- package/dist/src/lsp-readonly.js +217 -0
- 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 +630 -0
- package/dist/src/output-telemetry.js +368 -0
- package/dist/src/pr-triage.js +638 -0
- package/dist/src/progress-worker-runtime.js +46 -0
- package/dist/src/progressive-evidence.js +477 -0
- package/dist/src/pure-compression-cli.js +102 -0
- package/dist/src/relationship-adapters.js +377 -0
- package/dist/src/release-attestation.js +533 -0
- package/dist/src/release-preflight.js +513 -0
- package/dist/src/repair-lease.js +85 -0
- package/dist/src/repair-progress-worker.js +83 -2
- package/dist/src/repair-progress.js +262 -0
- package/dist/src/repository-init-process.js +177 -0
- package/dist/src/repository-management.js +1261 -0
- package/dist/src/resource-reachability.js +456 -0
- package/dist/src/response-budget.js +200 -0
- package/dist/src/server.js +217 -0
- package/dist/src/structural-fast-path.js +344 -0
- package/dist/src/structural-snapshot.js +37 -0
- package/dist/src/system-config.js +638 -0
- package/dist/src/terminal-help.js +83 -0
- package/dist/src/tools/knodin-tools.js +1645 -0
- package/dist/src/update-ceremony.js +162 -0
- package/dist/src/update-policy.js +944 -0
- package/dist/src/update-trust.js +504 -0
- package/dist/src/version.js +13 -0
- package/dist/src/visualization.js +515 -0
- package/dist/src/wait-for-fresh.js +98 -0
- package/dist/src/worktree-lifecycle.js +234 -0
- package/docs/BEHAVIORAL-CONTRACT.md +114 -0
- package/docs/CLI.md +30 -1
- package/docs/COMPARISON.md +413 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/CONTAINED-EXECUTION.md +77 -0
- package/docs/DEMO.md +49 -0
- package/docs/DIAGNOSTICS.md +80 -0
- package/docs/GIT-HISTORY-REVIEW.md +39 -0
- package/docs/HANDOFF.md +180 -0
- package/docs/INSTALLATION.md +21 -18
- package/docs/MCP.md +64 -8
- package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
- package/docs/PT-ACCESS-RECOMMENDATION.md +89 -0
- package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
- package/docs/SCIP-IMPORT.md +62 -0
- package/docs/SIGNED-UPDATES.md +151 -0
- package/docs/TELEMETRY.md +46 -0
- package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
- package/docs/assets/knodin-favicon.svg +4 -0
- package/docs/releases/0.3.0.md +46 -0
- package/docs/releases/0.4.0.md +68 -0
- package/docs/releases/0.4.1.md +28 -0
- package/docs/releases/0.4.2.md +27 -0
- package/docs/releases/0.4.3.md +23 -0
- package/docs/releases/0.5.0.md +29 -0
- package/docs/releases/0.5.1.md +17 -0
- package/docs/releases/0.6.0.md +18 -0
- package/docs/releases/0.7.0.md +24 -0
- package/docs/releases/0.7.1.md +21 -0
- package/docs/releases/0.7.2.md +21 -0
- package/docs/releases/0.7.3.md +23 -0
- package/docs/releases/0.7.4.md +17 -0
- package/docs/releases/0.7.5.md +20 -0
- package/docs/releases/0.8.0.md +74 -0
- package/docs/releases/0.8.2.md +34 -0
- package/docs/releases/0.8.3.md +47 -0
- package/package.json +139 -4
- package/roadmap/competitive-roadmap.md +3896 -0
- package/schemas/release-attestation-v1.schema.json +210 -0
- package/schemas/support-bundle-v2.schema.json +212 -0
- package/dist/chunks/chunk-DMQAGX77.js +0 -654
- package/dist/chunks/chunk-F4Z3Z766.js +0 -4
- package/dist/chunks/chunk-SIJAQVSX.js +0 -3
- package/dist/chunks/chunk-X6M4HUUE.js +0 -2
- package/dist/chunks/chunk-YPRMY2LP.js +0 -8
- package/dist/chunks/pure-compression-cli-4TA2TQD5.js +0 -5
- package/dist/chunks/server-7EDF4CBY.js +0 -14
- package/dist/chunks/structural-fast-path-KD5KQSPX.js +0 -4
- package/docs/releases/0.7.6.md +0 -25
|
@@ -0,0 +1,261 @@
|
|
|
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
|
+
* `KNODIN_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 KNODIN_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 { DEFAULT_PRNG_SEED, mulberry32 } from "../deterministic-random.js";
|
|
48
|
+
import * as embeddings from "./embeddings.js";
|
|
49
|
+
const DEFAULT_M = 16;
|
|
50
|
+
const DEFAULT_EF_CONSTRUCTION = 200;
|
|
51
|
+
const DEFAULT_EF_SEARCH = 200;
|
|
52
|
+
const DEFAULT_SEED = DEFAULT_PRNG_SEED;
|
|
53
|
+
class Hnsw {
|
|
54
|
+
nodes = [];
|
|
55
|
+
entryPoint = -1;
|
|
56
|
+
maxLevel = -1;
|
|
57
|
+
M;
|
|
58
|
+
Mmax0;
|
|
59
|
+
efConstruction;
|
|
60
|
+
mL;
|
|
61
|
+
rand;
|
|
62
|
+
constructor(params) {
|
|
63
|
+
this.M = params.M ?? DEFAULT_M;
|
|
64
|
+
this.Mmax0 = this.M * 2;
|
|
65
|
+
this.efConstruction = params.efConstruction ?? DEFAULT_EF_CONSTRUCTION;
|
|
66
|
+
this.mL = 1 / Math.log(this.M);
|
|
67
|
+
this.rand = mulberry32(params.seed ?? DEFAULT_SEED);
|
|
68
|
+
}
|
|
69
|
+
get size() {
|
|
70
|
+
return this.nodes.length;
|
|
71
|
+
}
|
|
72
|
+
randomLevel() {
|
|
73
|
+
return Math.floor(-Math.log(this.rand() || Number.MIN_VALUE) * this.mL);
|
|
74
|
+
}
|
|
75
|
+
sim(a, b) {
|
|
76
|
+
return embeddings.computeSimilarity(a, b);
|
|
77
|
+
}
|
|
78
|
+
insert(id, vec) {
|
|
79
|
+
const level = this.randomLevel();
|
|
80
|
+
const idx = this.nodes.length;
|
|
81
|
+
const node = {
|
|
82
|
+
id,
|
|
83
|
+
vec,
|
|
84
|
+
level,
|
|
85
|
+
neighbors: Array.from({ length: level + 1 }, () => []),
|
|
86
|
+
};
|
|
87
|
+
this.nodes.push(node);
|
|
88
|
+
if (this.entryPoint === -1) {
|
|
89
|
+
this.entryPoint = idx;
|
|
90
|
+
this.maxLevel = level;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
let ep = this.entryPoint;
|
|
94
|
+
// Descend from the top down to level+1 with a greedy 1-NN walk.
|
|
95
|
+
for (let lc = this.maxLevel; lc > level; lc--) {
|
|
96
|
+
ep = this.greedyDescend(vec, ep, lc).node;
|
|
97
|
+
}
|
|
98
|
+
// From min(level, maxLevel) down to 0, run the ef beam and connect.
|
|
99
|
+
for (let lc = Math.min(level, this.maxLevel); lc >= 0; lc--) {
|
|
100
|
+
const candidates = this.searchLayer(vec, [ep], this.efConstruction, lc).candidates;
|
|
101
|
+
const Mlevel = lc === 0 ? this.Mmax0 : this.M;
|
|
102
|
+
const selected = this.selectNeighbors(candidates, Mlevel);
|
|
103
|
+
node.neighbors[lc] = selected.slice();
|
|
104
|
+
// Add reciprocal links, pruning the neighbor's list back to its cap.
|
|
105
|
+
for (const nbr of selected) {
|
|
106
|
+
const nbrNode = this.nodes[nbr];
|
|
107
|
+
nbrNode.neighbors[lc].push(idx);
|
|
108
|
+
const cap = lc === 0 ? this.Mmax0 : this.M;
|
|
109
|
+
if (nbrNode.neighbors[lc].length > cap) {
|
|
110
|
+
const rescored = nbrNode.neighbors[lc].map((n) => ({
|
|
111
|
+
idx: n,
|
|
112
|
+
score: this.sim(nbrNode.vec, this.nodes[n].vec),
|
|
113
|
+
}));
|
|
114
|
+
rescored.sort((x, y) => y.score - x.score);
|
|
115
|
+
nbrNode.neighbors[lc] = rescored.slice(0, cap).map((r) => r.idx);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
ep = candidates.length > 0 ? candidates[0].idx : ep;
|
|
119
|
+
}
|
|
120
|
+
if (level > this.maxLevel) {
|
|
121
|
+
this.maxLevel = level;
|
|
122
|
+
this.entryPoint = idx;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Greedy single-nearest walk at one layer. Returns the local optimum node
|
|
127
|
+
* index and the exact number of distance computations it performed (so the
|
|
128
|
+
* caller can report `stats.visited` as a true distance-call count).
|
|
129
|
+
*/
|
|
130
|
+
greedyDescend(query, entry, level) {
|
|
131
|
+
let current = entry;
|
|
132
|
+
let currentScore = this.sim(query, this.nodes[current].vec);
|
|
133
|
+
let distanceComputations = 1;
|
|
134
|
+
let improved = true;
|
|
135
|
+
while (improved) {
|
|
136
|
+
improved = false;
|
|
137
|
+
for (const nbr of this.nodes[current].neighbors[level] ?? []) {
|
|
138
|
+
const s = this.sim(query, this.nodes[nbr].vec);
|
|
139
|
+
distanceComputations++;
|
|
140
|
+
if (s > currentScore) {
|
|
141
|
+
currentScore = s;
|
|
142
|
+
current = nbr;
|
|
143
|
+
improved = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { node: current, distanceComputations };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* ef-wide best-first beam search at one layer from the given entry points.
|
|
151
|
+
* Returns the ef best candidates (sorted nearest-first) and the count of
|
|
152
|
+
* distance computations performed (the pruning instrumentation).
|
|
153
|
+
*/
|
|
154
|
+
searchLayer(query, entries, ef, level) {
|
|
155
|
+
const visited = new Set();
|
|
156
|
+
// Max-heap-ish behavior via sorted arrays; corpora at this layer are small.
|
|
157
|
+
const candidateHeap = []; // frontier, best-first
|
|
158
|
+
const resultHeap = []; // ef best so far, worst-first
|
|
159
|
+
let distanceComputations = 0;
|
|
160
|
+
for (const e of entries) {
|
|
161
|
+
if (visited.has(e))
|
|
162
|
+
continue;
|
|
163
|
+
visited.add(e);
|
|
164
|
+
const s = this.sim(query, this.nodes[e].vec);
|
|
165
|
+
distanceComputations++;
|
|
166
|
+
candidateHeap.push({ idx: e, score: s });
|
|
167
|
+
resultHeap.push({ idx: e, score: s });
|
|
168
|
+
}
|
|
169
|
+
candidateHeap.sort((a, b) => b.score - a.score);
|
|
170
|
+
resultHeap.sort((a, b) => a.score - b.score);
|
|
171
|
+
while (candidateHeap.length > 0) {
|
|
172
|
+
const c = candidateHeap.shift();
|
|
173
|
+
if (!c)
|
|
174
|
+
break;
|
|
175
|
+
const worst = resultHeap[0];
|
|
176
|
+
if (worst && c.score < worst.score && resultHeap.length >= ef)
|
|
177
|
+
break;
|
|
178
|
+
for (const nbr of this.nodes[c.idx].neighbors[level] ?? []) {
|
|
179
|
+
if (visited.has(nbr))
|
|
180
|
+
continue;
|
|
181
|
+
visited.add(nbr);
|
|
182
|
+
const s = this.sim(query, this.nodes[nbr].vec);
|
|
183
|
+
distanceComputations++;
|
|
184
|
+
const currentWorst = resultHeap[0];
|
|
185
|
+
if (resultHeap.length < ef || (currentWorst && s > currentWorst.score)) {
|
|
186
|
+
insertSorted(candidateHeap, { idx: nbr, score: s }, true);
|
|
187
|
+
insertSorted(resultHeap, { idx: nbr, score: s }, false);
|
|
188
|
+
if (resultHeap.length > ef)
|
|
189
|
+
resultHeap.shift();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const candidates = resultHeap.slice().sort((a, b) => b.score - a.score);
|
|
194
|
+
return { candidates, visited: distanceComputations };
|
|
195
|
+
}
|
|
196
|
+
selectNeighbors(candidates, M) {
|
|
197
|
+
return candidates
|
|
198
|
+
.slice()
|
|
199
|
+
.sort((a, b) => b.score - a.score)
|
|
200
|
+
.slice(0, M)
|
|
201
|
+
.map((c) => c.idx);
|
|
202
|
+
}
|
|
203
|
+
search(queryVec, efSearch, k) {
|
|
204
|
+
if (this.entryPoint === -1)
|
|
205
|
+
return { results: [], stats: { visited: 0 } };
|
|
206
|
+
const ef = Math.max(efSearch, k);
|
|
207
|
+
let ep = this.entryPoint;
|
|
208
|
+
let visited = 0;
|
|
209
|
+
for (let lc = this.maxLevel; lc > 0; lc--) {
|
|
210
|
+
// Upper-layer greedy-walk distance calls count toward the search budget.
|
|
211
|
+
const descent = this.greedyDescend(queryVec, ep, lc);
|
|
212
|
+
ep = descent.node;
|
|
213
|
+
visited += descent.distanceComputations;
|
|
214
|
+
}
|
|
215
|
+
const { candidates, visited: layer0Visited } = this.searchLayer(queryVec, [ep], ef, 0);
|
|
216
|
+
visited += layer0Visited;
|
|
217
|
+
const results = candidates
|
|
218
|
+
.slice(0, k)
|
|
219
|
+
.map((c) => ({ id: this.nodes[c.idx].id, score: c.score }));
|
|
220
|
+
return { results, stats: { visited } };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Inserts into a score-sorted array; `bestFirst` = descending, else ascending. */
|
|
224
|
+
function insertSorted(arr, item, bestFirst) {
|
|
225
|
+
let lo = 0;
|
|
226
|
+
let hi = arr.length;
|
|
227
|
+
while (lo < hi) {
|
|
228
|
+
const mid = (lo + hi) >> 1;
|
|
229
|
+
const cmp = bestFirst ? arr[mid].score > item.score : arr[mid].score < item.score;
|
|
230
|
+
if (cmp)
|
|
231
|
+
lo = mid + 1;
|
|
232
|
+
else
|
|
233
|
+
hi = mid;
|
|
234
|
+
}
|
|
235
|
+
arr.splice(lo, 0, item);
|
|
236
|
+
}
|
|
237
|
+
/** Builds an in-memory HNSW index over the given embedding rows. */
|
|
238
|
+
export function buildHnswIndex(rows, params = {}) {
|
|
239
|
+
const index = new Hnsw(params);
|
|
240
|
+
for (const row of rows)
|
|
241
|
+
index.insert(row.id, row.vec);
|
|
242
|
+
return index;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Whether the ANN path is engaged. OPT-IN ONLY: the exact scan is the default at
|
|
246
|
+
* every corpus size until the >=95% top-10 recall gate is measured-and-cleared
|
|
247
|
+
* at a corpus size where the path would actually engage (see module header).
|
|
248
|
+
*/
|
|
249
|
+
export function annEnabled() {
|
|
250
|
+
return process.env.KNODIN_ANN === "1";
|
|
251
|
+
}
|
|
252
|
+
/** efSearch beam width, overridable via KNODIN_ANN_EF (a benchmark knob). */
|
|
253
|
+
export function annEfSearch() {
|
|
254
|
+
const raw = process.env.KNODIN_ANN_EF;
|
|
255
|
+
if (raw) {
|
|
256
|
+
const n = Number.parseInt(raw, 10);
|
|
257
|
+
if (Number.isFinite(n) && n > 0)
|
|
258
|
+
return n;
|
|
259
|
+
}
|
|
260
|
+
return DEFAULT_EF_SEARCH;
|
|
261
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-only by design (ADR 003, "Future Considerations" section,
|
|
3
|
+
* docs/adr/003-local-semantic-search.md): a cloud embedding provider
|
|
4
|
+
* (OpenAI/Google/MiniMax, as some competitors offer) was evaluated and
|
|
5
|
+
* deliberately deferred, not merely unbuilt. knodin's core positioning is
|
|
6
|
+
* zero-auth/local-first/no-egress — sending symbol text to a cloud API for
|
|
7
|
+
* embedding would break that guarantee for every indexed repo, not just tune
|
|
8
|
+
* a knob. If ever added, it must be opt-in with `local` staying the default,
|
|
9
|
+
* and reads as a natural pro-tier feature precisely because enabling it is a
|
|
10
|
+
* deliberate trade against this file's local/no-egress guarantee.
|
|
11
|
+
*/
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
let embedderPromise = null;
|
|
15
|
+
let embedderOverride = null;
|
|
16
|
+
const embedderProgressListeners = new Set();
|
|
17
|
+
let embedderReady = false;
|
|
18
|
+
const EMBEDDING_DIMENSIONS = 384;
|
|
19
|
+
const DEFAULT_MODEL_REMOTE_HOST = "https://huggingface.co/";
|
|
20
|
+
/**
|
|
21
|
+
* One machine-level model cache shared by every repository and every installed
|
|
22
|
+
* knodin version. An explicit override makes managed/offline environments and
|
|
23
|
+
* the clean-package acceptance gate deterministic.
|
|
24
|
+
*/
|
|
25
|
+
export function getModelCacheDirectory(options = {}) {
|
|
26
|
+
const environment = options.env ?? process.env;
|
|
27
|
+
const override = environment.KNODIN_MODEL_CACHE?.trim();
|
|
28
|
+
if (override)
|
|
29
|
+
return path.resolve(override);
|
|
30
|
+
const platform = options.platform ?? process.platform;
|
|
31
|
+
const home = options.home ?? os.homedir();
|
|
32
|
+
if (platform === "win32") {
|
|
33
|
+
return path.join(environment.LOCALAPPDATA || path.join(home, "AppData", "Local"), "knodin", "models");
|
|
34
|
+
}
|
|
35
|
+
if (platform === "darwin")
|
|
36
|
+
return path.join(home, "Library", "Caches", "knodin", "models");
|
|
37
|
+
return path.join(environment.XDG_CACHE_HOME || path.join(home, ".cache"), "knodin", "models");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Remote model origin used only when the shared machine cache is cold.
|
|
41
|
+
* The default remains the public Hugging Face Hub; managed environments may
|
|
42
|
+
* point at an approved mirror without changing where model files are cached.
|
|
43
|
+
*/
|
|
44
|
+
export function getModelRemoteHost(environment = process.env) {
|
|
45
|
+
const configured = environment.KNODIN_MODEL_HOST?.trim() || DEFAULT_MODEL_REMOTE_HOST;
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = new URL(configured);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error(`KNODIN_MODEL_HOST must be an absolute HTTP(S) URL: ${configured}`);
|
|
52
|
+
}
|
|
53
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
54
|
+
throw new Error(`KNODIN_MODEL_HOST must use HTTP(S): ${configured}`);
|
|
55
|
+
}
|
|
56
|
+
parsed.hash = "";
|
|
57
|
+
parsed.search = "";
|
|
58
|
+
return parsed.href.endsWith("/") ? parsed.href : `${parsed.href}/`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Number of times the real ONNX model pipeline has actually been loaded in this
|
|
62
|
+
* process. Observability only — it never changes embedding behavior. The test
|
|
63
|
+
* suite uses it to guarantee that non-model suites keep the deterministic test
|
|
64
|
+
* embedder installed and never fall through to native model initialization
|
|
65
|
+
* (which, repeated across a long-lived worker, is what C50 removes).
|
|
66
|
+
*/
|
|
67
|
+
let realEmbedderLoadCount = 0;
|
|
68
|
+
/** Returns how many times the real ONNX pipeline has loaded in this process. */
|
|
69
|
+
export function getRealEmbedderLoadCount() {
|
|
70
|
+
return realEmbedderLoadCount;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Overrides the embedding pipeline (e.g. with a fast mock in tests). Pass `null`
|
|
74
|
+
* to clear the override and fall back to the real lazily-loaded model.
|
|
75
|
+
*/
|
|
76
|
+
export function setEmbedder(pipeline) {
|
|
77
|
+
embedderOverride = pipeline;
|
|
78
|
+
embedderPromise = null;
|
|
79
|
+
embedderReady = pipeline !== null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Release the native ONNX model session if one was loaded, freeing its resident
|
|
83
|
+
* memory. Best-effort and test-facing: the single long-lived test worker
|
|
84
|
+
* (isolate:false) otherwise keeps the ~model resident for the whole run, bloating
|
|
85
|
+
* the process so later subprocess-spawning tests fork slowly. The dedicated
|
|
86
|
+
* embedding spec calls this in teardown once its real-model assertions are done.
|
|
87
|
+
* A no-op when only a mock embedder (or nothing) has been used.
|
|
88
|
+
*/
|
|
89
|
+
export async function disposeEmbedder() {
|
|
90
|
+
const pending = embedderPromise;
|
|
91
|
+
embedderPromise = null;
|
|
92
|
+
embedderReady = false;
|
|
93
|
+
if (!pending)
|
|
94
|
+
return;
|
|
95
|
+
try {
|
|
96
|
+
const pipe = (await pending);
|
|
97
|
+
if (typeof pipe.dispose === "function")
|
|
98
|
+
await pipe.dispose();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* best-effort native teardown; never fail a test on dispose */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Lazy-loads and caches the local embedding pipeline.
|
|
106
|
+
* Uses a lightweight, high-performance 384-dimension model (all-MiniLM-L6-v2)
|
|
107
|
+
* running in-process via ONNX Runtime Web.
|
|
108
|
+
* Uses dynamic imports to keep CLI and MCP server startup latency near-zero.
|
|
109
|
+
*/
|
|
110
|
+
export function getEmbedder(onProgress) {
|
|
111
|
+
if (embedderOverride) {
|
|
112
|
+
onProgress?.({ status: "ready", name: "local test embedder" });
|
|
113
|
+
return Promise.resolve(embedderOverride);
|
|
114
|
+
}
|
|
115
|
+
if (onProgress && embedderReady)
|
|
116
|
+
onProgress({ status: "ready", name: "Xenova/all-MiniLM-L6-v2" });
|
|
117
|
+
else if (onProgress)
|
|
118
|
+
embedderProgressListeners.add(onProgress);
|
|
119
|
+
if (!embedderPromise) {
|
|
120
|
+
embedderPromise = (async () => {
|
|
121
|
+
try {
|
|
122
|
+
const { env, pipeline } = await import("@huggingface/transformers");
|
|
123
|
+
env.cacheDir = getModelCacheDirectory();
|
|
124
|
+
env.remoteHost = getModelRemoteHost();
|
|
125
|
+
const notify = (event) => {
|
|
126
|
+
for (const listener of embedderProgressListeners) {
|
|
127
|
+
try {
|
|
128
|
+
listener(event);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* Observability must not alter model initialization. */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
notify({ status: "loading", name: "Xenova/all-MiniLM-L6-v2" });
|
|
136
|
+
const pipe = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2", {
|
|
137
|
+
progress_callback: notify,
|
|
138
|
+
});
|
|
139
|
+
realEmbedderLoadCount++;
|
|
140
|
+
embedderReady = true;
|
|
141
|
+
notify({ status: "ready", name: "Xenova/all-MiniLM-L6-v2" });
|
|
142
|
+
embedderProgressListeners.clear();
|
|
143
|
+
return pipe;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
embedderPromise = null; // Reset promise so we can retry on next call
|
|
147
|
+
embedderProgressListeners.clear();
|
|
148
|
+
console.error("Failed to initialize Hugging Face embedding pipeline:", error);
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
}
|
|
153
|
+
return embedderPromise;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Generates a normalized Float32Array embedding (384 dimensions) for a given text block.
|
|
157
|
+
*/
|
|
158
|
+
export async function generateEmbeddings(texts, onModelProgress) {
|
|
159
|
+
if (texts.length === 0)
|
|
160
|
+
return [];
|
|
161
|
+
const extractor = await getEmbedder(onModelProgress);
|
|
162
|
+
const result = await extractor([...texts], { pooling: "mean", normalize: true });
|
|
163
|
+
const data = result.data instanceof Float32Array ? result.data : Float32Array.from(result.data);
|
|
164
|
+
const dims = result.dims;
|
|
165
|
+
if (dims?.length !== 2 || dims[0] !== texts.length || dims[1] !== EMBEDDING_DIMENSIONS) {
|
|
166
|
+
throw new Error(`Unexpected batched embedding shape: expected [${texts.length}, ${EMBEDDING_DIMENSIONS}], got ${JSON.stringify(dims)}`);
|
|
167
|
+
}
|
|
168
|
+
const dimension = dims[1];
|
|
169
|
+
if (data.length !== texts.length * dimension) {
|
|
170
|
+
throw new Error(`Unexpected batched embedding data length: expected ${texts.length * dimension}, got ${data.length}`);
|
|
171
|
+
}
|
|
172
|
+
return texts.map((_, index) => data.slice(index * dimension, (index + 1) * dimension));
|
|
173
|
+
}
|
|
174
|
+
/** Compatibility wrapper used by query-time search. */
|
|
175
|
+
export async function generateEmbedding(text) {
|
|
176
|
+
return (await generateEmbeddings([text]))[0];
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Computes the cosine similarity between two normalized Float32Arrays.
|
|
180
|
+
* Since the vectors are pre-normalized, the cosine similarity simplifies
|
|
181
|
+
* to the mathematical dot product: sum(u_i * v_i).
|
|
182
|
+
*/
|
|
183
|
+
export function computeSimilarity(queryVec, targetVec) {
|
|
184
|
+
if (queryVec.length !== targetVec.length) {
|
|
185
|
+
throw new Error(`Embedding dimension mismatch: query has ${queryVec.length} dims, target has ${targetVec.length}`);
|
|
186
|
+
}
|
|
187
|
+
let dotProduct = 0;
|
|
188
|
+
const len = queryVec.length;
|
|
189
|
+
for (let i = 0; i < len; i++) {
|
|
190
|
+
dotProduct += queryVec[i] * targetVec[i];
|
|
191
|
+
}
|
|
192
|
+
return dotProduct;
|
|
193
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isIndexablePath } from "./prune.js";
|
|
4
|
+
/**
|
|
5
|
+
* Byte-order path comparison. Deliberately NOT `localeCompare`: index order must
|
|
6
|
+
* be identical on every machine, and locale collation is not.
|
|
7
|
+
*/
|
|
8
|
+
export function comparePaths(left, right) {
|
|
9
|
+
if (left < right)
|
|
10
|
+
return -1;
|
|
11
|
+
return left > right ? 1 : 0;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Deterministically enumerate regular files beneath a repository without
|
|
15
|
+
* following symlinks. Directory pruning happens before descent so dependency,
|
|
16
|
+
* build, VCS, worktree, and local graph state never incur a recursive scan.
|
|
17
|
+
*/
|
|
18
|
+
export function walkRepoFiles(repoPath, options = {}) {
|
|
19
|
+
const root = path.resolve(repoPath);
|
|
20
|
+
const pending = [{ absolute: root, relative: "" }];
|
|
21
|
+
const files = [];
|
|
22
|
+
while (pending.length > 0) {
|
|
23
|
+
const directory = pending.pop();
|
|
24
|
+
if (!directory)
|
|
25
|
+
break;
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = fs.readdirSync(directory.absolute, { withFileTypes: true });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
entries.sort((left, right) => comparePaths(left.name, right.name));
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const relative = directory.relative ? `${directory.relative}/${entry.name}` : entry.name;
|
|
36
|
+
if (!isIndexablePath(relative))
|
|
37
|
+
continue;
|
|
38
|
+
const absolute = path.join(directory.absolute, entry.name);
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
pending.push({ absolute, relative });
|
|
41
|
+
}
|
|
42
|
+
else if (entry.isFile() && (options.accept?.(relative) ?? true)) {
|
|
43
|
+
files.push(relative);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
files.sort(comparePaths);
|
|
48
|
+
return options.limit === undefined ? files : files.slice(0, Math.max(0, options.limit));
|
|
49
|
+
}
|