@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.
- package/LICENSE +21 -0
- package/README.md +52 -2
- package/package.json +1 -1
- package/scripts/engine.d.mts +18 -3
- package/scripts/engine.mjs +248 -95
- package/src/embed/encode.ts +26 -16
- package/src/embed/endpoint.ts +110 -21
- package/src/embed/index.ts +30 -14
- package/src/embed/search.ts +9 -3
- package/src/engine-cli.ts +99 -22
- package/src/engine.ts +17 -5
- package/src/extract/code.ts +15 -3
- package/src/mcp.ts +31 -17
- package/src/types.ts +6 -3
package/src/embed/endpoint.ts
CHANGED
|
@@ -1,40 +1,77 @@
|
|
|
1
|
-
// HTTP embedding endpoint client — the "rich" tier
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
24
|
+
// Endpoint BASE url (e.g. http://localhost:8756). Falls back to
|
|
25
|
+
// CODEINDEX_EMBED_ENDPOINT when omitted.
|
|
15
26
|
url?: string;
|
|
16
|
-
// Abort
|
|
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
|
|
23
|
-
// CODEINDEX_EMBED_ENDPOINT is set — the
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
35
|
-
if (!
|
|
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
|
|
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
|
+
}
|
package/src/embed/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
package/src/embed/search.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
embed
|
|
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
|
|
79
|
-
|
|
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
|
|
268
|
-
const
|
|
269
|
-
|
|
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
|
|
279
|
-
const
|
|
280
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
124
|
-
|
|
123
|
+
// HTTP endpoint tier (v2.11.0 — the "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).
|
package/src/extract/code.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
null,
|
|
483
|
-
|
|
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({
|