@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.
@@ -0,0 +1,90 @@
1
+ import type { RepoScan } from "../scan.js";
2
+ import { rrf } from "../util.js";
3
+ import { byStr } from "../sort.js";
4
+ import { searchIndex, type SearchOptions, type SearchResult } from "../bm25.js";
5
+ import { encode, intDot } from "./encode.js";
6
+ import type { EmbeddingIndex } from "./index.js";
7
+ import type { StaticEmbedModel } from "./model.js";
8
+
9
+ const DEFAULT_LIMIT = 20;
10
+ const RRF_K = 60; // reuse the engine-wide RRF damping constant
11
+
12
+ export interface SemanticSearchOptions extends SearchOptions {
13
+ // The loaded static model, needed to encode the QUERY into the same int8 space
14
+ // as the corpus. Absent → the search degrades to pure lexical (no throw).
15
+ model?: StaticEmbedModel;
16
+ // RRF damping (default 60), exposed for parity with the shared rrf helper.
17
+ rrfK?: number;
18
+ }
19
+
20
+ // A fused result. Extends the lexical SearchResult additively: `semanticSymbol`
21
+ // is the corpus symbol whose embedding was closest to the query for this file
22
+ // (absent when the file was contributed only by the lexical side, or when the
23
+ // search degraded to lexical).
24
+ export interface SemanticSearchResult extends SearchResult {
25
+ semanticSymbol?: string;
26
+ }
27
+
28
+ // RRF-fused semantic + lexical search. The two rankings live on incomparable
29
+ // scales (BM25 score vs integer dot product), so we fuse by RANK via the shared
30
+ // `rrf` helper (k=60) rather than any linear score blend. Deterministic end to
31
+ // end: lexical is deterministic, the semantic ranking sorts by exact integer dot
32
+ // (ties broken by path), and the fused list sorts by RRF score (ties by path).
33
+ //
34
+ // DEGRADATION: with no model, no index, or an empty index, this returns the pure
35
+ // lexical ranking unchanged (as SemanticSearchResult[] with no `semanticSymbol`)
36
+ // — the caller stays on exit 0 and simply prints a note.
37
+ export function searchSemantic(
38
+ scan: RepoScan,
39
+ query: string,
40
+ index: EmbeddingIndex | undefined,
41
+ opts: SemanticSearchOptions = {},
42
+ ): SemanticSearchResult[] {
43
+ const limit = opts.limit ?? DEFAULT_LIMIT;
44
+ // Pull a deeper lexical list than the final limit so RRF has enough overlap to
45
+ // fuse meaningfully, then trim after fusion.
46
+ const lexical = searchIndex(scan, query, { limit: Math.max(limit, 50), fuzzy: opts.fuzzy });
47
+
48
+ if (!opts.model || !index || index.records.length === 0) {
49
+ return lexical.slice(0, limit); // pure-lexical degradation
50
+ }
51
+
52
+ const q = encode(opts.model, query);
53
+ // Best (highest integer dot) record per file, remembering its symbol.
54
+ const bestByFile = new Map<string, { score: number; symbol?: string }>();
55
+ for (const r of index.records) {
56
+ const dot = intDot(q, r.vec);
57
+ const prev = bestByFile.get(r.file);
58
+ if (!prev || dot > prev.score) bestByFile.set(r.file, { score: dot, symbol: r.symbol });
59
+ }
60
+
61
+ // Semantic ranked file list: positive similarity only, best-first, ties by path.
62
+ const semList = [...bestByFile.entries()]
63
+ .filter(([, v]) => v.score > 0)
64
+ .sort((a, b) => b[1].score - a[1].score || byStr(a[0], b[0]))
65
+ .map(([file]) => file);
66
+ const lexList = lexical.map((r) => r.file);
67
+
68
+ // Fuse the two ranked lists (identity keyOf — items are already file paths).
69
+ const fused = rrf<string>([lexList, semList], (f) => f, opts.rrfK ?? RRF_K);
70
+
71
+ const lexByFile = new Map(lexical.map((r) => [r.file, r] as const));
72
+ const results: SemanticSearchResult[] = [...fused.entries()]
73
+ .sort((a, b) => b[1] - a[1] || byStr(a[0], b[0]))
74
+ .map(([file, score]) => {
75
+ const lex = lexByFile.get(file);
76
+ const res: SemanticSearchResult = {
77
+ file,
78
+ score: Number(score.toFixed(4)),
79
+ matchedTerms: lex?.matchedTerms ?? [],
80
+ topSymbols: lex?.topSymbols ?? [],
81
+ };
82
+ const sem = bestByFile.get(file);
83
+ if (sem?.symbol) res.semanticSymbol = sem.symbol;
84
+ if (lex?.fuzzyTerms) res.fuzzyTerms = lex.fuzzyTerms;
85
+ return res;
86
+ });
87
+ return results.slice(0, limit);
88
+ }
89
+
90
+ export { DEFAULT_LIMIT as SEMANTIC_DEFAULT_LIMIT };
package/src/engine-cli.ts CHANGED
@@ -19,6 +19,9 @@ import { symbolComplexity, riskHotspots } from "./complexity.js";
19
19
  import { renderMermaid } from "./viz.js";
