@maxgfr/codeindex 2.10.0 → 2.11.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.
@@ -87,14 +87,36 @@ export function roundHalfToEven(x: number): number {
87
87
  return f % 2 === 0 ? f : f + 1; // exactly .5 → nearest even
88
88
  }
89
89
 
90
+ // L2-normalize a float vector and quantize it to int8 at the fixed 1/127 scale
91
+ // (round-half-to-even, clamp to [-127,127]) — the SHARED tail of both tiers. The
92
+ // static encoder feeds its mean-pooled vector here; the endpoint tier feeds each
93
+ // float embedding it receives. Because the quantization is byte-identical, a
94
+ // given float vector maps to the same int8 bytes and the integer-dot ranking is
95
+ // consistent across tiers. A zero-norm input yields an all-zero vector (never
96
+ // NaN — it simply ranks last).
97
+ export function quantize(vec: ArrayLike<number>): Int8Array {
98
+ const dim = vec.length;
99
+ const out = new Int8Array(dim);
100
+ let sumsq = 0;
101
+ for (let d = 0; d < dim; d++) sumsq += vec[d]! * vec[d]!;
102
+ const norm = Math.sqrt(sumsq);
103
+ if (norm === 0) return out; // zero vector — nothing to rank on
104
+ for (let d = 0; d < dim; d++) {
105
+ let q = roundHalfToEven((vec[d]! / norm) * QUANT);
106
+ if (q > QUANT) q = QUANT;
107
+ else if (q < -QUANT) q = -QUANT;
108
+ out[d] = q;
109
+ }
110
+ return out;
111
+ }
112
+
90
113
  // Encode a text to an int8 unit-ish vector. Empty / all-OOV / zero-norm inputs
91
114
  // yield an all-zero vector (dot-products against it are 0 — it simply ranks
92
115
  // last), never NaN. Pure and deterministic: same model + text → same bytes.
93
116
  export function encode(model: StaticEmbedModel, text: string): Int8Array {
94
117
  const { dim, weights } = model;
95
- const out = new Int8Array(dim);
96
118
  const ids = tokenize(text, model);
97
- if (ids.length === 0) return out;
119
+ if (ids.length === 0) return new Int8Array(dim);
98
120
 
99
121
  // mean-pool in double precision, fixed order.
100
122
  const pooled = new Float64Array(dim);
@@ -105,20 +127,8 @@ export function encode(model: StaticEmbedModel, text: string): Int8Array {
105
127
  const inv = 1 / ids.length;
106
128
  for (let d = 0; d < dim; d++) pooled[d]! *= inv;
107
129
 
108
- // L2-normalize.
109
- let sumsq = 0;
110
- for (let d = 0; d < dim; d++) sumsq += pooled[d]! * pooled[d]!;
111
- const norm = Math.sqrt(sumsq);
112
- if (norm === 0) return out; // zero vector — nothing to rank on
113
-
114
- // quantize to int8 at the fixed 1/127 scale, round-half-to-even, clamp.
115
- for (let d = 0; d < dim; d++) {
116
- let q = roundHalfToEven((pooled[d]! / norm) * QUANT);
117
- if (q > QUANT) q = QUANT;
118
- else if (q < -QUANT) q = -QUANT;
119
- out[d] = q;
120
- }
121
- return out;
130
+ // L2-normalize + int8-quantize (the shared tail, identical to the endpoint tier).
131
+ return quantize(pooled);
122
132
  }
123
133
 
124
134
  // Integer dot product of two int8 vectors (the ranking primitive). Widened to a
@@ -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).