@maxgfr/codeindex 2.10.0 → 2.11.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.
@@ -1,40 +1,77 @@
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.
1
+ // HTTP embedding endpoint client — the "rich" tier (v2.11.0). The engine becomes
2
+ // a CONSUMER of a local containerized embedding server (e.g.
3
+ // ghcr.io/maxgfr/codeindex-embed): it POSTs corpus/query texts, receives float
4
+ // vectors, and runs them through the EXACT SAME L2-normalize + int8-quantize
5
+ // pipeline as the static tier (encode.ts `quantize`), so ranking stays a pure
6
+ // integer dot product. Node >=18 has global fetch, so this stays zero-dependency.
7
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.
8
+ // The contract: POST { texts: string[] } → { vectors: number[][] } to
9
+ // `${base}/embed`, plus GET `${base}/healthz`. `base` is CODEINDEX_EMBED_ENDPOINT
10
+ // (or opts.url). The library NEVER orchestrates docker; running the server is a
11
+ // user/CI concern (`codeindex embed serve` only prints/runs the docker command).
12
+ //
13
+ // DETERMINISM: this tier is deterministic PER IMAGE DIGEST (the model is baked
14
+ // into a pinned image), NOT byte-golden — endpoint float vectors are provider-
15
+ // dependent. That is why endpoint indexes are built at search time and never
16
+ // serialized to embeddings.bin (which carries a determinism guarantee).
17
+
18
+ import type { RepoScan } from "../scan.js";
19
+ import { quantize } from "./encode.js";
20
+ import { EMBED_VERSION } from "./model.js";
21
+ import { embeddingUnits, type EmbeddingIndex, type EmbeddingRecord } from "./index.js";
12
22
 
13
23
  export interface EmbedEndpointOptions {
14
- // Endpoint URL. Falls back to CODEINDEX_EMBED_ENDPOINT when omitted.
24
+ // Endpoint BASE url (e.g. http://localhost:8756). Falls back to
25
+ // CODEINDEX_EMBED_ENDPOINT when omitted.
15
26
  url?: string;
16
- // Abort the request after this many ms (default 30_000).
27
+ // Abort a request after this many ms (default from CODEINDEX_EMBED_TIMEOUT_MS,
28
+ // else 30_000).
17
29
  timeoutMs?: number;
18
30
  // Extra request headers (e.g. an auth token for a private endpoint).
19
31
  headers?: Record<string, string>;
32
+ // Max texts per POST /embed request when embedding a corpus (default 64).
33
+ batchSize?: number;
20
34
  }
21
35
 
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.
36
+ // Resolve the configured endpoint BASE url, or undefined when neither opts.url
37
+ // nor CODEINDEX_EMBED_ENDPOINT is set — the "endpoint tier not requested" signal.
24
38
  export function resolveEmbedEndpoint(opts: EmbedEndpointOptions = {}): string | undefined {
25
39
  const url = opts.url ?? process.env.CODEINDEX_EMBED_ENDPOINT;
26
40
  return url && url.trim() ? url.trim() : undefined;
27
41
  }
28
42
 
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.
43
+ function stripTrailingSlash(url: string): string {
44
+ return url.replace(/\/+$/, "");
45
+ }
46
+
47
+ // The POST /embed URL for a base. Idempotent when the base already ends in
48
+ // /embed (so pointing the env straight at the embed route also works).
49
+ export function embedEndpointUrl(base: string): string {
50
+ const b = stripTrailingSlash(base);
51
+ return b.endsWith("/embed") ? b : b + "/embed";
52
+ }
53
+
54
+ // The GET /healthz URL for a base (tolerates a base that already ends in /embed).
55
+ export function healthzUrl(base: string): string {
56
+ return stripTrailingSlash(base).replace(/\/embed$/, "") + "/healthz";
57
+ }
58
+
59
+ function resolveTimeout(opts: EmbedEndpointOptions): number {
60
+ if (typeof opts.timeoutMs === "number") return opts.timeoutMs;
61
+ const env = Number(process.env.CODEINDEX_EMBED_TIMEOUT_MS);
62
+ return Number.isFinite(env) && env > 0 ? env : 30_000;
63
+ }
64
+
65
+ // Request float embeddings for a batch of texts from the endpoint's /embed
66
+ // route. Throws a clear error when no endpoint is configured, the request times
67
+ // out / fails, or the response is malformed; the caller decides whether to
68
+ // degrade. NOTE: endpoint vectors are float and are NOT byte-deterministic.
33
69
  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)");