20
20
  import { searchIndex } from "./bm25.js";
21
21
  import { checkRules, parseRules } from "./rules.js";
22
+ import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, resolveEmbedPullUrl } from "./embed/model.js";
23
+ import { buildEmbeddingIndex, serializeEmbeddings } from "./embed/index.js";
24
+ import { searchSemantic } from "./embed/search.js";
22
25
 
23
26
  const HELP = `codeindex engine v${ENGINE_VERSION} — deterministic repo indexing
24
27
 
@@ -37,7 +40,14 @@ Commands:
37
40
  churn Per-file git commit counts (JSON; --since <ref> to bound)
38
41
  grep Search: cli.mjs grep <pattern> --repo <dir> (JSON hits)
39
42
  search Keyless BM25 lexical search over symbol names, path segments,
40
- markdown headings and summaries: cli.mjs search "<query>" --repo <dir>
43
+ markdown headings and summaries: cli.mjs search "<query>" --repo <dir>.
44
+ --semantic fuses in the deterministic static-embedding tier (RRF)
45
+ when a model is present; degrades to lexical (exit 0) when absent
46
+ embed Deterministic static-embedding tier (opt-in by model asset):
47
+ embed status Report the resolved model + EMBED_VERSION (JSON)
48
+ embed build Write embeddings.bin into --out <dir> from the repo
49
+ embed pull Fetch the model asset into CODEINDEX_EMBED_DIR (or
50
+ <repo>/.codeindex/models/) — needs CODEINDEX_EMBED_URL
41
51
  rules Architecture rules (forbidden edges, cycles, orphans) validated
42
52
  against the link-graph: --config <codeindex.rules.json>; exits 1
43
53
  on any error-severity violation (a CI gate)
@@ -63,6 +73,10 @@ Flags:
63
73
  --no-ast Skip tree-sitter grammars even when present (regex tier)
64
74
  --config <file> Rules config for \`rules\` (JSON: [{name, from, to, …}])
65
75
  --limit <n> Max results for \`search\` (default 20)
76
+ --no-fuzzy \`search\`: disable trigram fuzzy fallback for query terms
77
+ with zero document frequency (default: enabled)
78
+ --semantic \`search\`: RRF-fuse the deterministic static-embedding tier
79
+ with lexical (needs a model asset; lexical-only otherwise)
66
80
  --recall \`callers\`: recall-oriented binding (issue #7) — relaxes
67
81
  the JS/TS import gate to unique repo-wide names and labels
68
82
  each site corroborated|unique-name
@@ -84,13 +98,15 @@ interface CliFlags {
84
98
  budgetTokens?: number;
85
99
  config?: string; // rules config path
86
100
  limit?: number; // search result cap
101
+ fuzzy: boolean; // search: trigram fuzzy fallback for df==0 terms (default true)
102
+ semantic: boolean; // search: RRF-fuse the static-embedding tier (default false)
87
103
  recall?: boolean; // callers: recall-oriented binding
88
104
  projectRoot?: string; // scip: override Metadata.project_root
89
105
  positional?: string; // e.g. the grep pattern or search query
90
106
  }
91
107
 
92
108
  function parseFlags(args: string[]): CliFlags {
93
- const flags: CliFlags = { repo: process.cwd(), include: [], exclude: [], gitignore: true, noAst: false };
109
+ const flags: CliFlags = { repo: process.cwd(), include: [], exclude: [], gitignore: true, noAst: false, fuzzy: true, semantic: false };
94
110
  for (let i = 0; i < args.length; i++) {
95
111
  const a = args[i]!;
96
112
  const next = (): string => {
@@ -122,6 +138,8 @@ function parseFlags(args: string[]): CliFlags {
122
138
  else if (a === "--since") flags.since = next();
123
139
  else if (a === "--config") flags.config = resolve(next());
124
140
  else if (a === "--limit") flags.limit = num();
141
+ else if (a === "--no-fuzzy") flags.fuzzy = false;
142
+ else if (a === "--semantic") flags.semantic = true;
125
143
  else if (a === "--recall") flags.recall = true;
126
144
  else if (!a.startsWith("--") && flags.positional === undefined) flags.positional = a;
127
145
  else throw new Error(`unknown flag: ${a}`);
@@ -199,7 +217,18 @@ export async function runCli(argv: string[]): Promise<void> {
199
217
  cachePath,
200
218
  JSON.stringify({ schemaVersion: SCHEMA_VERSION, extractorVersion: EXTRACTOR_VERSION, files }) + "\n",
201
219
  );
202
- process.stderr.write(`codeindex: ${scan.files.length} files ${outDir}/graph.json + symbols.json${scan.capped ? " (capped)" : ""}\n`);
220
+ // Deterministic embeddings sidecar: written next to graph.json ONLY when a
221
+ // model asset is present (opt-in). Silently skipped otherwise — no model, no
222
+ // embeddings.bin, no impact on the graph/symbols consumers.
223
+ let embedNote = "";
224
+ const modelDir = resolveEmbedModelDir(flags.repo);
225
+ const model = modelDir ? loadEmbedModel(modelDir) : undefined;
226
+ if (model) {
227
+ const index = buildEmbeddingIndex(scan, model);
228
+ writeFileSync(join(outDir, "embeddings.bin"), serializeEmbeddings(index));
229
+ embedNote = ` + embeddings.bin (${index.records.length} records, model ${model.modelId})`;
230
+ }
231
+ process.stderr.write(`codeindex: ${scan.files.length} files → ${outDir}/graph.json + symbols.json${embedNote}${scan.capped ? " (capped)" : ""}\n`);
203
232
  } else if (cmd === "scan") {
204
233
  const { scan } = buildIndexArtifacts(flags.repo, scanOptions(flags));
205
234
  const summary = {
@@ -234,8 +263,85 @@ export async function runCli(argv: string[]): Promise<void> {
234
263
  } else if (cmd === "search") {
235
264
  if (!flags.positional) throw new Error('search needs a query: cli.mjs search "<query>" --repo <dir>');
236
265
  const scan = scanRepo(flags.repo, scanOptions(flags));
237
- const results = searchIndex(scan, flags.positional, { limit: flags.limit });
238
- emit(JSON.stringify(results, null, 2) + "\n", flags.out);
266
+ if (flags.semantic) {
267
+ const modelDir = resolveEmbedModelDir(flags.repo);
268
+ const model = modelDir ? loadEmbedModel(modelDir) : undefined;
269
+ if (!model) {
270
+ // Degradation: --semantic without a model → lexical results + a stderr
271
+ // note, exit 0. The results shape is a superset of the lexical one.
272
+ process.stderr.write(
273
+ "codeindex: semantic search unavailable (no embedding model present) — returning lexical results; run `codeindex embed pull` to enable it\n",
274
+ );
275
+ const results = searchIndex(scan, flags.positional, { limit: flags.limit, fuzzy: flags.fuzzy });
276
+ emit(JSON.stringify(results, null, 2) + "\n", flags.out);
277
+ } else {
278
+ const index = buildEmbeddingIndex(scan, model);
279
+ const results = searchSemantic(scan, flags.positional, index, { model, limit: flags.limit, fuzzy: flags.fuzzy });
280
+ emit(JSON.stringify(results, null, 2) + "\n", flags.out);
281
+ }
282
+ } else {
283
+ const results = searchIndex(scan, flags.positional, { limit: flags.limit, fuzzy: flags.fuzzy });
284
+ emit(JSON.stringify(results, null, 2) + "\n", flags.out);
285
+ }
286
+ } else if (cmd === "embed") {
287
+ const sub = flags.positional;
288
+ const modelDir = resolveEmbedModelDir(flags.repo);
289
+ if (sub === "status") {
290
+ const model = modelDir ? loadEmbedModel(modelDir) : undefined;
291
+ const status = {
292
+ embedVersion: EMBED_VERSION,
293
+ model: model
294
+ ? { present: true, dir: modelDir, modelId: model.modelId, dim: model.dim, vocabSize: model.vocabSize }
295
+ : { present: false },
296
+ endpoint: process.env.CODEINDEX_EMBED_ENDPOINT ?? null,
297
+ };
298
+ emit(JSON.stringify(status, null, 2) + "\n", flags.out);
299
+ } else if (sub === "build") {
300
+ if (!flags.out) throw new Error("embed build needs --out <dir>");
301
+ if (!modelDir) {
302
+ process.stderr.write("codeindex: no embedding model present — run `codeindex embed pull` first (nothing written)\n");
303
+ process.exitCode = 1;
304
+ return;
305
+ }
306
+ const model = loadEmbedModel(modelDir)!;
307
+ mkdirSync(flags.out, { recursive: true });
308
+ const scan = scanRepo(flags.repo, scanOptions(flags));
309
+ const index = buildEmbeddingIndex(scan, model);
310
+ writeFileSync(join(flags.out, "embeddings.bin"), serializeEmbeddings(index));
311
+ process.stderr.write(`codeindex: ${index.records.length} embedding records → ${flags.out}/embeddings.bin (model ${model.modelId})\n`);
312
+ } else if (sub === "pull") {
313
+ const url = resolveEmbedPullUrl();
314
+ if (!url) {
315
+ // Clean, actionable failure — the official asset is not published yet.
316
+ process.stderr.write(
317
+ "codeindex: no model URL configured. The official static-embedding asset is not published yet.\n" +
318
+ "Set CODEINDEX_EMBED_URL to a model.json URL (optionally CODEINDEX_EMBED_DIR as the destination), then re-run `codeindex embed pull`.\n",
319
+ );
320
+ process.exitCode = 1;
321
+ return;
322
+ }
323
+ const destDir = process.env.CODEINDEX_EMBED_DIR ?? join(flags.repo, ".codeindex", "models");
324
+ mkdirSync(destDir, { recursive: true });
325
+ process.stderr.write(`codeindex: fetching model from ${url} → ${join(destDir, "model.json")}\n`);
326
+ const res = await fetch(url);
327
+ if (!res.ok) {
328
+ process.stderr.write(`codeindex: pull failed — HTTP ${res.status} from ${url}\n`);
329
+ process.exitCode = 1;
330
+ return;
331
+ }
332
+ const body = await res.text();
333
+ try {
334
+ JSON.parse(body);
335
+ } catch {
336
+ process.stderr.write("codeindex: pull failed — response is not a valid model.json (expected JSON)\n");
337
+ process.exitCode = 1;
338
+ return;
339
+ }
340
+ writeFileSync(join(destDir, "model.json"), body);
341
+ process.stderr.write(`codeindex: model written to ${join(destDir, "model.json")}\n`);
342
+ } else {
343
+ throw new Error("embed needs a subcommand: pull | build | status");
344
+ }
239
345
  } else if (cmd === "rules") {
240
346
  if (!flags.config) throw new Error("rules needs --config <codeindex.rules.json>");
241
347
  const rules = parseRules(JSON.parse(readFileSync(flags.config, "utf8")));
package/src/engine.ts CHANGED
@@ -103,6 +103,27 @@ export type { SearchHit, GrepOptions } from "./grep.js";
103
103
  export { searchIndex, subtokens } from "./bm25.js";
104
104
  export type { SearchOptions, SearchResult } from "./bm25.js";
105
105
 
106
+ // Deterministic static-embedding tier (v2.10.0): a keyless, byte-deterministic
107
+ // semantic search, opt-in by model-asset presence (models NEVER ship in the
108
+ // tarball). EMBED_VERSION is dedicated to the embeddings.bin sidecar and is
109
+ // independent of SCHEMA_VERSION / EXTRACTOR_VERSION.
110
+ export {
111
+ EMBED_VERSION,
112
+ resolveEmbedModelDir,
113
+ hasEmbedModel,
114
+ loadEmbedModel,
115
+ resolveEmbedPullUrl,
116
+ } from "./embed/model.js";
117
+ export type { StaticEmbedModel } from "./embed/model.js";
118
+ export { encode, tokenize, wordpiece, basicTokenize, roundHalfToEven, intDot } from "./embed/encode.js";
119
+ export { buildEmbeddingIndex, serializeEmbeddings, deserializeEmbeddings } from "./embed/index.js";
120
+ export type { EmbeddingIndex, EmbeddingRecord } from "./embed/index.js";
121
+ export { searchSemantic } from "./embed/search.js";
122
+ export type { SemanticSearchOptions, SemanticSearchResult } from "./embed/search.js";
123
+ // HTTP endpoint tier (v2.11 preview — NOT wired into the CLI/MCP here).
124
+ export { embedViaEndpoint, resolveEmbedEndpoint } from "./embed/endpoint.js";
125
+ export type { EmbedEndpointOptions } from "./embed/endpoint.js";
126
+
106
127
  // Architecture rules: forbidden edges + cycles/orphans builtins (issue #4).
107
128
  export { checkRules, parseRules } from "./rules.js";
108
129
  export type { ArchRule, ForbiddenEdgeRule, BuiltinRule, RuleSeverity, RuleViolation } from "./rules.js";
package/src/mcp.ts CHANGED
@@ -25,6 +25,9 @@ import { replaceSymbolBody, insertAfterSymbol, insertBeforeSymbol } from "./edit
25
25
  import { writeMemory, readMemory, deleteMemory, listMemories } from "./memory.js";
26
26
  import { searchIndex } from "./bm25.js";
27
27
  import { checkRules, parseRules } from "./rules.js";
28
+ import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel } from "./embed/model.js";
29
+ import { buildEmbeddingIndex } from "./embed/index.js";
30
+ import { searchSemantic } from "./embed/search.js";
28
31
 
29
32
  interface RpcRequest {
30
33
  jsonrpc: "2.0";
@@ -266,7 +269,7 @@ const TOOLS = [
266
269
  {
267
270
  name: "search",
268
271
  description:
269
- 'Natural-language-ish lexical search: BM25 ranking (k1=1.2, b=0.75) over symbol names (camelCase/snake_case subtokens), file path segments, markdown headings and summary lines. NOT embeddings — deterministic, diacritic-folded, zero API keys. Answers "where is auth handled?"-style queries with ranked files, matched terms and top symbols.',
272
+ 'Natural-language-ish lexical search: BM25 ranking (k1=1.2, b=0.75) over symbol names (camelCase/snake_case subtokens), file path segments, markdown headings and summary lines. NOT embeddings by default — deterministic, diacritic-folded, zero API keys. Answers "where is auth handled?"-style queries with ranked files, matched terms and top symbols. Query terms with zero document frequency get a deterministic trigram-fuzzy fallback (typo-tolerant) unless `fuzzy: false`. Set `semantic: true` to RRF-fuse the deterministic static-embedding tier when a model asset is present (degrades to lexical otherwise — see embed_status).',
270
273
  inputSchema: {
271
274
  type: "object",
272
275
  properties: {
@@ -274,10 +277,26 @@ const TOOLS = [
274
277
  ...scopeProps,
275
278
  query: { type: "string", description: "Natural-language or identifier query" },
276
279
  limit: { type: "number", description: "Max results (default 20)" },
280
+ fuzzy: {
281
+ type: "boolean",
282
+ description:
283
+ "Trigram fuzzy fallback for query terms with zero document frequency (default true)",
284
+ },
285
+ semantic: {
286
+ type: "boolean",
287
+ description:
288
+ "RRF-fuse the deterministic static-embedding tier with lexical when a model asset is present (default false; silently lexical-only when no model)",
289
+ },
277
290
  },
278
291
  required: ["repo", "query"],
279
292
  },
280
293
  },
294
+ {
295
+ name: "embed_status",
296
+ description:
297
+ "Report the deterministic static-embedding tier: whether a model asset is resolved (opt-in, never shipped in the package), its modelId/dim, EMBED_VERSION, and any configured HTTP endpoint. Use to check whether `search` with semantic:true will actually fuse embeddings or degrade to lexical.",
298
+ inputSchema: { type: "object", properties: { ...repoProp }, required: ["repo"] },
299
+ },
281
300
  {
282
301
  name: "check_rules",
283
302
  description:
@@ -435,10 +454,34 @@ function callTool(name: string, args: Record<string, unknown>): string {
435
454
  if (name === "search") {
436
455
  const query = str(args.query);
437
456
  if (!query) throw new Error("`query` is required");
438
- const results = searchIndex(scanRepo(repo, scanOpts), query, {
439
- limit: typeof args.limit === "number" ? args.limit : undefined,
440
- });
441
- return JSON.stringify(results, null, 2);
457
+ const scan = scanRepo(repo, scanOpts);
458
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
459
+ const fuzzy = typeof args.fuzzy === "boolean" ? args.fuzzy : undefined;
460
+ if (args.semantic === true) {
461
+ const modelDir = resolveEmbedModelDir(repo);
462
+ const model = modelDir ? loadEmbedModel(modelDir) : undefined;
463
+ if (model) {
464
+ const index = buildEmbeddingIndex(scan, model);
465
+ return JSON.stringify(searchSemantic(scan, query, index, { model, limit, fuzzy }), null, 2);
466
+ }
467
+ // Degrade to lexical (opt-in tier not activated) — no throw.
468
+ }
469
+ return JSON.stringify(searchIndex(scan, query, { limit, fuzzy }), null, 2);
470
+ }
471
+ if (name === "embed_status") {
472
+ const modelDir = resolveEmbedModelDir(repo);
473
+ const model = modelDir ? loadEmbedModel(modelDir) : undefined;
474
+ return JSON.stringify(
475
+ {
476
+ embedVersion: EMBED_VERSION,
477
+ model: model
478
+ ? { present: true, dir: modelDir, modelId: model.modelId, dim: model.dim, vocabSize: model.vocabSize }
479
+ : { present: false },
480
+ endpoint: process.env.CODEINDEX_EMBED_ENDPOINT ?? null,
481
+ },
482
+ null,
483
+ 2,
484
+ );
442
485
  }
443
486
  if (name === "check_rules") {
444
487
  const rules = parseRules(args.rules); // throws a descriptive error on a malformed payload
package/src/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Single source of truth for the engine version the bundle reports. Kept in
2
2
  // lockstep with package.json by the release pipeline. Do not edit by hand
3
3
  // outside a release.
4
- export const ENGINE_VERSION = "2.8.1";
4
+ export const ENGINE_VERSION = "2.10.0";
5
5
 
6
6
  // Bumped whenever the on-disk artifact shape changes, so a consumer can reject
7
7
  // an index written by an incompatible engine instead of misreading it. The