@maxgfr/codeindex 2.9.0 → 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/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)
@@ -65,6 +75,8 @@ Flags:
65
75
  --limit <n> Max results for \`search\` (default 20)
66
76
  --no-fuzzy \`search\`: disable trigram fuzzy fallback for query terms
67
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)
68
80
  --recall \`callers\`: recall-oriented binding (issue #7) — relaxes
69
81
  the JS/TS import gate to unique repo-wide names and labels
70
82
  each site corroborated|unique-name
@@ -87,13 +99,14 @@ interface CliFlags {
87
99
  config?: string; // rules config path
88
100
  limit?: number; // search result cap
89
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)
90
103
  recall?: boolean; // callers: recall-oriented binding
91
104
  projectRoot?: string; // scip: override Metadata.project_root
92
105
  positional?: string; // e.g. the grep pattern or search query
93
106
  }
94
107
 
95
108
  function parseFlags(args: string[]): CliFlags {
96
- const flags: CliFlags = { repo: process.cwd(), include: [], exclude: [], gitignore: true, noAst: false, fuzzy: true };
109
+ const flags: CliFlags = { repo: process.cwd(), include: [], exclude: [], gitignore: true, noAst: false, fuzzy: true, semantic: false };
97
110
  for (let i = 0; i < args.length; i++) {
98
111
  const a = args[i]!;
99
112
  const next = (): string => {
@@ -126,6 +139,7 @@ function parseFlags(args: string[]): CliFlags {
126
139
  else if (a === "--config") flags.config = resolve(next());
127
140
  else if (a === "--limit") flags.limit = num();
128
141
  else if (a === "--no-fuzzy") flags.fuzzy = false;
142
+ else if (a === "--semantic") flags.semantic = true;
129
143
  else if (a === "--recall") flags.recall = true;
130
144
  else if (!a.startsWith("--") && flags.positional === undefined) flags.positional = a;
131
145
  else throw new Error(`unknown flag: ${a}`);
@@ -203,7 +217,18 @@ export async function runCli(argv: string[]): Promise<void> {
203
217
  cachePath,
204
218
  JSON.stringify({ schemaVersion: SCHEMA_VERSION, extractorVersion: EXTRACTOR_VERSION, files }) + "\n",
205
219
  );
206
- 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`);
207
232
  } else if (cmd === "scan") {
208
233
  const { scan } = buildIndexArtifacts(flags.repo, scanOptions(flags));
209
234
  const summary = {
@@ -238,8 +263,85 @@ export async function runCli(argv: string[]): Promise<void> {
238
263
  } else if (cmd === "search") {
239
264
  if (!flags.positional) throw new Error('search needs a query: cli.mjs search "<query>" --repo <dir>');
240
265
  const scan = scanRepo(flags.repo, scanOptions(flags));
241
- const results = searchIndex(scan, flags.positional, { limit: flags.limit, fuzzy: flags.fuzzy });
242
- 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
+ }
243
345
  } else if (cmd === "rules") {
244
346
  if (!flags.config) throw new Error("rules needs --config <codeindex.rules.json>");
245
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. Query terms with zero document frequency get a deterministic trigram-fuzzy fallback (typo-tolerant) unless `fuzzy: false`.',
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: {
@@ -279,10 +282,21 @@ const TOOLS = [
279
282
  description:
280
283
  "Trigram fuzzy fallback for query terms with zero document frequency (default true)",
281
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
+ },
282
290
  },
283
291
  required: ["repo", "query"],
284
292
  },
285
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
+ },
286
300
  {
287
301
  name: "check_rules",
288
302
  description:
@@ -440,11 +454,34 @@ function callTool(name: string, args: Record<string, unknown>): string {
440
454
  if (name === "search") {
441
455
  const query = str(args.query);
442
456
  if (!query) throw new Error("`query` is required");
443
- const results = searchIndex(scanRepo(repo, scanOpts), query, {
444
- limit: typeof args.limit === "number" ? args.limit : undefined,
445
- fuzzy: typeof args.fuzzy === "boolean" ? args.fuzzy : undefined,
446
- });
447
- 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
+ );
448
485
  }
449
486
  if (name === "check_rules") {
450
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.9.0";
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