70
+ const base = resolveEmbedEndpoint(opts);
71
+ if (!base) throw new Error("no embedding endpoint configured (set CODEINDEX_EMBED_ENDPOINT or pass opts.url)");
72
+ const url = embedEndpointUrl(base);
36
73
  const controller = new AbortController();
37
- const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 30_000);
74
+ const timer = setTimeout(() => controller.abort(), resolveTimeout(opts));
38
75
  try {
39
76
  const res = await fetch(url, {
40
77
  method: "POST",
@@ -53,3 +90,55 @@ export async function embedViaEndpoint(texts: string[], opts: EmbedEndpointOptio
53
90
  clearTimeout(timer);
54
91
  }
55
92
  }
93
+
94
+ // Is the endpoint reachable? GETs /healthz and returns true only on a 2xx. Never
95
+ // throws — a down/timed-out endpoint returns false (used by `embed status`).
96
+ export async function probeEndpoint(base: string, opts: EmbedEndpointOptions = {}): Promise<boolean> {
97
+ const controller = new AbortController();
98
+ const timer = setTimeout(() => controller.abort(), resolveTimeout(opts));
99
+ try {
100
+ const res = await fetch(healthzUrl(base), { signal: controller.signal, headers: opts.headers });
101
+ return res.ok;
102
+ } catch {
103
+ return false;
104
+ } finally {
105
+ clearTimeout(timer);
106
+ }
107
+ }
108
+
109
+ // Encode ONE query string via the endpoint → int8 vector, run through the shared
110
+ // quantize pipeline (so it lives in the same int8 space as an endpoint-built
111
+ // corpus). Throws on an unreachable/malformed endpoint — the caller degrades.
112
+ export async function encodeQueryViaEndpoint(query: string, opts: EmbedEndpointOptions = {}): Promise<Int8Array> {
113
+ const [vec] = await embedViaEndpoint([query], opts);
114
+ if (!vec) throw new Error("embedding endpoint returned no vector for the query");
115
+ return quantize(vec);
116
+ }
117
+
118
+ // Build the int8 corpus index by embedding every corpus unit (the SAME units the
119
+ // static tier uses) via the endpoint, batched, then quantizing each float vector
120
+ // through the shared L2+int8 pipeline. The result plugs straight into
121
+ // searchSemantic's integer-dot ranking. Built at search time (never serialized).
122
+ export async function buildEndpointIndex(scan: RepoScan, opts: EmbedEndpointOptions = {}): Promise<EmbeddingIndex> {
123
+ const units = embeddingUnits(scan);
124
+ const batchSize = opts.batchSize && opts.batchSize > 0 ? opts.batchSize : 64;
125
+ const records: EmbeddingRecord[] = [];
126
+ let dim = 0;
127
+ for (let i = 0; i < units.length; i += batchSize) {
128
+ const batch = units.slice(i, i + batchSize);
129
+ const vectors = await embedViaEndpoint(batch.map((u) => u.text), opts);
130
+ if (vectors.length !== batch.length) {
131
+ throw new Error(`embedding endpoint returned ${vectors.length} vectors for ${batch.length} texts`);
132
+ }
133
+ for (let j = 0; j < batch.length; j++) {
134
+ const u = batch[j]!;
135
+ const vec = quantize(vectors[j]!);
136
+ if (vec.length > dim) dim = vec.length;
137
+ const rec: EmbeddingRecord = { file: u.file, vec };
138
+ if (u.symbol !== undefined) rec.symbol = u.symbol;
139
+ if (u.line !== undefined) rec.line = u.line;
140
+ records.push(rec);
141
+ }
142
+ }
143
+ return { embedVersion: EMBED_VERSION, modelId: "endpoint", dim, records };
144
+ }
@@ -34,11 +34,21 @@ function fileText(rel: string, title: string | undefined, summary: string | unde
34
34
  return [title ?? "", summary ?? "", ...headings, rel.replace(/\//g, " ")].join("\n");
35
35
  }
36
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[] = [];
37
+ // One corpus item to embed: its target (file, optional symbol/line) plus the
38
+ // exact text to encode. This is the SINGLE definition of "what the corpus is",
39
+ // shared by the static tier (buildEmbeddingIndex) and the endpoint tier
40
+ // (buildEndpointIndex) so both embed byte-identical texts in the same order and
41
+ // differ ONLY in the encoder. Deterministic: scan order (files by rel) then
42
+ // declaration order within a file, deduped by symbol name (bm25 parity).
43
+ export interface EmbeddingUnit {
44
+ file: string;
45
+ symbol?: string;
46
+ line?: number;
47
+ text: string;
48
+ }
49
+
50
+ export function embeddingUnits(scan: RepoScan): EmbeddingUnit[] {
51
+ const units: EmbeddingUnit[] = [];
42
52
  for (const f of scan.files) {
43
53
  const seen = new Set<string>();
44
54
  let hadSymbol = false;
@@ -46,20 +56,26 @@ export function buildEmbeddingIndex(scan: RepoScan, model: StaticEmbedModel): Em
46
56
  if (seen.has(s.name)) continue; // dedupe by name within a file (bm25 parity)
47
57
  seen.add(s.name);
48
58
  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
- });
59
+ units.push({ file: f.rel, symbol: s.name, line: s.line, text: symbolText(f.rel, s.name, s.signature, f.summary) });
55
60
  }
56
61
  if (!hadSymbol) {
57
62
  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
- }
63
+ if (text.replace(/\s+/g, "")) units.push({ file: f.rel, text });
61
64
  }
62
65
  }
66
+ return units;
67
+ }
68
+
69
+ // Build the corpus embedding index from a scan + a loaded model. Pure and
70
+ // deterministic (encode is byte-stable, scan order is fixed) → two builds of an
71
+ // unchanged repo produce byte-identical serialized bytes.
72
+ export function buildEmbeddingIndex(scan: RepoScan, model: StaticEmbedModel): EmbeddingIndex {
73
+ const records: EmbeddingRecord[] = embeddingUnits(scan).map((u) => {
74
+ const rec: EmbeddingRecord = { file: u.file, vec: encode(model, u.text) };
75
+ if (u.symbol !== undefined) rec.symbol = u.symbol;
76
+ if (u.line !== undefined) rec.line = u.line;
77
+ return rec;
78
+ });
63
79
  return { embedVersion: EMBED_VERSION, modelId: model.modelId, dim: model.dim, records };
64
80
  }
