graph-indexer 3.0.1 → 3.1.1

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 (58) hide show
  1. package/CHANGELOG.md +456 -0
  2. package/README.md +61 -8
  3. package/SECURITY.md +35 -20
  4. package/bin/graph-indexer.mjs +46 -5
  5. package/integrations/claude-code/.claude-plugin/plugin.json +1 -1
  6. package/integrations/claude-code/agents/code-structure.md +0 -3
  7. package/integrations/claude-code/skills/setup/SKILL.md +26 -0
  8. package/integrations/claude-code-local/.claude-plugin/plugin.json +9 -0
  9. package/integrations/{claude-code → claude-code-local}/.mcp.json +1 -1
  10. package/integrations/claude-code-local/README.md +10 -0
  11. package/integrations/claude-code-local/agents/code-structure.md +22 -0
  12. package/integrations/{claude-code → claude-code-local}/hooks/gi-hook.mjs +15 -4
  13. package/integrations/opencode/graph-indexer.js +10 -3
  14. package/package.json +1 -1
  15. package/src/cli/helper.mjs +15 -1
  16. package/src/cli/hook.mjs +124 -40
  17. package/src/cli/init.mjs +32 -5
  18. package/src/cli/resident.mjs +19 -4
  19. package/src/index/discover.mjs +52 -30
  20. package/src/index/indexer.mjs +78 -42
  21. package/src/index/modules.mjs +75 -2
  22. package/src/index/parse-worker.mjs +12 -0
  23. package/src/index/parsepool.mjs +73 -0
  24. package/src/index/resolver.mjs +362 -23
  25. package/src/mcp/server.mjs +43 -13
  26. package/src/mcp/tools.mjs +418 -26
  27. package/src/parse/arity.mjs +39 -2
  28. package/src/parse/extract.mjs +269 -45
  29. package/src/parse/lang/ecma.mjs +2 -0
  30. package/src/parse/lang/go.mjs +2 -0
  31. package/src/parse/lang/jvm.mjs +33 -0
  32. package/src/parse/lang/markdown.mjs +92 -0
  33. package/src/parse/lang/python.mjs +43 -0
  34. package/src/parse/lang/rust.mjs +215 -15
  35. package/src/parse/lang/sfc.mjs +126 -0
  36. package/src/parse/languages.mjs +9 -2
  37. package/src/query/check.mjs +49 -13
  38. package/src/query/facts.mjs +3 -2
  39. package/src/query/injection-spring.mjs +406 -0
  40. package/src/query/injection.mjs +174 -0
  41. package/src/query/intel.mjs +85 -15
  42. package/src/query/issue.mjs +156 -0
  43. package/src/query/orm.mjs +288 -0
  44. package/src/query/read.mjs +6 -1
  45. package/src/query/report.mjs +53 -0
  46. package/src/query/routes.mjs +142 -0
  47. package/src/query/showcase.mjs +59 -0
  48. package/src/query/textsearch.mjs +30 -9
  49. package/src/search/search.mjs +85 -17
  50. package/src/search/semantic.mjs +201 -0
  51. package/src/search/vectors.mjs +122 -0
  52. package/src/store/db.mjs +33 -4
  53. package/src/util/git.mjs +60 -11
  54. package/src/util/paths.mjs +10 -3
  55. package/vendor/grammars/README.md +47 -0
  56. package/vendor/grammars/kotlin.wasm.gz +0 -0
  57. package/vendor/grammars/manifest.json +4 -4
  58. /package/integrations/{claude-code → claude-code-local}/hooks/hooks.json +0 -0
@@ -15,12 +15,14 @@ const MAX_FILE_BYTES = 1_500_000;
15
15
  // a caller's pattern gets this much matching time per search (reading files does not count): one
16
16
  // with catastrophic backtracking (`(a+)+$`) is stopped instead of blocking the server
17
17
  const REGEX_BUDGET_MS = 5000;
18
+ const MAX_KEPT = 50_000;
18
19
  const SCAN = new vm.Script(`(() => {
19
20
  if (!re.test(text)) return null;
20
21
  const lines = text.split(/\\r?\\n/), hits = [];
21
22
  for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) hits.push(i);
22
23
  return { lines, hits };
23
24
  })()`);
25
+ const GLOB_FILTER = new vm.Script(`paths.filter(p => re.test(onName ? p.slice(p.lastIndexOf('/') + 1) : p))`);
24
26
  const SKIP_DIRS = /(^|\/)(node_modules|\.git|\.graph-indexer|dist|build|out|target|coverage|__pycache__|\.venv|venv|\.next|\.nuxt|\.cache|vendor)\//;
25
27
  const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials?|benchmarks?|playground)\//i;
