@maxgfr/codeindex 2.8.1 → 2.10.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.
- package/README.md +39 -0
- package/docs/MIGRATION.md +38 -0
- package/package.json +1 -1
- package/scripts/engine.d.mts +59 -2
- package/scripts/engine.mjs +560 -32
- package/src/bm25.ts +118 -9
- package/src/embed/encode.ts +133 -0
- package/src/embed/endpoint.ts +55 -0
- package/src/embed/index.ts +125 -0
- package/src/embed/model.ts +117 -0
- package/src/embed/search.ts +90 -0
- package/src/engine-cli.ts +111 -5
- package/src/engine.ts +21 -0
- package/src/mcp.ts +48 -5
- package/src/types.ts +1 -1
package/src/bm25.ts
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
// the query reuses util keywords, so query and haystack always tokenize alike.
|
|
9
9
|
// Deterministic: files are scored in scan order (sorted by rel), scores are
|
|
10
10
|
// fixed to 4 decimal places, and ties break by path.
|
|
11
|
+
//
|
|
12
|
+
// Trigram fuzzy fallback (v2.9.0): a query term that matches NOTHING in the
|
|
13
|
+
// corpus (document frequency == 0 — checked STRICTLY, so any term that
|
|
14
|
+
// already matches anywhere is never touched) is expanded against the corpus
|
|
15
|
+
// vocabulary via character-trigram Dice similarity (threshold 0.6, top-3
|
|
16
|
+
// candidates, deterministic tie-break). This keeps every currently-matching
|
|
17
|
+
// query byte-identical: the expansion only ever engages on terms that would
|
|
18
|
+
// otherwise contribute nothing.
|
|
11
19
|
import type { RepoScan } from "./scan.js";
|
|
12
20
|
import { foldText, keywords } from "./util.js";
|
|
13
21
|
import { byStr } from "./sort.js";
|
|
@@ -16,10 +24,17 @@ const K1 = 1.2;
|
|
|
16
24
|
const B = 0.75;
|
|
17
25
|
const DEFAULT_LIMIT = 20;
|
|
18
26
|
const TOP_SYMBOLS = 5;
|
|
27
|
+
const FUZZY_DICE_THRESHOLD = 0.6;
|
|
28
|
+
const FUZZY_CAP = 3;
|
|
19
29
|
|
|
20
30
|
export interface SearchOptions {
|
|
21
31
|
// Maximum results returned (default 20).
|
|
22
32
|
limit?: number;
|
|
33
|
+
// Trigram fuzzy fallback for query terms with zero document frequency
|
|
34
|
+
// (default true). Safe as an always-on default: the df==0 gate means it
|
|
35
|
+
// only ever engages on terms that would otherwise match nothing, so a
|
|
36
|
+
// query where every term already hits is completely unaffected.
|
|
37
|
+
fuzzy?: boolean;
|
|
23
38
|
}
|
|
24
39
|
|
|
25
40
|
export interface SearchResult {
|
|
@@ -27,6 +42,10 @@ export interface SearchResult {
|
|
|
27
42
|
score: number; // BM25 score, fixed to 4 decimal places
|
|
28
43
|
matchedTerms: string[]; // query tokens present in this file's document, sorted
|
|
29
44
|
topSymbols: string[]; // symbols whose name matches the most query tokens (cap 5)
|
|
45
|
+
// Query terms (df==0) resolved via trigram fuzzy fallback that contributed
|
|
46
|
+
// to this result, sorted. Present only when >=1 term used the fallback —
|
|
47
|
+
// purely additive, never present for an all-exact-match result.
|
|
48
|
+
fuzzyTerms?: string[];
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
// Split an identifier/phrase into lowercase, diacritic-folded subtokens:
|
|
@@ -87,6 +106,39 @@ function buildDocs(scan: RepoScan): Doc[] {
|
|
|
87
106
|
return docs;
|
|
88
107
|
}
|
|
89
108
|
|
|
109
|
+
// Character trigrams of a token, padded with two boundary sentinels on each
|
|
110
|
+
// side (pg_trgm-style: "^^t…m$$") so short prefix/suffix runs still produce
|
|
111
|
+
// shared grams. Deduplicated into a Set — a repeated gram doesn't inflate
|
|
112
|
+
// Dice similarity.
|
|
113
|
+
export function charTrigrams(term: string): Set<string> {
|
|
114
|
+
const padded = `^^${term}$$`;
|
|
115
|
+
const grams = new Set<string>();
|
|
116
|
+
for (let i = 0; i + 3 <= padded.length; i++) grams.add(padded.slice(i, i + 3));
|
|
117
|
+
return grams;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Dice coefficient between two trigram sets: 2|A∩B| / (|A|+|B|). 0 when
|
|
121
|
+
// either side is empty (no divide-by-zero).
|
|
122
|
+
export function diceCoefficient(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
|
|
123
|
+
if (!a.size || !b.size) return 0;
|
|
124
|
+
let inter = 0;
|
|
125
|
+
for (const g of a) if (b.has(g)) inter++;
|
|
126
|
+
return (2 * inter) / (a.size + b.size);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Trigram index of the corpus vocabulary: every distinct doc token mapped to
|
|
130
|
+
// its trigram set. Built LAZILY by searchIndex — only when >=1 query term has
|
|
131
|
+
// df==0 — so a fully-matched query never pays this cost.
|
|
132
|
+
function buildTrigramIndex(docs: Doc[]): Map<string, Set<string>> {
|
|
133
|
+
const index = new Map<string, Set<string>>();
|
|
134
|
+
for (const d of docs) {
|
|
135
|
+
for (const term of d.tf.keys()) {
|
|
136
|
+
if (!index.has(term)) index.set(term, charTrigrams(term));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return index;
|
|
140
|
+
}
|
|
141
|
+
|
|
90
142
|
// Rank the scanned files against a natural-language (or identifier) query.
|
|
91
143
|
// Pure and deterministic: same scan + query → the same results, byte-for-byte.
|
|
92
144
|
export function searchIndex(scan: RepoScan, query: string, opts: SearchOptions = {}): SearchResult[] {
|
|
@@ -118,36 +170,93 @@ export function searchIndex(scan: RepoScan, query: string, opts: SearchOptions =
|
|
|
118
170
|
df.set(t, count);
|
|
119
171
|
}
|
|
120
172
|
|
|
173
|
+
// Fuzzy fallback: STRICT df==0 gate — a term that matches anywhere, even
|
|
174
|
+
// once, is never expanded. The trigram index of the corpus vocabulary is
|
|
175
|
+
// built lazily, only when at least one term needs it, so a fully-matched
|
|
176
|
+
// query (the common case) pays zero extra cost and stays byte-identical.
|
|
177
|
+
const fuzzyEnabled = opts.fuzzy ?? true;
|
|
178
|
+
const fuzzyCandidates = new Map<string, { term: string; dice: number }[]>();
|
|
179
|
+
if (fuzzyEnabled) {
|
|
180
|
+
const unmatched = terms.filter((t) => df.get(t) === 0);
|
|
181
|
+
if (unmatched.length) {
|
|
182
|
+
const trigramIndex = buildTrigramIndex(docs);
|
|
183
|
+
for (const t of unmatched) {
|
|
184
|
+
const grams = charTrigrams(t);
|
|
185
|
+
const candidates: { term: string; dice: number }[] = [];
|
|
186
|
+
for (const [vocabTerm, vocabGrams] of trigramIndex) {
|
|
187
|
+
const dice = diceCoefficient(grams, vocabGrams);
|
|
188
|
+
if (dice >= FUZZY_DICE_THRESHOLD) candidates.push({ term: vocabTerm, dice });
|
|
189
|
+
}
|
|
190
|
+
// Deterministic: similarity desc, then vocab term asc.
|
|
191
|
+
candidates.sort((a, b) => b.dice - a.dice || byStr(a.term, b.term));
|
|
192
|
+
fuzzyCandidates.set(t, candidates.slice(0, FUZZY_CAP));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// df cache for expanded vocab terms (distinct from query-term df above).
|
|
197
|
+
const vocabDf = new Map<string, number>();
|
|
198
|
+
const dfOfVocabTerm = (term: string): number => {
|
|
199
|
+
const known = df.get(term) ?? vocabDf.get(term);
|
|
200
|
+
if (known !== undefined) return known;
|
|
201
|
+
let count = 0;
|
|
202
|
+
for (const d of docs) if (d.tf.has(term)) count++;
|
|
203
|
+
vocabDf.set(term, count);
|
|
204
|
+
return count;
|
|
205
|
+
};
|
|
206
|
+
|
|
121
207
|
const results: SearchResult[] = [];
|
|
122
208
|
for (const d of docs) {
|
|
123
209
|
let score = 0;
|
|
124
210
|
const matched: string[] = [];
|
|
211
|
+
const symbolTerms = new Set<string>(); // matched ∪ fuzzy-expanded vocab terms, for topSymbols ranking
|
|
212
|
+
const fuzzyHit = new Set<string>(); // original query terms resolved via fuzzy fallback, for this doc
|
|
125
213
|
for (const t of terms) {
|
|
126
214
|
const tf = d.tf.get(t);
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
215
|
+
if (tf) {
|
|
216
|
+
matched.push(t);
|
|
217
|
+
symbolTerms.add(t);
|
|
218
|
+
const idf = Math.log(1 + (n - df.get(t)! + 0.5) / (df.get(t)! + 0.5));
|
|
219
|
+
score += (idf * (tf * (K1 + 1))) / (tf + K1 * (1 - B + (B * d.len) / avgLen));
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// Only ever reached for a term with df==0 (or absent from THIS doc but
|
|
223
|
+
// matched elsewhere — fuzzyCandidates has no entry for those, so the
|
|
224
|
+
// lookup below is a no-op and behavior is identical to before v2.9.0).
|
|
225
|
+
const candidates = fuzzyCandidates.get(t);
|
|
226
|
+
if (!candidates) continue;
|
|
227
|
+
for (const cand of candidates) {
|
|
228
|
+
const ctf = d.tf.get(cand.term);
|
|
229
|
+
if (!ctf) continue;
|
|
230
|
+
const cdf = dfOfVocabTerm(cand.term);
|
|
231
|
+
const idf = Math.log(1 + (n - cdf + 0.5) / (cdf + 0.5));
|
|
232
|
+
const contribution = (idf * (ctf * (K1 + 1))) / (ctf + K1 * (1 - B + (B * d.len) / avgLen));
|
|
233
|
+
score += contribution * cand.dice; // near-miss always scores below an exact hit (dice < 1)
|
|
234
|
+
symbolTerms.add(cand.term);
|
|
235
|
+
fuzzyHit.add(t);
|
|
236
|
+
}
|
|
131
237
|
}
|
|
132
|
-
if (!matched.length) continue;
|
|
238
|
+
if (!matched.length && !fuzzyHit.size) continue;
|
|
133
239
|
|
|
134
|
-
// Symbols ranked by how many query tokens
|
|
240
|
+
// Symbols ranked by how many query tokens (exact or fuzzy-expanded) their
|
|
241
|
+
// name carries, then by name.
|
|
135
242
|
const scored = d.symbols
|
|
136
243
|
.map((name) => {
|
|
137
244
|
const toks = new Set(subtokens(name));
|
|
138
245
|
let hits = 0;
|
|
139
|
-
for (const t of
|
|
246
|
+
for (const t of symbolTerms) if (toks.has(t)) hits++;
|
|
140
247
|
return { name, hits };
|
|
141
248
|
})
|
|
142
249
|
.filter((s) => s.hits > 0)
|
|
143
250
|
.sort((a, b) => b.hits - a.hits || byStr(a.name, b.name));
|
|
144
251
|
|
|
145
|
-
|
|
252
|
+
const result: SearchResult = {
|
|
146
253
|
file: d.file,
|
|
147
254
|
score: Number(score.toFixed(4)),
|
|
148
255
|
matchedTerms: matched.sort(byStr),
|
|
149
256
|
topSymbols: scored.slice(0, TOP_SYMBOLS).map((s) => s.name),
|
|
150
|
-
}
|
|
257
|
+
};
|
|
258
|
+
if (fuzzyHit.size) result.fuzzyTerms = [...fuzzyHit].sort(byStr);
|
|
259
|
+
results.push(result);
|
|
151
260
|
}
|
|
152
261
|
|
|
153
262
|
// Rounded score first (so 4-dp ties resolve stably), then path.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { foldText } from "../util.js";
|
|
2
|
+
import type { StaticEmbedModel } from "./model.js";
|
|
3
|
+
|
|
4
|
+
// The pure-JS deterministic encoder — the reason this whole tier can produce
|
|
5
|
+
// goldens. Every step is chosen to be byte-identical on any platform:
|
|
6
|
+
// 1. tokenize : foldText (NFKD, strip combining marks) + camelCase/ACRONYM
|
|
7
|
+
// split + lowercase + split on non-alphanumeric runs.
|
|
8
|
+
// 2. wordpiece : greedy longest-match against the model vocab, with BERT-style
|
|
9
|
+
// "##" continuation pieces; an unsplittable word → the UNK row
|
|
10
|
+
// (or nothing when the model has no UNK).
|
|
11
|
+
// 3. gather : look up each token's row (double precision).
|
|
12
|
+
// 4. mean-pool : sum rows in FIXED token order, divide by token count. IEEE-754
|
|
13
|
+
// double add/div is deterministic for a fixed evaluation order.
|
|
14
|
+
// 5. L2-norm : divide by sqrt(Σ v²) — sqrt is correctly-rounded per IEEE-754,
|
|
15
|
+
// so identical across engines.
|
|
16
|
+
// 6. quantize : multiply the unit vector by 127, round-half-to-EVEN (NOT
|
|
17
|
+
// Math.round, which is half-up), clamp to [-127, 127] → int8.
|
|
18
|
+
// The fixed 1/127 scale means ranking is a PURE INTEGER dot product (no per-vector
|
|
19
|
+
// float scale to store or multiply): for a fixed query every corpus vector shares
|
|
20
|
+
// the same scale, so the integer dot Σ q·c is monotonic with the cosine estimate.
|
|
21
|
+
|
|
22
|
+
const QUANT = 127;
|
|
23
|
+
|
|
24
|
+
// Split raw text into lowercase, diacritic-folded alphanumeric words. camelCase
|
|
25
|
+
// and ACRONYMWord boundaries become spaces FIRST (so "verifyAuthToken" exposes
|
|
26
|
+
// "verify","auth","token" as separate words to wordpiece), then any
|
|
27
|
+
// non-alphanumeric run splits. Mirrors the boundary rules bm25 `subtokens` uses,
|
|
28
|
+
// keeping the two search tiers tokenizing code identifiers alike.
|
|
29
|
+
export function basicTokenize(text: string): string[] {
|
|
30
|
+
const spaced = foldText(text)
|
|
31
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
32
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
|
|
33
|
+
const out: string[] = [];
|
|
34
|
+
for (const part of spaced.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
35
|
+
if (part) out.push(part);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Greedy longest-match wordpiece for ONE word. The first piece is looked up as
|
|
41
|
+
// written; every subsequent piece is looked up with a "##" prefix. If no piece
|
|
42
|
+
// matches at some position the whole word is unresolvable → [unkId] (or [] when
|
|
43
|
+
// the model has no UNK row). Deterministic: longest-match is a fixed scan.
|
|
44
|
+
export function wordpiece(word: string, model: StaticEmbedModel): number[] {
|
|
45
|
+
if (!word) return [];
|
|
46
|
+
const ids: number[] = [];
|
|
47
|
+
let start = 0;
|
|
48
|
+
const n = word.length;
|
|
49
|
+
while (start < n) {
|
|
50
|
+
let end = n;
|
|
51
|
+
let match = -1;
|
|
52
|
+
while (end > start) {
|
|
53
|
+
const piece = start === 0 ? word.slice(start, end) : "##" + word.slice(start, end);
|
|
54
|
+
const id = model.vocab.get(piece);
|
|
55
|
+
if (id !== undefined) {
|
|
56
|
+
match = id;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
end--;
|
|
60
|
+
}
|
|
61
|
+
if (match === -1) return model.unkId >= 0 ? [model.unkId] : []; // whole word is OOV
|
|
62
|
+
ids.push(match);
|
|
63
|
+
start = end;
|
|
64
|
+
}
|
|
65
|
+
return ids;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Full token-id sequence for a text: tokenize → wordpiece each word, concatenated
|
|
69
|
+
// in order. The order is load-bearing for determinism, but mean-pooling is
|
|
70
|
+
// order-independent anyway; the fixed order simply makes reasoning trivial.
|
|
71
|
+
export function tokenize(text: string, model: StaticEmbedModel): number[] {
|
|
72
|
+
const ids: number[] = [];
|
|
73
|
+
for (const word of basicTokenize(text)) {
|
|
74
|
+
for (const id of wordpiece(word, model)) ids.push(id);
|
|
75
|
+
}
|
|
76
|
+
return ids;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Round half to EVEN (banker's rounding). JS `Math.round` rounds half toward
|
|
80
|
+
// +∞, which is not symmetric and would bias quantization; half-to-even is the
|
|
81
|
+
// IEEE-754 default and keeps quantize(x) === -quantize(-x).
|
|
82
|
+
export function roundHalfToEven(x: number): number {
|
|
83
|
+
const f = Math.floor(x);
|
|
84
|
+
const diff = x - f;
|
|
85
|
+
if (diff < 0.5) return f;
|
|
86
|
+
if (diff > 0.5) return f + 1;
|
|
87
|
+
return f % 2 === 0 ? f : f + 1; // exactly .5 → nearest even
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Encode a text to an int8 unit-ish vector. Empty / all-OOV / zero-norm inputs
|
|
91
|
+
// yield an all-zero vector (dot-products against it are 0 — it simply ranks
|
|
92
|
+
// last), never NaN. Pure and deterministic: same model + text → same bytes.
|
|
93
|
+
export function encode(model: StaticEmbedModel, text: string): Int8Array {
|
|
94
|
+
const { dim, weights } = model;
|
|
95
|
+
const out = new Int8Array(dim);
|
|
96
|
+
const ids = tokenize(text, model);
|
|
97
|
+
if (ids.length === 0) return out;
|
|
98
|
+
|
|
99
|
+
// mean-pool in double precision, fixed order.
|
|
100
|
+
const pooled = new Float64Array(dim);
|
|
101
|
+
for (const id of ids) {
|
|
102
|
+
const base = id * dim;
|
|
103
|
+
for (let d = 0; d < dim; d++) pooled[d]! += weights[base + d]!;
|
|
104
|
+
}
|
|
105
|
+
const inv = 1 / ids.length;
|
|
106
|
+
for (let d = 0; d < dim; d++) pooled[d]! *= inv;
|
|
107
|
+
|
|
108
|
+
// L2-normalize.
|
|
109
|
+
let sumsq = 0;
|
|
110
|
+
for (let d = 0; d < dim; d++) sumsq += pooled[d]! * pooled[d]!;
|
|
111
|
+
const norm = Math.sqrt(sumsq);
|
|
112
|
+
if (norm === 0) return out; // zero vector — nothing to rank on
|
|
113
|
+
|
|
114
|
+
// quantize to int8 at the fixed 1/127 scale, round-half-to-even, clamp.
|
|
115
|
+
for (let d = 0; d < dim; d++) {
|
|
116
|
+
let q = roundHalfToEven((pooled[d]! / norm) * QUANT);
|
|
117
|
+
if (q > QUANT) q = QUANT;
|
|
118
|
+
else if (q < -QUANT) q = -QUANT;
|
|
119
|
+
out[d] = q;
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Integer dot product of two int8 vectors (the ranking primitive). Widened to a
|
|
125
|
+
// JS number (double), but every operand and partial sum is an exact integer
|
|
126
|
+
// (max |Σ| for dim 512 ≈ 512·127² ≈ 8.3M, far inside 2⁵³) — so this is an EXACT
|
|
127
|
+
// integer computation, deterministic everywhere.
|
|
128
|
+
export function intDot(a: Int8Array, b: Int8Array): number {
|
|
129
|
+
const n = Math.min(a.length, b.length);
|
|
130
|
+
let dot = 0;
|
|
131
|
+
for (let i = 0; i < n; i++) dot += a[i]! * b[i]!;
|
|
132
|
+
return dot;
|
|
133
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// HTTP embedding endpoint client — the "rich" tier SKELETON for v2.11.0. It is
|
|
2
|
+
// intentionally NOT wired into the CLI or MCP in v2.10.0: the deterministic
|
|
3
|
+
// static tier is the default, and an endpoint's vectors are float and provider-
|
|
4
|
+
// dependent (NOT byte-deterministic), so this stays an explicit, opt-in escape
|
|
5
|
+
// hatch a future release finalizes. Shipped now only so the module surface is
|
|
6
|
+
// stable and the contract is documented.
|
|
7
|
+
//
|
|
8
|
+
// The contract (v2.11): POST { texts: string[] } → { vectors: number[][] } to
|
|
9
|
+
// CODEINDEX_EMBED_ENDPOINT (or opts.url). Node >=18 has global fetch, so this
|
|
10
|
+
// stays zero-dependency. The library never orchestrates docker; running such a
|
|
11
|
+
// server (e.g. ghcr.io/maxgfr/codeindex-embed) is a user/CI concern.
|
|
12
|
+
|
|
13
|
+
export interface EmbedEndpointOptions {
|
|
14
|
+
// Endpoint URL. Falls back to CODEINDEX_EMBED_ENDPOINT when omitted.
|
|
15
|
+
url?: string;
|
|
16
|
+
// Abort the request after this many ms (default 30_000).
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
// Extra request headers (e.g. an auth token for a private endpoint).
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Resolve the configured endpoint URL, or undefined when neither opts.url nor
|
|
23
|
+
// CODEINDEX_EMBED_ENDPOINT is set — the silent "endpoint tier unavailable" signal.
|
|
24
|
+
export function resolveEmbedEndpoint(opts: EmbedEndpointOptions = {}): string | undefined {
|
|
25
|
+
const url = opts.url ?? process.env.CODEINDEX_EMBED_ENDPOINT;
|
|
26
|
+
return url && url.trim() ? url.trim() : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Request float embeddings for a batch of texts from the endpoint. Throws a
|
|
30
|
+
// clear error when no endpoint is configured or the response is malformed; the
|
|
31
|
+
// caller decides whether to degrade. NOTE: endpoint vectors are float and are
|
|
32
|
+
// NOT part of any determinism guarantee — this path is for the rich tier only.
|
|
33
|
+
export async function embedViaEndpoint(texts: string[], opts: EmbedEndpointOptions = {}): Promise<number[][]> {
|
|
34
|
+
const url = resolveEmbedEndpoint(opts);
|
|
35
|
+
if (!url) throw new Error("no embedding endpoint configured (set CODEINDEX_EMBED_ENDPOINT or pass opts.url)");
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 30_000);
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(url, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "content-type": "application/json", ...(opts.headers ?? {}) },
|
|
42
|
+
body: JSON.stringify({ texts }),
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
});
|
|
45
|
+
if (!res.ok) throw new Error(`embedding endpoint ${url} returned HTTP ${res.status}`);
|
|
46
|
+
const data = (await res.json()) as { vectors?: unknown };
|
|
47
|
+
const vectors = data.vectors;
|
|
48
|
+
if (!Array.isArray(vectors) || !vectors.every((v) => Array.isArray(v) && v.every((x) => typeof x === "number"))) {
|
|
49
|
+
throw new Error(`embedding endpoint ${url} returned a malformed { vectors } payload`);
|
|
50
|
+
}
|
|
51
|
+
return vectors as number[][];
|
|
52
|
+
} finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { RepoScan } from "../scan.js";
|
|
2
|
+
import { encode } from "./encode.js";
|
|
3
|
+
import { EMBED_VERSION, type StaticEmbedModel } from "./model.js";
|
|
4
|
+
|
|
5
|
+
// The corpus embedding artifact. Granularity is PER-SYMBOL (each symbol gets its
|
|
6
|
+
// own record, so search can surface the exact symbol that matched), with a
|
|
7
|
+
// PER-FILE fallback record for files that declare no symbols (docs, config) so
|
|
8
|
+
// every file with content is still represented. Deterministic: records follow
|
|
9
|
+
// scan order (files sorted by rel) then declaration order within a file.
|
|
10
|
+
export interface EmbeddingRecord {
|
|
11
|
+
file: string; // repo-relative path
|
|
12
|
+
symbol?: string; // the declared symbol this vector represents (absent for a file-level record)
|
|
13
|
+
line?: number; // 1-based symbol line (absent for a file-level record)
|
|
14
|
+
vec: Int8Array; // length === dim
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface EmbeddingIndex {
|
|
18
|
+
embedVersion: number;
|
|
19
|
+
modelId: string;
|
|
20
|
+
dim: number;
|
|
21
|
+
records: EmbeddingRecord[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The text encoded for one symbol: its name (camelCase-split by the tokenizer),
|
|
25
|
+
// its signature, the owning file's one-line summary, and the file's path segments
|
|
26
|
+
// — the same signal bm25 indexes, but pooled into a single vector. Newline-joined
|
|
27
|
+
// so the tokenizer's non-alphanumeric split cleanly separates the parts.
|
|
28
|
+
function symbolText(rel: string, name: string, signature: string | undefined, summary: string | undefined): string {
|
|
29
|
+
return [name, signature ?? "", summary ?? "", rel.replace(/\//g, " ")].join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A file-level record's text (symbol-less files): title, summary, headings, path.
|
|
33
|
+
function fileText(rel: string, title: string | undefined, summary: string | undefined, headings: string[]): string {
|
|
34
|
+
return [title ?? "", summary ?? "", ...headings, rel.replace(/\//g, " ")].join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Build the corpus embedding index from a scan + a loaded model. Pure and
|
|
38
|
+
// deterministic (encode is byte-stable, scan order is fixed) → two builds of an
|
|
39
|
+
// unchanged repo produce byte-identical serialized bytes.
|
|
40
|
+
export function buildEmbeddingIndex(scan: RepoScan, model: StaticEmbedModel): EmbeddingIndex {
|
|
41
|
+
const records: EmbeddingRecord[] = [];
|
|
42
|
+
for (const f of scan.files) {
|
|
43
|
+
const seen = new Set<string>();
|
|
44
|
+
let hadSymbol = false;
|
|
45
|
+
for (const s of f.symbols) {
|
|
46
|
+
if (seen.has(s.name)) continue; // dedupe by name within a file (bm25 parity)
|
|
47
|
+
seen.add(s.name);
|
|
48
|
+
hadSymbol = true;
|
|
49
|
+
records.push({
|
|
50
|
+
file: f.rel,
|
|
51
|
+
symbol: s.name,
|
|
52
|
+
line: s.line,
|
|
53
|
+
vec: encode(model, symbolText(f.rel, s.name, s.signature, f.summary)),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (!hadSymbol) {
|
|
57
|
+
const text = fileText(f.rel, f.title, f.summary, f.headings);
|
|
58
|
+
if (text.replace(/\s+/g, "")) {
|
|
59
|
+
records.push({ file: f.rel, vec: encode(model, text) });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { embedVersion: EMBED_VERSION, modelId: model.modelId, dim: model.dim, records };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const MAGIC = "CIE1"; // codeindex embeddings, format 1
|
|
67
|
+
|
|
68
|
+
// Serialize to embeddings.bin: a fixed ASCII magic, a uint32-LE header length, a
|
|
69
|
+
// UTF-8 JSON header (per-record metadata in build order — carries NO absolute
|
|
70
|
+
// path or timestamp), then the packed int8 body (count × dim signed bytes). The
|
|
71
|
+
// header JSON key order is fixed by construction, and every body byte is written
|
|
72
|
+
// explicitly, so the bytes are fully deterministic. Returns a Uint8Array (a
|
|
73
|
+
// Buffer at runtime) so the public type surface stays free of the Node `Buffer`
|
|
74
|
+
// global — a consumer type-checking without @types/node still resolves it.
|
|
75
|
+
export function serializeEmbeddings(index: EmbeddingIndex): Uint8Array {
|
|
76
|
+
const header = JSON.stringify({
|
|
77
|
+
embedVersion: index.embedVersion,
|
|
78
|
+
modelId: index.modelId,
|
|
79
|
+
dim: index.dim,
|
|
80
|
+
count: index.records.length,
|
|
81
|
+
records: index.records.map((r) => ({ file: r.file, symbol: r.symbol ?? "", line: r.line ?? 0 })),
|
|
82
|
+
});
|
|
83
|
+
const headerBuf = Buffer.from(header, "utf8");
|
|
84
|
+
const body = Buffer.alloc(index.records.length * index.dim);
|
|
85
|
+
let off = 0;
|
|
86
|
+
for (const r of index.records) {
|
|
87
|
+
for (let d = 0; d < index.dim; d++) body.writeInt8(r.vec[d] ?? 0, off++);
|
|
88
|
+
}
|
|
89
|
+
const out = Buffer.alloc(8 + headerBuf.length + body.length);
|
|
90
|
+
out.write(MAGIC, 0, "ascii");
|
|
91
|
+
out.writeUInt32LE(headerBuf.length, 4);
|
|
92
|
+
headerBuf.copy(out, 8);
|
|
93
|
+
body.copy(out, 8 + headerBuf.length);
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Inverse of serializeEmbeddings. Accepts any Uint8Array (e.g. a fs.readFileSync
|
|
98
|
+
// Buffer) and wraps it as a Buffer VIEW (no copy) for the numeric reads. Throws
|
|
99
|
+
// on a bad magic (a corrupt or foreign file) so a caller fails loudly rather
|
|
100
|
+
// than misreading arbitrary bytes.
|
|
101
|
+
export function deserializeEmbeddings(bytes: Uint8Array): EmbeddingIndex {
|
|
102
|
+
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
103
|
+
if (buf.length < 8 || buf.toString("ascii", 0, 4) !== MAGIC) {
|
|
104
|
+
throw new Error("embeddings.bin: bad magic (not a codeindex embeddings artifact)");
|
|
105
|
+
}
|
|
106
|
+
const headerLen = buf.readUInt32LE(4);
|
|
107
|
+
const header = JSON.parse(buf.toString("utf8", 8, 8 + headerLen)) as {
|
|
108
|
+
embedVersion: number;
|
|
109
|
+
modelId: string;
|
|
110
|
+
dim: number;
|
|
111
|
+
count: number;
|
|
112
|
+
records: { file: string; symbol: string; line: number }[];
|
|
113
|
+
};
|
|
114
|
+
const bodyOff = 8 + headerLen;
|
|
115
|
+
const { dim } = header;
|
|
116
|
+
const records: EmbeddingRecord[] = header.records.map((m, i) => {
|
|
117
|
+
const vec = new Int8Array(dim);
|
|
118
|
+
for (let d = 0; d < dim; d++) vec[d] = buf.readInt8(bodyOff + i * dim + d);
|
|
119
|
+
const rec: EmbeddingRecord = { file: m.file, vec };
|
|
120
|
+
if (m.symbol) rec.symbol = m.symbol;
|
|
121
|
+
if (m.line) rec.line = m.line;
|
|
122
|
+
return rec;
|
|
123
|
+
});
|
|
124
|
+
return { embedVersion: header.embedVersion, modelId: header.modelId, dim, records };
|
|
125
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// The deterministic static-embedding tier (v2.10.0). A *static* embedding model
|
|
5
|
+
// is a plain lookup table: token → row vector. Inference is tokenize → gather
|
|
6
|
+
// rows → mean-pool → L2-normalize → int8-quantize (see encode.ts). There is NO
|
|
7
|
+
// neural forward pass, no wasm, no float matmul — so the encode is pure-JS and
|
|
8
|
+
// byte-identical across platforms, which is the whole reason this tier exists.
|
|
9
|
+
//
|
|
10
|
+
// Activation is OPT-IN by asset presence, exactly like the tree-sitter grammar
|
|
11
|
+
// tier (src/ast/loader.ts): no model on disk → the engine silently stays
|
|
12
|
+
// lexical. Models are NEVER shipped in the npm tarball; the asset is fetched by
|
|
13
|
+
// `codeindex embed pull` into CODEINDEX_EMBED_DIR (or <repo>/.codeindex/models/).
|
|
14
|
+
|
|
15
|
+
// Bumped independently of SCHEMA_VERSION / EXTRACTOR_VERSION whenever the
|
|
16
|
+
// embeddings.bin artifact shape or the encode algorithm changes, so a consumer
|
|
17
|
+
// rejects an embeddings.bin written by an incompatible engine (or a different
|
|
18
|
+
// model) instead of misranking with it. SCHEMA_VERSION is deliberately left
|
|
19
|
+
// UNTOUCHED — embeddings are a purely additive sidecar with zero impact on the
|
|
20
|
+
// graph.json / symbols.json consumers.
|
|
21
|
+
export const EMBED_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
// The default model-name a `pull` writes and a `status` reports. The real asset
|
|
24
|
+
// URL is intentionally NOT hard-coded yet (see resolveEmbedPullUrl): the asset
|
|
25
|
+
// is unpublished, so `pull` fails cleanly asking for CODEINDEX_EMBED_URL rather
|
|
26
|
+
// than fetching a phantom.
|
|
27
|
+
export const DEFAULT_EMBED_DIRNAME = "models";
|
|
28
|
+
|
|
29
|
+
// A loaded static-embedding model. `vocab` maps a wordpiece token to its row
|
|
30
|
+
// index; `weights` is the flat row-major (vocabSize × dim) matrix as IEEE-754
|
|
31
|
+
// doubles (float rows widen to double exactly, so mean-pool stays deterministic).
|
|
32
|
+
export interface StaticEmbedModel {
|
|
33
|
+
modelId: string;
|
|
34
|
+
dim: number;
|
|
35
|
+
unk: string; // the OOV token (e.g. "[UNK]"); may be absent from vocab
|
|
36
|
+
unkId: number; // row index of `unk`, or -1 when the model has no UNK row
|
|
37
|
+
vocabSize: number;
|
|
38
|
+
vocab: Map<string, number>;
|
|
39
|
+
weights: Float64Array; // length === vocabSize * dim, row-major
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The on-disk model.json header shape. `weights` is an array of `vocabSize`
|
|
43
|
+
// rows, each of length `dim`. Kept human-authorable (plain JSON numbers) so a
|
|
44
|
+
// tiny fixture model is committable and diffable; a real `pull`ed model uses the
|
|
45
|
+
// identical shape at scale.
|
|
46
|
+
interface ModelFile {
|
|
47
|
+
modelId: string;
|
|
48
|
+
dim: number;
|
|
49
|
+
unk?: string;
|
|
50
|
+
vocab: string[];
|
|
51
|
+
weights: number[][];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Where the model asset lives. CODEINDEX_EMBED_DIR wins outright (explicit
|
|
55
|
+
// override, mirrors CODEINDEX_GRAMMAR_DIR); otherwise <repo>/.codeindex/models/
|
|
56
|
+
// then <cwd>/.codeindex/models/. Returns undefined when no model.json is found
|
|
57
|
+
// anywhere — the silent-degradation signal. When CODEINDEX_EMBED_DIR is set but
|
|
58
|
+
// empty of a model, that is still "no model" (undefined), never an error.
|
|
59
|
+
export function resolveEmbedModelDir(repo?: string): string | undefined {
|
|
60
|
+
const env = process.env.CODEINDEX_EMBED_DIR;
|
|
61
|
+
const candidates: string[] = [];
|
|
62
|
+
if (env) candidates.push(env);
|
|
63
|
+
if (repo) candidates.push(join(repo, ".codeindex", DEFAULT_EMBED_DIRNAME));
|
|
64
|
+
candidates.push(join(process.cwd(), ".codeindex", DEFAULT_EMBED_DIRNAME));
|
|
65
|
+
for (const c of candidates) {
|
|
66
|
+
if (existsSync(join(c, "model.json"))) return c;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// True when an activatable model is present (used by `embed status` and the MCP
|
|
72
|
+
// gate before attempting a semantic search).
|
|
73
|
+
export function hasEmbedModel(repo?: string): boolean {
|
|
74
|
+
return resolveEmbedModelDir(repo) !== undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Load and validate a static model from a directory containing model.json.
|
|
78
|
+
// Throws on a malformed file (bad dim, ragged weights) so a corrupt asset fails
|
|
79
|
+
// loudly at load rather than silently misranking. Returns undefined only when
|
|
80
|
+
// the directory has no model.json (the not-present case).
|
|
81
|
+
export function loadEmbedModel(dir?: string): StaticEmbedModel | undefined {
|
|
82
|
+
if (!dir) return undefined;
|
|
83
|
+
const path = join(dir, "model.json");
|
|
84
|
+
if (!existsSync(path)) return undefined;
|
|
85
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as ModelFile;
|
|
86
|
+
const { modelId, dim, vocab, weights } = raw;
|
|
87
|
+
if (typeof modelId !== "string" || !modelId) throw new Error(`embed model: missing modelId in ${path}`);
|
|
88
|
+
if (!Number.isInteger(dim) || dim <= 0) throw new Error(`embed model: bad dim ${dim} in ${path}`);
|
|
89
|
+
if (!Array.isArray(vocab) || !Array.isArray(weights) || vocab.length !== weights.length) {
|
|
90
|
+
throw new Error(`embed model: vocab/weights length mismatch in ${path}`);
|
|
91
|
+
}
|
|
92
|
+
const vocabSize = vocab.length;
|
|
93
|
+
const flat = new Float64Array(vocabSize * dim);
|
|
94
|
+
const vmap = new Map<string, number>();
|
|
95
|
+
for (let i = 0; i < vocabSize; i++) {
|
|
96
|
+
const tok = vocab[i]!;
|
|
97
|
+
if (typeof tok !== "string") throw new Error(`embed model: non-string vocab entry at ${i}`);
|
|
98
|
+
if (!vmap.has(tok)) vmap.set(tok, i); // first occurrence wins (deterministic)
|
|
99
|
+
const row = weights[i]!;
|
|
100
|
+
if (!Array.isArray(row) || row.length !== dim) {
|
|
101
|
+
throw new Error(`embed model: row ${i} has length ${row?.length}, expected ${dim}`);
|
|
102
|
+
}
|
|
103
|
+
for (let d = 0; d < dim; d++) flat[i * dim + d] = Number(row[d]);
|
|
104
|
+
}
|
|
105
|
+
const unk = typeof raw.unk === "string" ? raw.unk : "[UNK]";
|
|
106
|
+
const unkId = vmap.has(unk) ? vmap.get(unk)! : -1;
|
|
107
|
+
return { modelId, dim, unk, unkId, vocabSize, vocab: vmap, weights: flat };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Resolve the URL `embed pull` fetches from. The official asset is not published
|
|
111
|
+
// yet, so there is no built-in default: the user must point CODEINDEX_EMBED_URL
|
|
112
|
+
// at a model.json (or a directory serving one). Returns undefined when unset —
|
|
113
|
+
// the caller turns that into a clean, actionable failure, never a crash.
|
|
114
|
+
export function resolveEmbedPullUrl(): string | undefined {
|
|
115
|
+
const url = process.env.CODEINDEX_EMBED_URL;
|
|
116
|
+
return url && url.trim() ? url.trim() : undefined;
|
|
117
|
+
}
|