65
81
 
@@ -13,6 +13,10 @@ export interface SemanticSearchOptions extends SearchOptions {
13
13
  // The loaded static model, needed to encode the QUERY into the same int8 space
14
14
  // as the corpus. Absent → the search degrades to pure lexical (no throw).
15
15
  model?: StaticEmbedModel;
16
+ // A pre-encoded int8 query vector — the endpoint tier's escape hatch: it has
17
+ // no local model but has already quantized the endpoint's float query vector
18
+ // through the SAME pipeline as the corpus. Wins over `model` when both are set.
19
+ queryVec?: Int8Array;
16
20
  // RRF damping (default 60), exposed for parity with the shared rrf helper.
17
21
  rrfK?: number;
18
22
  }
@@ -45,11 +49,13 @@ export function searchSemantic(
45
49
  // fuse meaningfully, then trim after fusion.
46
50
  const lexical = searchIndex(scan, query, { limit: Math.max(limit, 50), fuzzy: opts.fuzzy });
47
51
 
48
- if (!opts.model || !index || index.records.length === 0) {
52
+ // Resolve the query vector: an already-encoded one (endpoint tier) wins;
53
+ // otherwise encode via the local model. No vector, no index, or an empty index
54
+ // → pure-lexical degradation.
55
+ const q = opts.queryVec ?? (opts.model ? encode(opts.model, query) : undefined);
56
+ if (!q || !index || index.records.length === 0) {
49
57
  return lexical.slice(0, limit); // pure-lexical degradation
50
58
  }
51
-
52
- const q = encode(opts.model, query);
53
59
  // Best (highest integer dot) record per file, remembering its symbol.
54
60
  const bestByFile = new Map<string, { score: number; symbol?: string }>();
55
61
  for (const r of index.records) {
package/src/engine-cli.ts CHANGED
@@ -22,6 +22,13 @@ import { checkRules, parseRules } from "./rules.js";
22
22
  import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, resolveEmbedPullUrl } from "./embed/model.js";
23
23
  import { buildEmbeddingIndex, serializeEmbeddings } from "./embed/index.js";
24
24
  import { searchSemantic } from "./embed/search.js";
25
+ import {
26
+ resolveEmbedEndpoint,
27
+ buildEndpointIndex,
28
+ encodeQueryViaEndpoint,
29
+ probeEndpoint,
30
+ } from "./embed/endpoint.js";
31
+ import { have, sh } from "./util.js";
25
32
 
26
33
  const HELP = `codeindex engine v${ENGINE_VERSION} — deterministic repo indexing
27
34
 
@@ -41,13 +48,17 @@ Commands:
41
48
  grep Search: cli.mjs grep <pattern> --repo <dir> (JSON hits)
42
49
  search Keyless BM25 lexical search over symbol names, path segments,
43
50
  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
51
+ --semantic fuses in an embedding tier (RRF) — the HTTP endpoint
52
+ (CODEINDEX_EMBED_ENDPOINT) if set, else a local static model;
53
+ degrades to lexical (exit 0) when neither is available/reachable
54
+ embed Embedding tiers (opt-in). Precedence: endpoint > static model:
55
+ embed status Effective mode (none/static/endpoint), model +
56
+ EMBED_VERSION, and endpoint reachability (JSON)
57
+ embed build Write embeddings.bin into --out <dir> (static tier)
49
58
  embed pull Fetch the model asset into CODEINDEX_EMBED_DIR (or
50
59
  <repo>/.codeindex/models/) — needs CODEINDEX_EMBED_URL
60
+ embed serve Print (or --run) the docker command that starts the
61
+ containerized embedding server (rich tier)
51
62
  rules Architecture rules (forbidden edges, cycles, orphans) validated
52
63
  against the link-graph: --config <codeindex.rules.json>; exits 1
53
64
  on any error-severity violation (a CI gate)
@@ -75,8 +86,10 @@ Flags:
75
86
  --limit <n> Max results for \`search\` (default 20)
76
87
  --no-fuzzy \`search\`: disable trigram fuzzy fallback for query terms
77
88
  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)
89
+ --semantic \`search\`: RRF-fuse an embedding tier with lexical — the
90
+ HTTP endpoint if CODEINDEX_EMBED_ENDPOINT is set, else a
91
+ local static model (lexical-only when neither is available)
92
+ --run \`embed serve\`: run the docker command instead of printing it
80
93
  --recall \`callers\`: recall-oriented binding (issue #7) — relaxes
81
94
  the JS/TS import gate to unique repo-wide names and labels
82
95
  each site corroborated|unique-name
@@ -101,6 +114,7 @@ interface CliFlags {
101
114
  fuzzy: boolean; // search: trigram fuzzy fallback for df==0 terms (default true)
102
115
  semantic: boolean; // search: RRF-fuse the static-embedding tier (default false)
103
116
  recall?: boolean; // callers: recall-oriented binding
117
+ run?: boolean; // `embed serve`: actually run the docker command (default: print)
104
118
  projectRoot?: string; // scip: override Metadata.project_root
105
119
  positional?: string; // e.g. the grep pattern or search query
106
120
  }
@@ -141,6 +155,7 @@ function parseFlags(args: string[]): CliFlags {
141
155
  else if (a === "--no-fuzzy") flags.fuzzy = false;
142
156
  else if (a === "--semantic") flags.semantic = true;
143
157
  else if (a === "--recall") flags.recall = true;
158
+ else if (a === "--run") flags.run = true;
144
159
  else if (!a.startsWith("--") && flags.positional === undefined) flags.positional = a;
145
160
  else throw new Error(`unknown flag: ${a}`);
146
161
  }
@@ -264,20 +279,42 @@ export async function runCli(argv: string[]): Promise<void> {
264
279
  if (!flags.positional) throw new Error('search needs a query: cli.mjs search "<query>" --repo <dir>');
265
280
  const scan = scanRepo(flags.repo, scanOptions(flags));
266
281
  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 });
282
+ const endpoint = resolveEmbedEndpoint();
283
+ const lexical = (): void => {
284
+ const results = searchIndex(scan, flags.positional!, { limit: flags.limit, fuzzy: flags.fuzzy });
276
285
  emit(JSON.stringify(results, null, 2) + "\n", flags.out);
286
+ };
287
+ if (endpoint) {
288
+ // Rich tier. The endpoint takes PRECEDENCE over a local static model:
289
+ // configuring CODEINDEX_EMBED_ENDPOINT is an explicit user intent. An
290
+ // unreachable/timed-out/malformed endpoint degrades straight to lexical
291
+ // (a stderr note, exit 0) — NOT to the static model.
292
+ try {
293
+ const index = await buildEndpointIndex(scan);
294
+ const queryVec = await encodeQueryViaEndpoint(flags.positional);
295
+ const results = searchSemantic(scan, flags.positional, index, { queryVec, limit: flags.limit, fuzzy: flags.fuzzy });
296
+ emit(JSON.stringify(results, null, 2) + "\n", flags.out);
297
+ } catch (e) {
298
+ process.stderr.write(
299
+ `codeindex: embedding endpoint ${endpoint} unavailable (${e instanceof Error ? e.message : e}) — returning lexical results\n`,
300
+ );
301
+ lexical();
302
+ }
277
303
  } 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);
304
+ const modelDir = resolveEmbedModelDir(flags.repo);
305
+ const model = modelDir ? loadEmbedModel(modelDir) : undefined;
306
+ if (!model) {
307
+ // Degradation: --semantic without a model or endpoint → lexical results
308
+ // + a stderr note, exit 0. The results shape is a superset of lexical.
309
+ process.stderr.write(
310
+ "codeindex: semantic search unavailable (no embedding model or endpoint) — returning lexical results; run `codeindex embed pull` or set CODEINDEX_EMBED_ENDPOINT to enable it\n",
311
+ );
312
+ lexical();
313
+ } else {
314
+ const index = buildEmbeddingIndex(scan, model);
315
+ const results = searchSemantic(scan, flags.positional, index, { model, limit: flags.limit, fuzzy: flags.fuzzy });
316
+ emit(JSON.stringify(results, null, 2) + "\n", flags.out);
317
+ }
281
318
  }
282
319
  } else {
283
320
  const results = searchIndex(scan, flags.positional, { limit: flags.limit, fuzzy: flags.fuzzy });
@@ -288,14 +325,54 @@ export async function runCli(argv: string[]): Promise<void> {
288
325
  const modelDir = resolveEmbedModelDir(flags.repo);
289
326
  if (sub === "status") {
290
327
  const model = modelDir ? loadEmbedModel(modelDir) : undefined;
291
- const status = {
328
+ const endpoint = resolveEmbedEndpoint();
329
+ // Effective mode with precedence: endpoint > static model > none.
330
+ const mode: "none" | "static" | "endpoint" = endpoint ? "endpoint" : model ? "static" : "none";
331
+ const status: Record<string, unknown> = {
292
332
  embedVersion: EMBED_VERSION,
333
+ mode,
293
334
  model: model
294
335
  ? { present: true, dir: modelDir, modelId: model.modelId, dim: model.dim, vocabSize: model.vocabSize }
295
336
  : { present: false },
296
- endpoint: process.env.CODEINDEX_EMBED_ENDPOINT ?? null,
337
+ endpoint: endpoint ?? null,
297
338
  };
339
+ // When an endpoint is configured, actually probe its reachability.
340
+ if (endpoint) status.endpointReachable = await probeEndpoint(endpoint);
298
341
  emit(JSON.stringify(status, null, 2) + "\n", flags.out);
342
+ } else if (sub === "serve") {
343
+ // Convenience only — the LIBRARY never orchestrates docker (engine.ts is
344
+ // side-effect-free). This lives in the CLI: it prints (or, with --run,
345
+ // executes) the docker command that starts the embedding server image.
346
+ const dockerArgs = ["run", "-d", "-p", "8756:8756", "ghcr.io/maxgfr/codeindex-embed:latest"];
347
+ const oneLiner = `docker ${dockerArgs.join(" ")}`;
348
+ if (!have("docker")) {
349
+ process.stderr.write(
350
+ "codeindex: docker not found on PATH. Install Docker, then run:\n " + oneLiner + "\n",
351
+ );
352
+ process.exitCode = 1;
353
+ return;
354
+ }
355
+ if (flags.run) {
356
+ process.stderr.write(`codeindex: starting embedding server → ${oneLiner}\n`);
357
+ const res = sh("docker", dockerArgs);
358
+ if (res.stdout.trim()) process.stdout.write(res.stdout.trim() + "\n"); // container id
359
+ if (!res.ok) {
360
+ process.stderr.write(res.stderr || "codeindex: docker run failed\n");
361
+ process.exitCode = 1;
362
+ return;
363
+ }
364
+ process.stderr.write(
365
+ "codeindex: server starting on http://localhost:8756 — then:\n" +
366
+ " CODEINDEX_EMBED_ENDPOINT=http://localhost:8756 codeindex search \"<query>\" --repo . --semantic\n",
367
+ );
368
+ } else {
369
+ // Print the command for the user to run (default; no side effects).
370
+ process.stdout.write(oneLiner + "\n");
371
+ process.stderr.write(
372
+ "codeindex: run the line above to start the embedding server (or `embed serve --run`), then:\n" +
373
+ " CODEINDEX_EMBED_ENDPOINT=http://localhost:8756 codeindex search \"<query>\" --repo . --semantic\n",
374
+ );
375
+ }
299
376
  } else if (sub === "build") {
300
377
  if (!flags.out) throw new Error("embed build needs --out <dir>");
301
378
  if (!modelDir) {
@@ -340,7 +417,7 @@ export async function runCli(argv: string[]): Promise<void> {
340
417
  writeFileSync(join(destDir, "model.json"), body);
341
418
  process.stderr.write(`codeindex: model written to ${join(destDir, "model.json")}\n`);
342
419
  } else {
343
- throw new Error("embed needs a subcommand: pull | build | status");
420
+ throw new Error("embed needs a subcommand: status | build | pull | serve");
344
421
  }
345
422
  } else if (cmd === "rules") {
346
423
  if (!flags.config) throw new Error("rules needs --config <codeindex.rules.json>");
package/src/engine.ts CHANGED
@@ -115,13 +115,25 @@ export {
115
115
  resolveEmbedPullUrl,
116
116
  } from "./embed/model.js";
117
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";
118
+ export { encode, quantize, tokenize, wordpiece, basicTokenize, roundHalfToEven, intDot } from "./embed/encode.js";
119
+ export { buildEmbeddingIndex, serializeEmbeddings, deserializeEmbeddings, embeddingUnits } from "./embed/index.js";
120
+ export type { EmbeddingIndex, EmbeddingRecord, EmbeddingUnit } from "./embed/index.js";
121
121
  export { searchSemantic } from "./embed/search.js";
122
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";
123
+ // HTTP endpoint tier (v2.11.0the "rich" tier). The engine is a fetch consumer
124
+ // of a containerized embedding server (CODEINDEX_EMBED_ENDPOINT): float vectors
125
+ // run through the SAME L2+int8 quantize pipeline, then the same integer ranking.
126
+ // Deterministic PER IMAGE DIGEST (not byte-golden). The library never
127
+ // orchestrates docker — `codeindex embed serve` (CLI) only prints/runs it.
128
+ export {
129
+ embedViaEndpoint,
130
+ resolveEmbedEndpoint,
131
+ embedEndpointUrl,
132
+ healthzUrl,
133
+ probeEndpoint,
134
+ encodeQueryViaEndpoint,
135
+ buildEndpointIndex,
136
+ } from "./embed/endpoint.js";
125
137
  export type { EmbedEndpointOptions } from "./embed/endpoint.js";
126
138
 
127
139
  // Architecture rules: forbidden edges + cycles/orphans builtins (issue #4).
@@ -256,12 +256,22 @@ function extractImports(ext: string, content: string): RawRef[] {
256
256
  // Barrel re-exports (`export { A, B as C } from './x'`, `export * from './y'`).
257
257
  // The line-based lang extractor can't capture multi-name lists, but these ARE
258
258
  // the public facade of a module — so list them as exported symbols here.
259
- function extractReexports(rel: string, content: string): CodeSymbol[] {
259
+ //
260
+ // An ALIAS with no `from` clause (`export { b as c }`) renames an in-file
261
+ // declaration — `localSymbols` (already extracted by the AST or regex tier)
262
+ // lets us resolve `b` and mirror ITS kind onto `c` (e.g. "function"), so the
263
+ // alias reads as the real symbol it is rather than the generic "reexport".
264
+ // A true cross-module re-export (`export { b as c } from "./mod"`) has no
265
+ // local `b` to resolve — and an alias the local pass genuinely can't see
266
+ // (destructured/ambient/etc.) falls back the same way — both keep "reexport".
267
+ function extractReexports(rel: string, content: string, localSymbols: CodeSymbol[]): CodeSymbol[] {
260
268
  if (!JS_TS.has(rel.slice(rel.lastIndexOf(".")))) return [];
261
269
  const lang = /\.(ts|tsx|mts|cts)$/.test(rel) ? "typescript" : "javascript";
262
270
  const out: CodeSymbol[] = [];
263
271
  const seen = new Set<string>();
264
272
  const lineAt = (idx: number): number => content.slice(0, idx).split(/\r?\n/).length;
273
+ const localKindOf = new Map<string, string>();
274
+ for (const s of localSymbols) if (!localKindOf.has(s.name)) localKindOf.set(s.name, s.kind);
265
275
 
266
276
  const named = /export\s*\{([\s\S]*?)\}\s*(?:from\s*['"]([^'"]+)['"])?\s*;?/g;
267
277
  let m: RegExpExecArray | null;
@@ -270,11 +280,13 @@ function extractReexports(rel: string, content: string): CodeSymbol[] {
270
280
  for (const part of m[1]!.split(",")) {
271
281
  const p = part.trim().replace(/^type\s+/, "");
272
282
  const as = /^(\S+)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(p);
283
+ const orig = as ? as[1]! : p;
273
284
  const name = as ? as[2]! : p;
274
285
  if (!/^[A-Za-z_$][\w$]*$/.test(name) || name === "default" || seen.has(name)) continue;
275
286
  seen.add(name);
287
+ const mirroredKind = !from ? localKindOf.get(orig) : undefined;
276
288
  out.push({
277
- name, kind: "reexport", file: rel, line: lineAt(m.index),
289
+ name, kind: mirroredKind ?? "reexport", file: rel, line: lineAt(m.index),
278
290
  signature: from ? `export { ${name} } from "${from}"` : `export { ${name} }`,
279
291
  exported: true, lang,
280
292
  });
@@ -354,7 +366,7 @@ export function extractCode(rel: string, ext: string, content: string): CodeInfo
354
366
  const symbols = (ast ? ast.symbols : extractSymbols(rel, ext, content)).slice(0, 400);
355
367
  // Add barrel re-exports the local def didn't already cover.
356
368
  const known = new Set(symbols.map((s) => s.name));
357
- const reexports = extractReexports(rel, content).filter((s) => !known.has(s.name));
369
+ const reexports = extractReexports(rel, content, symbols).filter((s) => !known.has(s.name));
358
370
  return {
359
371
  symbols: [...symbols, ...reexports],
360
372
  summary: topDocComment(content),
package/src/mcp.ts CHANGED
@@ -28,6 +28,7 @@ import { checkRules, parseRules } from "./rules.js";
28
28
  import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel } from "./embed/model.js";
29
29
  import { buildEmbeddingIndex } from "./embed/index.js";
30
30
  import { searchSemantic } from "./embed/search.js";
31
+ import { resolveEmbedEndpoint, buildEndpointIndex, encodeQueryViaEndpoint, probeEndpoint } from "./embed/endpoint.js";
31
32
 
32
33
  interface RpcRequest {
33
34
  jsonrpc: "2.0";
@@ -285,7 +286,7 @@ const TOOLS = [
285
286
  semantic: {
286
287
  type: "boolean",
287
288
  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
+ "RRF-fuse an embedding tier with lexical (default false). Precedence: the HTTP endpoint (CODEINDEX_EMBED_ENDPOINT) if set, else a local static model. Degrades silently to lexical-only when neither is available/reachable — see embed_status.",
289
290
  },
290
291
  },
291
292
  required: ["repo", "query"],
@@ -294,7 +295,7 @@ const TOOLS = [
294
295
  {
295
296
  name: "embed_status",
296
297
  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
+ "Report the embedding tier: the effective mode (none/static/endpoint; endpoint > static model), the resolved model (opt-in, never shipped in the package) with its modelId/dim, EMBED_VERSION, and the configured HTTP endpoint with its reachability. Use to check whether `search` with semantic:true will fuse embeddings or degrade to lexical.",
298
299
  inputSchema: { type: "object", properties: { ...repoProp }, required: ["repo"] },
299
300
  },
300
301
  {
@@ -320,7 +321,7 @@ function strArray(v: unknown): string[] | undefined {
320
321
  return Array.isArray(v) && v.every((x) => typeof x === "string") && v.length ? (v as string[]) : undefined;
321
322
  }
322
323
 
323
- function callTool(name: string, args: Record<string, unknown>): string {
324
+ async function callTool(name: string, args: Record<string, unknown>): Promise<string> {
324
325
  const repo = str(args.repo);
325
326
  if (!repo) throw new Error("`repo` is required (absolute path to the repository root)");
326
327
  const scanOpts = { scope: str(args.scope), include: strArray(args.include), exclude: strArray(args.exclude) };
@@ -458,6 +459,18 @@ function callTool(name: string, args: Record<string, unknown>): string {
458
459
  const limit = typeof args.limit === "number" ? args.limit : undefined;
459
460
  const fuzzy = typeof args.fuzzy === "boolean" ? args.fuzzy : undefined;
460
461
  if (args.semantic === true) {
462
+ const endpoint = resolveEmbedEndpoint();
463
+ if (endpoint) {
464
+ // Rich tier — endpoint takes PRECEDENCE over a local static model. An
465
+ // unreachable/malformed endpoint degrades to lexical (no throw).
466
+ try {
467
+ const index = await buildEndpointIndex(scan);
468
+ const queryVec = await encodeQueryViaEndpoint(query);
469
+ return JSON.stringify(searchSemantic(scan, query, index, { queryVec, limit, fuzzy }), null, 2);
470
+ } catch {
471
+ return JSON.stringify(searchIndex(scan, query, { limit, fuzzy }), null, 2);
472
+ }
473
+ }
461
474
  const modelDir = resolveEmbedModelDir(repo);
462
475
  const model = modelDir ? loadEmbedModel(modelDir) : undefined;
463
476
  if (model) {
@@ -471,17 +484,18 @@ function callTool(name: string, args: Record<string, unknown>): string {
471
484
  if (name === "embed_status") {
472
485
  const modelDir = resolveEmbedModelDir(repo);
473
486
  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
- );
487
+ const endpoint = resolveEmbedEndpoint();
488
+ const mode: "none" | "static" | "endpoint" = endpoint ? "endpoint" : model ? "static" : "none";
489
+ const status: Record<string, unknown> = {
490
+ embedVersion: EMBED_VERSION,
491
+ mode,
492
+ model: model
493
+ ? { present: true, dir: modelDir, modelId: model.modelId, dim: model.dim, vocabSize: model.vocabSize }
494
+ : { present: false },
495
+ endpoint: endpoint ?? null,
496
+ };
497
+ if (endpoint) status.endpointReachable = await probeEndpoint(endpoint);
498
+ return JSON.stringify(status, null, 2);
485
499
  }
486
500
  if (name === "check_rules") {
487
501
  const rules = parseRules(args.rules); // throws a descriptive error on a malformed payload
@@ -512,10 +526,10 @@ export async function runMcpServer(): Promise<void> {
512
526
  // JSON-RPC 2.0 batch: answer each member (a batching client would
513
527
  // otherwise hang forever on a silently dropped array).
514
528
  const requests = Array.isArray(parsed) ? (parsed as RpcRequest[]) : [parsed as RpcRequest];
515
- for (const req of requests) handle(req);
529
+ for (const req of requests) await handle(req);
516
530
  }
517
531
 
518
- function handle(req: RpcRequest): void {
532
+ async function handle(req: RpcRequest): Promise<void> {
519
533
  if (req.id === undefined || req.id === null) return; // notification — no response
520
534
 
521
535
  try {
@@ -537,7 +551,7 @@ export async function runMcpServer(): Promise<void> {
537
551
  const name = str(params.name) ?? "";
538
552
  const args = (params.arguments ?? {}) as Record<string, unknown>;
539
553
  try {
540
- const text = callTool(name, args);
554
+ const text = await callTool(name, args);
541
555
  send({ id: req.id, result: { content: [{ type: "text", text }] } });
542
556
  } catch (e) {
543
557
  send({