26
28
  const COMMENT_LINE = /^\s*(\/\/|#(?![!\[])|\*|\/\*|<!--|--\s|;;|%)/;
@@ -54,7 +56,7 @@ function globSource(glob) {
54
56
  i++;
55
57
  if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
56
58
  } else if (c === '?') re += '[^/]';
57
- else if (c === '[' && glob.indexOf(']', i + 2) > i) { const j = glob.indexOf(']', i + 2); re += glob.slice(i, j + 1).replace(/^\[!/, '[^'); i = j; }
59
+ else if (c === '[' && glob.indexOf(']', i + 2) > i) { const j = glob.indexOf(']', i + 2); re += '[' + glob.slice(i + 1, j).replace(/^!/, '^').replace(/\\/g, '\\\\') + ']'; i = j; }
58
60
  else if (c === '{' && glob.indexOf('}', i) > i) { const j = glob.indexOf('}', i); re += `(?:${glob.slice(i + 1, j).split(',').map(escapeRe).join('|')})`; i = j; }
59
61
  else re += escapeRe(c);
60
62
  }
@@ -71,7 +73,16 @@ export function findFiles(root, pattern, { path: prefix = null } = {}) {
71
73
  if (/[*?[{]/.test(pattern)) {
72
74
  const onName = !pattern.includes('/');
73
75
  const re = new RegExp(onName ? `^${globSource(pattern)}$` : `(^|/)${globSource(pattern.replace(/^\.?\//, ''))}$`);
74
- test = (p) => re.test(onName ? p.slice(p.lastIndexOf('/') + 1) : p);
76
+ // a glob of many stars (`*a*a*a*a*a*a*b`) backtracks for minutes on one long name: matched
77
+ // under the same budget as search_text's expressions, so it cannot block the server
78
+ const paths = listFiles(root).filter(p => !pre || p === pre || p.startsWith(pre + '/'));
79
+ const ctx = vm.createContext({ re, paths, onName });
80
+ try {
81
+ return GLOB_FILTER.runInContext(ctx, { timeout: REGEX_BUDGET_MS }).sort();
82
+ } catch (e) {
83
+ if (e.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw e;
84
+ throw new Error(`The glob took more than ${REGEX_BUDGET_MS / 1000} s to match (too many wildcards?). Use fewer wildcards.`);
85
+ }
75
86
  } else {
76
87
  const t = pattern.toLowerCase();
77
88
  test = (p) => p.toLowerCase().includes(t);
@@ -133,7 +144,8 @@ export async function textSearch(intel, { pattern, path: prefix = null, literal
133
144
  const f = indexed.get(rel);
134
145
  for (const i of scan.hits) {
135
146
  total++;
136
- matches.push({ path: rel, line: i + 1, text: scan.lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
147
+ // counted, but kept (and annotated) only up to a bound: memory and time stay bounded
148
+ if (matches.length < MAX_KEPT) matches.push({ path: rel, line: i + 1, text: scan.lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
137
149
  }
138
150
  }
139
151
  const ident = identifierOf(pattern, literal);
@@ -149,13 +161,22 @@ export async function textSearch(intel, { pattern, path: prefix = null, literal
149
161
  WHERE r.file_id = ? AND r.name = ?`, f.id, ident) : [];
150
162
  const refsAt = new Map();
151
163
  for (const r of refs) (refsAt.get(r.line) ?? refsAt.set(r.line, []).get(r.line)).push(r);
164
+ // innermost definition around each matched line, ignoring ones that start on it (inline
165
+ // classes, the def itself): one sweep over the definitions in start order, writing only the
166
+ // matched lines, instead of every definition for every match
167
+ const wanted = new Map(ms.map(m => [m.line, null]));
168
+ const lines = [...wanted.keys()].sort((a, b) => a - b);
169
+ for (const s of syms) {
170
+ let lo = 0, hi = lines.length;
171
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (lines[mid] <= s.start_line) lo = mid + 1; else hi = mid; }
172
+ for (let k = lo; k < lines.length && lines[k] <= s.end_line; k++) wanted.set(lines[k], s);
173
+ }
174
+ const defAt = new Map();
175
+ if (summary) for (const s of syms) if (s.name === ident && !defAt.has(s.name_line)) defAt.set(s.name_line, s);
152
176
  for (const m of ms) {
153
- // innermost definition around the line, ignoring ones that start on it (inline classes, the def itself)
154
- let encl = null;
155
- for (const s of syms) if (s.start_line < m.line && s.end_line >= m.line && (!encl || s.start_line >= encl.start_line)) encl = s;
156
- m.encl = encl?.qname ?? null;
177
+ m.encl = wanted.get(m.line)?.qname ?? null;
157
178
  if (!summary) continue;
158
- const def = syms.find(s => s.name === ident && s.name_line === m.line);
179
+ const def = defAt.get(m.line);
159
180
  const rs = refsAt.get(m.line) ?? [];
160
181
  if (def) { m.what = 'def'; m.target = def.qname; summary.defs++; continue; }
161
182
  const bound = rs.filter(r => r.dst_id != null);
@@ -174,5 +195,5 @@ export async function textSearch(intel, { pattern, path: prefix = null, literal
174
195
  }
175
196
  }
176
197
  matches.sort((a, b) => rank(a.path, a.isTest) + (a.code ? 0 : 1) - (rank(b.path, b.isTest) + (b.code ? 0 : 1)) || a.path.localeCompare(b.path) || a.line - b.line);
177
- return { total, files: nfiles, shown: matches.slice(0, limit), all: matches, summary, truncated: matches.length > limit };
198
+ return { total, files: nfiles, shown: matches.slice(0, limit), all: matches, summary, truncated: total > limit, annotated: matches.length };
178
199
  }
@@ -4,17 +4,20 @@
4
4
  * 1. name channel exact / case-insensitive / qualified / prefix matches on symbol names
5
5
  * 2. lexical channel FTS5 BM25 over fields (name, qualified name, signature, doc, path, body)
6
6
  * 3. concept channel the same index queried with thesaurus expansions (natural-language queries)
7
- * 4. rerank a small linear model over interpretable features (name evidence, field
7
+ * 4. semantic channel (optional) cosine similarity of a static code-embedding model (vectors.mjs),
8
+ * on when its model is installed
9
+ * 5. rerank a small linear model over interpretable features (name evidence, field
8
10
  * coverage, kind/test/export priors, graph centrality), then per-file
9
11
  * diversification so one file cannot flood the top results.
10
12
  */
11
13
  import { analyzeQuery, codeTokens, splitIdentifier } from './tokenize.mjs';
14
+ import { VectorIndex } from './vectors.mjs';
12
15
 
13
16
  const FTS_WEIGHTS = [10.0, 6.0, 3.0, 2.0, 2.5, 1.0]; // name, qname, sig, doc, path, body
14
17
  const KIND_PRIOR = {
15
18
  class: 1.0, interface: 0.95, struct: 1.0, trait: 0.95, enum: 0.85, type: 0.8, object: 0.95, module: 0.7, impl: 0.7,
16
19
  function: 1.0, method: 1.0, constructor: 0.75, macro: 0.8, field: 0.55, property: 0.6, variable: 0.55, constant: 0.6,
17
- selector: 0.35, keyframes: 0.3,
20
+ selector: 0.35, keyframes: 0.3, section: 0.5,
18
21
  };
19
22
 
20
23
  export const DEFAULT_WEIGHTS = {
@@ -39,11 +42,21 @@ export const DEFAULT_WEIGHTS = {
39
42
  nlField: -0.93, // fields/properties/variables for natural-language (behavioural) queries
40
43
  file: 1.0, // file-level BM25 of the symbol's file (hierarchical: file relevance first)
41
44
  fileNL: 0.5, // extra file-level weight for natural-language (behavioural) queries
45
+ docs: 2.0, // documentation channel BM25 (only for queries about docs, design or rationale)
46
+ // semantic channel (opt-in): cosine, min-max normalised over its top candidates. Higher weights
47
+ // (0.5 / 2.0) score better on symbol search and worse on localizing real commits; these are
48
+ // the highest that leave localization within noise (docs/BENCHMARKS.md)
49
+ sem: 0.25,
50
+ semNL: 0.5, // extra semantic weight for natural-language queries
42
51
  };
43
52
 
44
53
  const EXAMPLE_PATH = /(^|\/)(examples?|samples?|demos?|sandbox|playground|docs?(_src|_source|_examples?)?|documentation|benchmarks?|bench|scripts?|fixtures?|testdata|tutorials?|website|\.github)\//i;
45
54
  const DECLARATION_PATH = /\.d\.[cm]?ts$/;
55
+ export const WANTS_DOCS = /\b(docs?|documentation|documented|readme|guide|tutorial|design|rationale|architecture|adr|why|explained?|explanation|changelog|faq|how to)\b/i;
46
56
  const BM25_FLOOR = 1.0;
57
+ const SEM_K = 200; // semantic candidates per query
58
+ const RRF_K = 60;
59
+ export const MAX_QUERIES = 6;
47
60
  const FILE_GATE = 0.25; // own normalized BM25 at which the file boost applies in full (tuning split)
48
61
 
49
62
  function stem(w) {
@@ -58,7 +71,8 @@ function ftsQuery(terms) {
58
71
  }
59
72
 
60
73
  export class SearchEngine {
61
- constructor(indexer, { weights = DEFAULT_WEIGHTS, centrality = null, perFileCap = Number(process.env.GI_PER_FILE_CAP) || 3 } = {}) {
74
+ constructor(indexer, { weights = DEFAULT_WEIGHTS, centrality = null, perFileCap = Number(process.env.GI_PER_FILE_CAP) || 3, semantic = undefined } = {}) {
75
+ this.semantic = semantic === undefined ? VectorIndex.maybe(indexer) : semantic;
62
76
  this.perFileCap = perFileCap;
63
77
  this.ix = indexer;
64
78
  this.store = indexer.store;
@@ -78,6 +92,7 @@ export class SearchEngine {
78
92
  const kindFilter = opts.kinds ?? a.filters.kind ?? null;
79
93
  const langFilter = opts.lang ?? a.filters.lang?.[0] ?? null;
80
94
  const wantsTests = opts.includeTests ?? /\b(test|tests|spec|specs|mock|fixture)\b/i.test(query);
95
+ const wantsDocs = WANTS_DOCS.test(query);
81
96
  const cands = new Map(); // id -> feature bag
82
97
  const bag = (id) => { let f = cands.get(id); if (!f) cands.set(id, (f = { id })); return f; };
83
98
 
@@ -140,6 +155,23 @@ export class SearchEngine {
140
155
  for (const r of rows) { const f = bag(r.id); if (f.bm25 == null) { f.bm25 = -r.s; if (-r.s > maxBm) maxBm = -r.s; } }
141
156
  }
142
157
  }
158
+ // documentation channel: Markdown sections, only when the query is about docs, design or rationale
159
+ let maxDoc = 0;
160
+ if (q1 && (wantsDocs || opts.docs)) {
161
+ for (const r of this.store.all('SELECT rowid AS id, bm25(doc_fts, 4.0, 1.0) AS s FROM doc_fts WHERE doc_fts MATCH ? ORDER BY s LIMIT 60', q1)) {
162
+ bag(r.id).doc = -r.s; if (-r.s > maxDoc) maxDoc = -r.s;
163
+ }
164
+ }
165
+ // semantic channel: definitions whose embedding is closest to the query's
166
+ let semLo = 0, semHi = 0;
167
+ if (this.semantic && (this.w.sem || this.w.semNL)) {
168
+ const hits = this.semantic.search(query, SEM_K);
169
+ if (hits.size) {
170
+ const vals = [...hits.values()];
171
+ semHi = Math.max(...vals); semLo = Math.min(...vals);
172
+ for (const [id, c] of hits) bag(id).sem = c;
173
+ }
174
+ }
143
175
  if (!cands.size) return { results: [], analysis: a };
144
176
 
145
177
  // 4. features + rerank
@@ -161,6 +193,7 @@ export class SearchEngine {
161
193
  if (pathFilter && !r.path.startsWith(pathFilter.replace(/^\.\//, ''))) continue;
162
194
  if (kindFilter && !kindFilter.includes(r.kind)) continue;
163
195
  if (langFilter && r.lang !== langFilter) continue;
196
+ if (r.kind === 'section' && !wantsDocs && !opts.docs) continue; // documentation answers only questions about it
164
197
  const nameWords = splitIdentifier(r.name).map(stem);
165
198
  const qnameWords = new Set(splitIdentifier(r.qname.replace(/\./g, '_')).map(stem));
166
199
  const nameCover = nameWords.length ? nameWords.filter(x => qStems.has(x)).length / nameWords.length : 0;
@@ -171,7 +204,9 @@ export class SearchEngine {
171
204
  // blown up by max-normalisation; when literal evidence is weak, the concept channel
172
205
  // (thesaurus expansions) takes over — that is exactly the vocabulary-mismatch case.
173
206
  if (f.bm25) s += w.bm25 * (f.bm25 / Math.max(maxBm, BM25_FLOOR));
207
+ if (f.doc) s += (w.docs ?? 0) * (f.doc / Math.max(maxDoc, BM25_FLOOR));
174
208
  if (f.concept) s += (maxBm < BM25_FLOOR ? Math.max(w.concept, 1.0) : w.concept) * (f.concept / Math.max(maxCx, BM25_FLOOR));
209
+ if (f.sem != null && semHi > semLo) s += ((w.sem ?? 0) + (a.natural ? (w.semNL ?? 0) : 0)) * (f.sem - semLo) / (semHi - semLo);
175
210
  if (f.exact) { s += w.exact * f.exact; reasons.push('name'); }
176
211
  if (f.exactCI) { s += w.exactCI * f.exactCI; reasons.push('name'); }
177
212
  if (f.qualified) { s += w.qualified; reasons.push('qualified name'); }
@@ -188,23 +223,56 @@ export class SearchEngine {
188
223
  if (fs && f.bm25 && r.kind !== 'selector' && r.kind !== 'keyframes') s += (w.file + (a.natural ? w.fileNL : 0)) * fs * Math.min(1, (f.bm25 / Math.max(maxBm, BM25_FLOOR)) / FILE_GATE);
189
224
  if (r.parent_kind === 'function' || r.parent_kind === 'method') s += w.nested;
190
225
  if (DECLARATION_PATH.test(r.path)) s += w.declaration;
191
- if (EXAMPLE_PATH.test(r.path) && !(pathFilter && EXAMPLE_PATH.test(pathFilter))) s += w.example;
226
+ if (r.kind !== 'section' && EXAMPLE_PATH.test(r.path) && !(pathFilter && EXAMPLE_PATH.test(pathFilter))) s += w.example;
192
227
  if (a.natural && (r.kind === 'field' || r.kind === 'property' || r.kind === 'variable' || r.kind === 'constant')) s += w.nlField;
193
228
  scored.push({ id: r.id, score: s, reasons, path: r.path, ...(opts.debug ? { features: { ...f, nameCover, queryCover, kind: r.kind } } : {}) });
194
229
  }
195
230
  scored.sort((x, y) => y.score - x.score || x.id - y.id);
196
- // per-file diversification: 3rd+ result from the same file is pushed down
197
- const perFile = new Map();
198
- const out = [];
199
- const deferred = [];
200
- for (const r of scored) {
201
- const n = perFile.get(r.path) ?? 0;
202
- if (n >= this.perFileCap) { deferred.push(r); continue; }
203
- perFile.set(r.path, n + 1);
204
- out.push(r);
205
- if (out.length >= limit) break;
206
- }
207
- for (const r of deferred) { if (out.length >= limit) break; out.push(r); }
208
- return { results: out, total: scored.length, analysis: a };
231
+ return { results: diversify(scored, limit, this.perFileCap), total: scored.length, analysis: a };
232
+ }
233
+
234
+ /**
235
+ * Several phrasings of one need ("where are tokens refreshed", "refreshToken", "session renewal")
236
+ * in one call: each is searched on its own and the rankings are fused by reciprocal rank
237
+ * (k = 60), so a definition several phrasings agree on rises and one phrasing's noise does not
238
+ * dominate. One query is the same as search().
239
+ */
240
+ searchMany(queries, opts = {}) {
241
+ const qs = [...new Set((queries ?? []).map(q => String(q ?? '').trim()).filter(Boolean))].slice(0, MAX_QUERIES);
242
+ if (qs.length <= 1) return this.search(qs[0] ?? '', opts);
243
+ const limit = Math.max(1, Math.min(opts.limit ?? 10, 100));
244
+ const per = Math.max(2 * limit, 20);
245
+ const fused = new Map();
246
+ let total = 0, analysis = null;
247
+ qs.forEach((q, qi) => {
248
+ const r = this.search(q, { ...opts, limit: per });
249
+ analysis ??= r.analysis;
250
+ total = Math.max(total, r.total ?? r.results.length);
251
+ r.results.forEach((x, rank) => {
252
+ let e = fused.get(x.id);
253
+ if (!e) fused.set(x.id, (e = { id: x.id, score: 0, reasons: [], path: x.path, queries: [] }));
254
+ e.score += 1 / (RRF_K + rank + 1);
255
+ e.queries.push(qi);
256
+ for (const why of x.reasons) if (!e.reasons.includes(why)) e.reasons.push(why);
257
+ });
258
+ });
259
+ const scored = [...fused.values()].sort((x, y) => y.score - x.score || x.id - y.id);
260
+ return { results: diversify(scored, limit, this.perFileCap), total: Math.max(total, scored.length), analysis, queries: qs };
261
+ }
262
+ }
263
+
264
+ /** Per-file diversification: results past the cap from one file are pushed below the others. */
265
+ function diversify(scored, limit, cap) {
266
+ const perFile = new Map();
267
+ const out = [];
268
+ const deferred = [];
269
+ for (const r of scored) {
270
+ const n = perFile.get(r.path) ?? 0;
271
+ if (n >= cap) { deferred.push(r); continue; }
272
+ perFile.set(r.path, n + 1);
273
+ out.push(r);
274
+ if (out.length >= limit) break;
209
275
  }
276
+ for (const r of deferred) { if (out.length >= limit) break; out.push(r); }
277
+ return out;
210
278
  }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Optional semantic channel: a static (Model2Vec) code embedding model, run in plain JavaScript.
3
+ *
4
+ * A static model is a table of one vector per WordPiece token; a text's embedding is the mean of
5
+ * its tokens' vectors, normalised. There is no neural network to run, so it needs no native code,
6
+ * no ONNX runtime and no GPU: embedding a whole repository takes seconds on one core.
7
+ *
8
+ * The model is not part of the package. `graph-indexer semantic install` downloads it once, at a
9
+ * pinned revision with checked SHA-256 digests, into ~/.cache/graph-indexer/models (or
10
+ * GRAPH_INDEXER_MODEL_DIR). The channel is opt-in: it runs only with GRAPH_INDEXER_SEMANTIC=1 and
11
+ * the model installed; otherwise search is purely lexical + graph. On the benchmarks it helps
12
+ * symbol search for queries in words and does not help localizing real commits (docs/BENCHMARKS.md),
13
+ * so it is not the default.
14
+ *
15
+ * Vectors are computed lazily, file by file, the first time a search needs them, and kept in the
16
+ * index (`vectors`, `vec_files`), keyed by the file's content hash so edits re-embed only that file.
17
+ */
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import crypto from 'node:crypto';
22
+
23
+ export const MODEL = {
24
+ id: 'potion-code-16M-v2',
25
+ repo: 'minishlab/potion-code-16M-v2',
26
+ revision: 'e9d2a44ca6a05ac6685f3b23709ea57eb7352d5b',
27
+ license: 'MIT',
28
+ files: {
29
+ 'model.safetensors': '75cf7a6c2171b230ad19b1e7d8e0b1aee86da5a02af8e7cacedd9921d227623c',
30
+ 'tokenizer.json': '107bbdcbad4bff1d299b7a4c3a2fb17c52890688b7dd0e4c9deab79d3c4f3d45',
31
+ 'config.json': '148e5691a6fcc553437156859701fba017a1ba5d340b170f17e0f3668fb861a7',
32
+ },
33
+ };
34
+
35
+ const MAX_TOKENS = 512; // Model2Vec's default truncation
36
+ const MAX_WORD_CHARS = 100; // WordPiece max_input_chars_per_word
37
+
38
+ export function modelDir() {
39
+ const base = process.env.GRAPH_INDEXER_MODEL_DIR || path.join(os.homedir(), '.cache', 'graph-indexer', 'models');
40
+ return path.join(base, MODEL.id);
41
+ }
42
+
43
+ export function semanticEnabled() {
44
+ if (process.env.GRAPH_INDEXER_SEMANTIC !== '1') return false;
45
+ return fs.existsSync(path.join(modelDir(), 'model.safetensors')) && fs.existsSync(path.join(modelDir(), 'tokenizer.json'));
46
+ }
47
+
48
+ // ── tokenizer: BertNormalizer(lowercase, strip accents) + BertPreTokenizer + WordPiece ──────────
49
+
50
+ function isControl(c) {
51
+ if (c === 9 || c === 10 || c === 13) return false;
52
+ return (c < 32) || (c >= 127 && c < 160);
53
+ }
54
+ function isWhitespace(c) { return c === 32 || c === 9 || c === 10 || c === 13 || c === 0xa0 || c === 0x3000 || (c >= 0x2000 && c <= 0x200a) || c === 0x2028 || c === 0x2029 || c === 0x202f || c === 0x205f || c === 0x1680; }
55
+ function isCJK(c) {
56
+ return (c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3400 && c <= 0x4dbf) || (c >= 0x20000 && c <= 0x2a6df) || (c >= 0x2a700 && c <= 0x2b73f) ||
57
+ (c >= 0x2b740 && c <= 0x2b81f) || (c >= 0x2b820 && c <= 0x2ceaf) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0x2f800 && c <= 0x2fa1f);
58
+ }
59
+ const PUNCT_RE = /\p{P}/u;
60
+ function isPunct(ch, c) {
61
+ return (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126) || (c > 127 && PUNCT_RE.test(ch));
62
+ }
63
+
64
+ /** Text → normalised pre-tokens (words and single punctuation characters). */
65
+ export function preTokenize(text) {
66
+ let s = '';
67
+ for (const ch of text) {
68
+ const c = ch.codePointAt(0);
69
+ if (c === 0 || c === 0xfffd || isControl(c)) continue;
70
+ if (isWhitespace(c)) { s += ' '; continue; }
71
+ if (isCJK(c)) { s += ' ' + ch + ' '; continue; }
72
+ s += ch;
73
+ }
74
+ s = s.toLowerCase().normalize('NFD').replace(/\p{Mn}/gu, '');
75
+ const out = [];
76
+ let cur = '';
77
+ for (const ch of s) {
78
+ const c = ch.codePointAt(0);
79
+ if (c === 32) { if (cur) { out.push(cur); cur = ''; } continue; }
80
+ if (isPunct(ch, c)) { if (cur) { out.push(cur); cur = ''; } out.push(ch); continue; }
81
+ cur += ch;
82
+ }
83
+ if (cur) out.push(cur);
84
+ return out;
85
+ }
86
+
87
+ export class StaticModel {
88
+ constructor(dir = modelDir()) {
89
+ const tok = JSON.parse(fs.readFileSync(path.join(dir, 'tokenizer.json'), 'utf8'));
90
+ const m = tok.model;
91
+ if (m?.type !== 'WordPiece') throw new Error(`unsupported tokenizer model ${m?.type}`);
92
+ this.vocab = new Map(Object.entries(m.vocab));
93
+ this.prefix = m.continuing_subword_prefix ?? '##';
94
+ this.unk = this.vocab.get(m.unk_token ?? '[UNK]') ?? -1;
95
+ const buf = fs.readFileSync(path.join(dir, 'model.safetensors'));
96
+ const hlen = Number(buf.readBigUInt64LE(0));
97
+ const header = JSON.parse(buf.subarray(8, 8 + hlen).toString('utf8'));
98
+ const t = header.embeddings ?? Object.values(header).find(v => v && v.shape);
99
+ const [rows, dim] = t.shape;
100
+ const [a, b] = t.data_offsets;
101
+ const raw = buf.subarray(8 + hlen + a, 8 + hlen + b);
102
+ this.dim = dim;
103
+ this.rows = rows;
104
+ this.table = new Float32Array(rows * dim);
105
+ if (t.dtype === 'F32') for (let i = 0; i < rows * dim; i++) this.table[i] = raw.readFloatLE(i * 4);
106
+ else if (t.dtype === 'F16') for (let i = 0; i < rows * dim; i++) this.table[i] = f16(raw.readUInt16LE(i * 2));
107
+ else throw new Error(`unsupported embedding dtype ${t.dtype}`);
108
+ this.cache = new Map(); // word -> token ids
109
+ }
110
+
111
+ /** WordPiece ids of one pre-token (greedy longest match), [] when unknown. */
112
+ #word(w) {
113
+ let ids = this.cache.get(w);
114
+ if (ids) return ids;
115
+ ids = [];
116
+ if ([...w].length > MAX_WORD_CHARS) ids = [this.unk];
117
+ else {
118
+ let start = 0;
119
+ while (start < w.length) {
120
+ let end = w.length, found = -1;
121
+ while (start < end) {
122
+ const sub = (start > 0 ? this.prefix : '') + w.slice(start, end);
123
+ const id = this.vocab.get(sub);
124
+ if (id !== undefined) { found = id; break; }
125
+ end--;
126
+ }
127
+ if (found < 0) { ids = [this.unk]; break; }
128
+ ids.push(found);
129
+ start = end;
130
+ }
131
+ }
132
+ if (this.cache.size > 200000) this.cache.clear();
133
+ this.cache.set(w, ids);
134
+ return ids;
135
+ }
136
+
137
+ tokenize(text) {
138
+ const out = [];
139
+ for (const w of preTokenize(text)) {
140
+ for (const id of this.#word(w)) { if (id !== this.unk) out.push(id); }
141
+ if (out.length >= MAX_TOKENS) break;
142
+ }
143
+ return out.length > MAX_TOKENS ? out.slice(0, MAX_TOKENS) : out;
144
+ }
145
+
146
+ /** Mean of token vectors, L2-normalised; null for text without known tokens. */
147
+ embed(text) {
148
+ const ids = this.tokenize(text);
149
+ if (!ids.length) return null;
150
+ const d = this.dim, v = new Float32Array(d), T = this.table;
151
+ for (const id of ids) { const o = id * d; for (let j = 0; j < d; j++) v[j] += T[o + j]; }
152
+ let n = 0;
153
+ for (let j = 0; j < d; j++) n += v[j] * v[j];
154
+ n = Math.sqrt(n);
155
+ if (!n) return null;
156
+ for (let j = 0; j < d; j++) v[j] /= n;
157
+ return v;
158
+ }
159
+ }
160
+
161
+ function f16(h) {
162
+ const s = (h & 0x8000) ? -1 : 1, e = (h >> 10) & 0x1f, f = h & 0x3ff;
163
+ if (e === 0) return s * Math.pow(2, -14) * (f / 1024);
164
+ if (e === 31) return f ? NaN : s * Infinity;
165
+ return s * Math.pow(2, e - 15) * (1 + f / 1024);
166
+ }
167
+
168
+ /** int8 with one scale per vector: v ≈ q * scale. */
169
+ export function quantize(v) {
170
+ let m = 0;
171
+ for (let j = 0; j < v.length; j++) { const a = Math.abs(v[j]); if (a > m) m = a; }
172
+ const q = new Int8Array(v.length);
173
+ const scale = m / 127 || 1;
174
+ for (let j = 0; j < v.length; j++) q[j] = Math.round(v[j] / scale);
175
+ return { q, scale };
176
+ }
177
+
178
+ // ── model download (explicit, user-initiated) ──────────────────────────────────────────────────
179
+
180
+ export async function installModel({ log = console.error } = {}) {
181
+ const dir = modelDir();
182
+ fs.mkdirSync(dir, { recursive: true });
183
+ for (const [name, digest] of Object.entries(MODEL.files)) {
184
+ const dest = path.join(dir, name);
185
+ if (fs.existsSync(dest) && sha256File(dest) === digest) { log(`${name}: present`); continue; }
186
+ const url = `https://huggingface.co/${MODEL.repo}/resolve/${MODEL.revision}/${name}`;
187
+ log(`downloading ${url}`);
188
+ const res = await fetch(url, { redirect: 'follow' });
189
+ if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
190
+ const data = Buffer.from(await res.arrayBuffer());
191
+ const got = crypto.createHash('sha256').update(data).digest('hex');
192
+ if (got !== digest) throw new Error(`${name}: SHA-256 mismatch (expected ${digest}, got ${got}); nothing was installed`);
193
+ const tmp = dest + '.part';
194
+ fs.writeFileSync(tmp, data);
195
+ fs.renameSync(tmp, dest);
196
+ log(`${name}: ${(data.length / 1048576).toFixed(1)} MB, verified`);
197
+ }
198
+ return dir;
199
+ }
200
+
201
+ function sha256File(p) { return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Vector index over definitions for the semantic channel (see semantic.mjs).
3
+ *
4
+ * One int8 vector per definition, embedded from its qualified name, signature, doc comment and
5
+ * the head of its body. Vectors are filled lazily, file by file, keyed by the file's content
6
+ * hash, so an edit re-embeds only the files it touched. Search is an exact scan over the matrix
7
+ * held in memory (no ANN structure to go stale): ~1 ms per 10k definitions.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { StaticModel, quantize, semanticEnabled } from './semantic.mjs';
12
+ import { splitIdentifier } from './tokenize.mjs';
13
+
14
+ const EMBED_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl',
15
+ 'function', 'method', 'constructor', 'macro', 'section']);
16
+ const BODY_LINES = 60;
17
+
18
+ let sharedModel = null;
19
+ function model() { return (sharedModel ??= new StaticModel()); }
20
+
21
+ /** The text a definition is embedded from. */
22
+ export function symbolText(sym, path_, lines) {
23
+ const words = splitIdentifier(sym.name).join(' ');
24
+ const head = `${path_}\n${sym.qname} (${words})\n${sym.sig ?? ''}\n${sym.doc ?? ''}\n`;
25
+ const body = lines.slice(sym.start_line - 1, Math.min(sym.end_line, sym.start_line - 1 + BODY_LINES)).join('\n');
26
+ return head + body;
27
+ }
28
+
29
+ export class VectorIndex {
30
+ constructor(indexer, { m = null } = {}) {
31
+ this.ix = indexer;
32
+ this.store = indexer.store;
33
+ this.m = m;
34
+ this.version = -1;
35
+ this.ids = null; // Int32Array of symbol ids
36
+ this.mat = null; // Int8Array rows × dim
37
+ this.scales = null; // Float32Array
38
+ }
39
+
40
+ static maybe(indexer) {
41
+ try { return semanticEnabled() ? new VectorIndex(indexer) : null; } catch { return null; }
42
+ }
43
+
44
+ get model() { return (this.m ??= model()); }
45
+
46
+ /** Embed the files whose vectors are missing or stale; returns how many files were embedded. */
47
+ fill() {
48
+ const s = this.store;
49
+ const stale = s.all(`SELECT f.id, f.path, f.hash FROM files f LEFT JOIN vec_files v ON v.file_id = f.id
50
+ WHERE v.hash IS NULL OR v.hash <> f.hash`);
51
+ const gone = s.all('SELECT v.file_id AS id FROM vec_files v LEFT JOIN files f ON f.id = v.file_id WHERE f.id IS NULL');
52
+ if (!stale.length && !gone.length) return 0;
53
+ const dim = this.model.dim;
54
+ let n = 0;
55
+ const BATCH = 200;
56
+ for (let i = 0; i < stale.length; i += BATCH) {
57
+ s.tx(() => {
58
+ for (const f of stale.slice(i, i + BATCH)) {
59
+ s.run('DELETE FROM vectors WHERE file_id = ?', f.id);
60
+ let src = null;
61
+ try { src = fs.readFileSync(path.join(this.ix.root, f.path), 'utf8'); } catch { /* deleted since */ }
62
+ if (src != null) {
63
+ const lines = src.split('\n');
64
+ const syms = s.all('SELECT id, name, qname, kind, sig, doc, start_line, end_line FROM symbols WHERE file_id = ?', f.id);
65
+ for (const sym of syms) {
66
+ if (!EMBED_KINDS.has(sym.kind)) continue;
67
+ const v = this.model.embed(symbolText(sym, f.path, lines));
68
+ if (!v || v.length !== dim) continue;
69
+ const { q, scale } = quantize(v);
70
+ s.run('INSERT INTO vectors (symbol_id, file_id, scale, v) VALUES (?, ?, ?, ?)', sym.id, f.id, scale, Buffer.from(q.buffer));
71
+ }
72
+ }
73
+ s.run('INSERT INTO vec_files (file_id, hash) VALUES (?, ?) ON CONFLICT(file_id) DO UPDATE SET hash = excluded.hash', f.id, f.hash);
74
+ n++;
75
+ }
76
+ });
77
+ }
78
+ if (gone.length) s.tx(() => { for (const g of gone) { s.run('DELETE FROM vectors WHERE file_id = ?', g.id); s.run('DELETE FROM vec_files WHERE file_id = ?', g.id); } });
79
+ return n;
80
+ }
81
+
82
+ #load() {
83
+ if (this.version === this.ix.version && this.mat) return;
84
+ this.fill();
85
+ const rows = this.store.all('SELECT symbol_id, scale, v FROM vectors');
86
+ const dim = this.model.dim;
87
+ this.ids = new Int32Array(rows.length);
88
+ this.scales = new Float32Array(rows.length);
89
+ this.mat = new Int8Array(rows.length * dim);
90
+ rows.forEach((r, i) => {
91
+ this.ids[i] = r.symbol_id;
92
+ this.scales[i] = r.scale;
93
+ this.mat.set(new Int8Array(r.v.buffer, r.v.byteOffset, dim), i * dim);
94
+ });
95
+ this.version = this.ix.version;
96
+ }
97
+
98
+ /** Top-k definitions by cosine similarity to the query: Map(symbol id → cosine). */
99
+ search(query, k = 200) {
100
+ const qv = this.model.embed(query);
101
+ if (!qv) return new Map();
102
+ this.#load();
103
+ const dim = this.model.dim, M = this.mat, n = this.ids.length;
104
+ const top = []; // small min-heap would be faster; k ≪ n and n is modest, a threshold scan is enough
105
+ let floor = -Infinity;
106
+ for (let i = 0; i < n; i++) {
107
+ let d = 0;
108
+ const o = i * dim;
109
+ for (let j = 0; j < dim; j++) d += M[o + j] * qv[j];
110
+ d *= this.scales[i];
111
+ if (top.length < k) { top.push([i, d]); if (top.length === k) { top.sort((a, b) => a[1] - b[1]); floor = top[0][1]; } }
112
+ else if (d > floor) {
113
+ top[0] = [i, d];
114
+ // re-establish the minimum at index 0 (insertion into sorted position)
115
+ let p = 0;
116
+ while (p + 1 < top.length && top[p][1] > top[p + 1][1]) { const t = top[p]; top[p] = top[p + 1]; top[p + 1] = t; p++; }
117
+ floor = top[0][1];
118
+ }
119
+ }
120
+ return new Map(top.map(([i, d]) => [this.ids[i], d]));
121
+ }
122
+ }