@kolisachint/hoocode-agent 0.4.164 → 0.4.166
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/CHANGELOG.md +4 -0
- package/dist/core/embsearch/client.d.ts +39 -2
- package/dist/core/embsearch/client.d.ts.map +1 -1
- package/dist/core/embsearch/client.js +33 -3
- package/dist/core/embsearch/client.js.map +1 -1
- package/dist/core/embsearch/embsearch-service.d.ts +30 -1
- package/dist/core/embsearch/embsearch-service.d.ts.map +1 -1
- package/dist/core/embsearch/embsearch-service.js +78 -5
- package/dist/core/embsearch/embsearch-service.js.map +1 -1
- package/dist/core/search/cross-rerank.d.ts +44 -0
- package/dist/core/search/cross-rerank.d.ts.map +1 -0
- package/dist/core/search/cross-rerank.js +77 -0
- package/dist/core/search/cross-rerank.js.map +1 -0
- package/dist/core/search/eval-compare.d.ts +57 -0
- package/dist/core/search/eval-compare.d.ts.map +1 -0
- package/dist/core/search/eval-compare.js +114 -0
- package/dist/core/search/eval-compare.js.map +1 -0
- package/dist/core/search/eval-gold.d.ts +47 -0
- package/dist/core/search/eval-gold.d.ts.map +1 -0
- package/dist/core/search/eval-gold.js +172 -0
- package/dist/core/search/eval-gold.js.map +1 -0
- package/dist/core/search/eval-harness.d.ts +140 -0
- package/dist/core/search/eval-harness.d.ts.map +1 -0
- package/dist/core/search/eval-harness.js +225 -0
- package/dist/core/search/eval-harness.js.map +1 -0
- package/dist/core/search/eval-live.d.ts +50 -0
- package/dist/core/search/eval-live.d.ts.map +1 -0
- package/dist/core/search/eval-live.js +48 -0
- package/dist/core/search/eval-live.js.map +1 -0
- package/dist/core/search/eval.d.ts +69 -8
- package/dist/core/search/eval.d.ts.map +1 -1
- package/dist/core/search/eval.js +77 -12
- package/dist/core/search/eval.js.map +1 -1
- package/dist/core/search/hybrid-search.d.ts +22 -0
- package/dist/core/search/hybrid-search.d.ts.map +1 -1
- package/dist/core/search/hybrid-search.js +61 -3
- package/dist/core/search/hybrid-search.js.map +1 -1
- package/dist/core/search/mode.d.ts +21 -5
- package/dist/core/search/mode.d.ts.map +1 -1
- package/dist/core/search/mode.js +23 -10
- package/dist/core/search/mode.js.map +1 -1
- package/dist/core/search/rerank.d.ts +21 -0
- package/dist/core/search/rerank.d.ts.map +1 -1
- package/dist/core/search/rerank.js +208 -11
- package/dist/core/search/rerank.js.map +1 -1
- package/dist/core/search/rrf.d.ts +19 -6
- package/dist/core/search/rrf.d.ts.map +1 -1
- package/dist/core/search/rrf.js +19 -6
- package/dist/core/search/rrf.js.map +1 -1
- package/dist/core/search/types.d.ts +11 -1
- package/dist/core/search/types.d.ts.map +1 -1
- package/dist/core/search/types.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -15,11 +15,26 @@ export interface EmbSearchDaemonInfo {
|
|
|
15
15
|
dim: number;
|
|
16
16
|
count: number;
|
|
17
17
|
}
|
|
18
|
+
/** One candidate sent for cross-encoder scoring. */
|
|
19
|
+
export interface EmbSearchRerankPassage {
|
|
20
|
+
id: string;
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
/** A cross-encoder relevance logit. Higher is more relevant, but the scale is
|
|
24
|
+
* unnormalized and comparable only within one call. */
|
|
25
|
+
export interface EmbSearchRerankResult {
|
|
26
|
+
id: string;
|
|
27
|
+
score: number;
|
|
28
|
+
}
|
|
18
29
|
export interface EmbSearchBulkResult {
|
|
19
30
|
inserted: number;
|
|
20
31
|
updated: number;
|
|
21
32
|
}
|
|
33
|
+
/** Which daemon-side retriever answers a query (embsearch >= 0.2.0). */
|
|
34
|
+
export type DaemonRetriever = "dense" | "lexical" | "hybrid";
|
|
22
35
|
export interface EmbSearchClientOptions {
|
|
36
|
+
/** Open/create the store with a BM25 lexical index alongside the vectors. */
|
|
37
|
+
hybrid?: boolean;
|
|
23
38
|
/** Path to the `embsearch` binary. */
|
|
24
39
|
binaryPath: string;
|
|
25
40
|
/** Store directory passed as `--path`. */
|
|
@@ -39,8 +54,30 @@ export declare class EmbSearchClient {
|
|
|
39
54
|
get isClosed(): boolean;
|
|
40
55
|
private onStdout;
|
|
41
56
|
private send;
|
|
42
|
-
/**
|
|
43
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Search for the top-`k` matches for `text`.
|
|
59
|
+
*
|
|
60
|
+
* `retriever` selects which leg answers:
|
|
61
|
+
* - `dense` (default) — vector search, the historical behaviour;
|
|
62
|
+
* - `lexical` — BM25 only, raw scores, no embedding computed;
|
|
63
|
+
* - `hybrid` — both, pre-fused by the daemon's own RRF constant.
|
|
64
|
+
*
|
|
65
|
+
* `lexical` and `hybrid` need a store created with `--hybrid`. Prefer
|
|
66
|
+
* `lexical` over `hybrid` when fusing here: `hybrid` collapses both legs
|
|
67
|
+
* into one RRF score, discarding the per-retriever ranks the trace records
|
|
68
|
+
* and preventing n-way fusion with the grep leg.
|
|
69
|
+
*/
|
|
70
|
+
query(text: string, k?: number, retriever?: DaemonRetriever): Promise<EmbSearchResult[]>;
|
|
71
|
+
/**
|
|
72
|
+
* Score `passages` against `query` with the daemon's cross-encoder and
|
|
73
|
+
* return the best `k`, best first.
|
|
74
|
+
*
|
|
75
|
+
* Passages are sent inline rather than referenced by id: the caller has the
|
|
76
|
+
* exact spans it intends to show the model, and a cross-encoder scores the
|
|
77
|
+
* text it is given, so sending anything else would score the wrong thing.
|
|
78
|
+
* Requires an onnx build with reranker weights (embsearch >= 0.3.0).
|
|
79
|
+
*/
|
|
80
|
+
rerank(query: string, passages: EmbSearchRerankPassage[], k: number): Promise<EmbSearchRerankResult[]>;
|
|
44
81
|
/**
|
|
45
82
|
* Batched insert-or-replace. One embedding inference for the whole batch —
|
|
46
83
|
* the fast path for bulk indexing. Keep batches modest (e.g. 32–64) so a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/core/embsearch/client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,MAAM,WAAW,eAAe;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,mBAAmB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,mBAAmB;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,sBAAsB;IACtC,sCAAsC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,MAAM,CAAC,EAAE,QAAQ,GAAG,KAAK,GAAG,WAAW,CAAC;CACxC;AAoBD,qBAAa,eAAe;IAC3B,OAAO,CAAC,IAAI,CAAiC;IAC7C,OAAO,CAAC,KAAK,CAAiB;IAC9B,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,YAAY,CAAgB;IAEpC,YAAY,IAAI,EAAE,sBAAsB,EA2BvC;IAED,6DAA6D;IAC7D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAErB;IAED,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED,OAAO,CAAC,QAAQ;IAqBhB,OAAO,CAAC,IAAI;IAQZ,iDAAiD;IAC3C,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,SAAK,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAG5D;IAED;;;;OAIG;IACG,IAAI,CAAC,KAAK,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAGnF;IAED,qEAAqE;IAC/D,IAAI,IAAI,OAAO,CAAC,mBAAmB,CAAC,CAGzC;IAED,yDAAyD;IACnD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAGzC;IAED,uDAAuD;IACjD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAE7B;IAED,gDAAgD;IAC1C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAE1B;IAED,+DAA+D;IACzD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAI3B;CACD","sourcesContent":["/**\n * Client for the `embsearch` stdio daemon (vendored from\n * github.com/kolisachint/embeddingsearchtools ts/client.ts).\n *\n * Spawns `embsearch serve` once and talks newline-delimited JSON over its\n * stdin/stdout. The process stays alive so the model and index load a single\n * time; every query after startup is hot.\n */\n\nimport { type ChildProcessWithoutNullStreams, spawn } from \"child_process\";\n\nexport interface EmbSearchResult {\n\tid: string;\n\tscore: number;\n}\n\nexport interface EmbSearchDaemonInfo {\n\tmodelId: string;\n\tdim: number;\n\tcount: number;\n}\n\nexport interface EmbSearchBulkResult {\n\tinserted: number;\n\tupdated: number;\n}\n\nexport interface EmbSearchClientOptions {\n\t/** Path to the `embsearch` binary. */\n\tbinaryPath: string;\n\t/** Store directory passed as `--path`. */\n\tstorePath: string;\n\t/** Metric for a freshly created store. Default: \"cosine\". */\n\tmetric?: \"cosine\" | \"dot\" | \"euclidean\";\n}\n\ninterface Pending {\n\tresolve: (value: EmbSearchRawResponse) => void;\n\treject: (err: Error) => void;\n}\n\ninterface EmbSearchRawResponse {\n\tok: boolean;\n\terror?: string;\n\tresults?: EmbSearchResult[];\n\tinserted?: boolean;\n\tremoved?: boolean;\n\tcount?: number;\n\tinserted_count?: number;\n\tupdated_count?: number;\n\tmodel_id?: string;\n\tdim?: number;\n}\n\nexport class EmbSearchClient {\n\tprivate proc: ChildProcessWithoutNullStreams;\n\tprivate queue: Pending[] = [];\n\tprivate buffer = \"\";\n\tprivate closed = false;\n\tprivate readyPromise: Promise<void>;\n\n\tconstructor(opts: EmbSearchClientOptions) {\n\t\tconst args = [\"serve\", \"--path\", opts.storePath];\n\t\tif (opts.metric) args.push(\"--metric\", opts.metric);\n\n\t\tthis.proc = spawn(opts.binaryPath, args, { stdio: [\"pipe\", \"pipe\", \"pipe\"] });\n\n\t\tthis.proc.stdout.setEncoding(\"utf8\");\n\t\tthis.proc.stdout.on(\"data\", (chunk: string) => this.onStdout(chunk));\n\t\t// Swallow the informational stderr banner; errors surface via responses/exit.\n\t\tthis.proc.stderr.resume();\n\n\t\t// Readiness = the daemon answering a ping (probes the actual request loop).\n\t\tthis.readyPromise = this.send({ op: \"ping\" }).then(() => undefined);\n\t\t// Spawn failures reject the pending ping via the exit handler; mark the\n\t\t// promise handled so a failed spawn doesn't raise an unhandled rejection\n\t\t// before ready() is awaited.\n\t\tthis.readyPromise.catch(() => {});\n\n\t\tthis.proc.on(\"error\", (err) => {\n\t\t\tthis.closed = true;\n\t\t\tfor (const p of this.queue.splice(0)) p.reject(err);\n\t\t});\n\t\tthis.proc.on(\"exit\", (code) => {\n\t\t\tthis.closed = true;\n\t\t\tconst err = new Error(`embsearch daemon exited (code ${code})`);\n\t\t\tfor (const p of this.queue.splice(0)) p.reject(err);\n\t\t});\n\t}\n\n\t/** Resolves once the daemon has loaded the model + index. */\n\tready(): Promise<void> {\n\t\treturn this.readyPromise;\n\t}\n\n\tget isClosed(): boolean {\n\t\treturn this.closed;\n\t}\n\n\tprivate onStdout(chunk: string): void {\n\t\tthis.buffer += chunk;\n\t\t// Each response is one line; dispatch FIFO against the pending queue.\n\t\tfor (let nl = this.buffer.indexOf(\"\\n\"); nl !== -1; nl = this.buffer.indexOf(\"\\n\")) {\n\t\t\tconst line = this.buffer.slice(0, nl).trim();\n\t\t\tthis.buffer = this.buffer.slice(nl + 1);\n\t\t\tif (!line) continue;\n\t\t\tconst pending = this.queue.shift();\n\t\t\tif (!pending) continue;\n\t\t\tlet msg: EmbSearchRawResponse;\n\t\t\ttry {\n\t\t\t\tmsg = JSON.parse(line) as EmbSearchRawResponse;\n\t\t\t} catch {\n\t\t\t\tpending.reject(new Error(`bad response: ${line}`));\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (msg.ok) pending.resolve(msg);\n\t\t\telse pending.reject(new Error(msg.error ?? \"unknown error\"));\n\t\t}\n\t}\n\n\tprivate send(req: Record<string, unknown>): Promise<EmbSearchRawResponse> {\n\t\tif (this.closed) return Promise.reject(new Error(\"embsearch client is closed\"));\n\t\treturn new Promise<EmbSearchRawResponse>((resolve, reject) => {\n\t\t\tthis.queue.push({ resolve, reject });\n\t\t\tthis.proc.stdin.write(`${JSON.stringify(req)}\\n`);\n\t\t});\n\t}\n\n\t/** Search for the top-`k` matches for `text`. */\n\tasync query(text: string, k = 10): Promise<EmbSearchResult[]> {\n\t\tconst res = await this.send({ op: \"query\", text, k });\n\t\treturn res.results ?? [];\n\t}\n\n\t/**\n\t * Batched insert-or-replace. One embedding inference for the whole batch —\n\t * the fast path for bulk indexing. Keep batches modest (e.g. 32–64) so a\n\t * concurrent query is not stuck behind a huge inference.\n\t */\n\tasync bulk(items: Array<{ id: string; text: string }>): Promise<EmbSearchBulkResult> {\n\t\tconst res = await this.send({ op: \"bulk\", items });\n\t\treturn { inserted: res.inserted_count ?? 0, updated: res.updated_count ?? 0 };\n\t}\n\n\t/** Model id, dimensionality, and live vector count of the daemon. */\n\tasync info(): Promise<EmbSearchDaemonInfo> {\n\t\tconst res = await this.send({ op: \"info\" });\n\t\treturn { modelId: res.model_id ?? \"\", dim: res.dim ?? 0, count: res.count ?? 0 };\n\t}\n\n\t/** Remove a record. Resolves to `true` if it existed. */\n\tasync remove(id: string): Promise<boolean> {\n\t\tconst res = await this.send({ op: \"remove\", id });\n\t\treturn res.removed === true;\n\t}\n\n\t/** Reclaim tombstoned rows left behind by `remove`. */\n\tasync compact(): Promise<void> {\n\t\tawait this.send({ op: \"compact\" });\n\t}\n\n\t/** Persist the index to the store directory. */\n\tasync save(): Promise<void> {\n\t\tawait this.send({ op: \"save\" });\n\t}\n\n\t/** Shut the daemon down, closing stdin so it exits cleanly. */\n\tasync close(): Promise<void> {\n\t\tif (this.closed) return;\n\t\tthis.proc.stdin.end();\n\t\tawait new Promise<void>((resolve) => this.proc.on(\"exit\", () => resolve()));\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/core/embsearch/client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,MAAM,WAAW,eAAe;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,mBAAmB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACd;AAED,oDAAoD;AACpD,MAAM,WAAW,sBAAsB;IACtC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;CACb;AAED;wDACwD;AACxD,MAAM,WAAW,qBAAqB;IACrC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,mBAAmB;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,wEAAwE;AACxE,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,CAAC;AAE7D,MAAM,WAAW,sBAAsB;IACtC,6EAA6E;IAC7E,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,sCAAsC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,MAAM,CAAC,EAAE,QAAQ,GAAG,KAAK,GAAG,WAAW,CAAC;CACxC;AAuBD,qBAAa,eAAe;IAC3B,OAAO,CAAC,IAAI,CAAiC;IAC7C,OAAO,CAAC,KAAK,CAAiB;IAC9B,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,YAAY,CAAgB;IAEpC,YAAY,IAAI,EAAE,sBAAsB,EA+BvC;IAED,6DAA6D;IAC7D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAErB;IAED,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED,OAAO,CAAC,QAAQ;IAqBhB,OAAO,CAAC,IAAI;IAQZ;;;;;;;;;;;;OAYG;IACG,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,SAAK,EAAE,SAAS,GAAE,eAAyB,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAKlG;IAED;;;;;;;;OAQG;IACG,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,sBAAsB,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAG3G;IAED;;;;OAIG;IACG,IAAI,CAAC,KAAK,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAGnF;IAED,qEAAqE;IAC/D,IAAI,IAAI,OAAO,CAAC,mBAAmB,CAAC,CAGzC;IAED,yDAAyD;IACnD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAGzC;IAED,uDAAuD;IACjD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAE7B;IAED,gDAAgD;IAC1C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAE1B;IAED,+DAA+D;IACzD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAI3B;CACD","sourcesContent":["/**\n * Client for the `embsearch` stdio daemon (vendored from\n * github.com/kolisachint/embeddingsearchtools ts/client.ts).\n *\n * Spawns `embsearch serve` once and talks newline-delimited JSON over its\n * stdin/stdout. The process stays alive so the model and index load a single\n * time; every query after startup is hot.\n */\n\nimport { type ChildProcessWithoutNullStreams, spawn } from \"child_process\";\n\nexport interface EmbSearchResult {\n\tid: string;\n\tscore: number;\n}\n\nexport interface EmbSearchDaemonInfo {\n\tmodelId: string;\n\tdim: number;\n\tcount: number;\n}\n\n/** One candidate sent for cross-encoder scoring. */\nexport interface EmbSearchRerankPassage {\n\tid: string;\n\ttext: string;\n}\n\n/** A cross-encoder relevance logit. Higher is more relevant, but the scale is\n * unnormalized and comparable only within one call. */\nexport interface EmbSearchRerankResult {\n\tid: string;\n\tscore: number;\n}\n\nexport interface EmbSearchBulkResult {\n\tinserted: number;\n\tupdated: number;\n}\n\n/** Which daemon-side retriever answers a query (embsearch >= 0.2.0). */\nexport type DaemonRetriever = \"dense\" | \"lexical\" | \"hybrid\";\n\nexport interface EmbSearchClientOptions {\n\t/** Open/create the store with a BM25 lexical index alongside the vectors. */\n\thybrid?: boolean;\n\t/** Path to the `embsearch` binary. */\n\tbinaryPath: string;\n\t/** Store directory passed as `--path`. */\n\tstorePath: string;\n\t/** Metric for a freshly created store. Default: \"cosine\". */\n\tmetric?: \"cosine\" | \"dot\" | \"euclidean\";\n}\n\ninterface Pending {\n\tresolve: (value: EmbSearchRawResponse) => void;\n\treject: (err: Error) => void;\n}\n\ninterface EmbSearchRawResponse {\n\tok: boolean;\n\terror?: string;\n\tresults?: EmbSearchResult[];\n\tinserted?: boolean;\n\tremoved?: boolean;\n\tcount?: number;\n\tinserted_count?: number;\n\tupdated_count?: number;\n\tmodel_id?: string;\n\tdim?: number;\n\t/** Cross-encoder scores. Separate from `results` because the daemon's\n\t * logits are not comparable to retrieval scores. */\n\treranked?: EmbSearchRerankResult[];\n}\n\nexport class EmbSearchClient {\n\tprivate proc: ChildProcessWithoutNullStreams;\n\tprivate queue: Pending[] = [];\n\tprivate buffer = \"\";\n\tprivate closed = false;\n\tprivate readyPromise: Promise<void>;\n\n\tconstructor(opts: EmbSearchClientOptions) {\n\t\tconst args = [\"serve\", \"--path\", opts.storePath];\n\t\tif (opts.metric) args.push(\"--metric\", opts.metric);\n\t\t// Hybrid-ness is fixed when a store is created: passing --hybrid against\n\t\t// an existing non-hybrid store warns and is ignored daemon-side, so a\n\t\t// hybrid store needs its own directory.\n\t\tif (opts.hybrid) args.push(\"--hybrid\");\n\n\t\tthis.proc = spawn(opts.binaryPath, args, { stdio: [\"pipe\", \"pipe\", \"pipe\"] });\n\n\t\tthis.proc.stdout.setEncoding(\"utf8\");\n\t\tthis.proc.stdout.on(\"data\", (chunk: string) => this.onStdout(chunk));\n\t\t// Swallow the informational stderr banner; errors surface via responses/exit.\n\t\tthis.proc.stderr.resume();\n\n\t\t// Readiness = the daemon answering a ping (probes the actual request loop).\n\t\tthis.readyPromise = this.send({ op: \"ping\" }).then(() => undefined);\n\t\t// Spawn failures reject the pending ping via the exit handler; mark the\n\t\t// promise handled so a failed spawn doesn't raise an unhandled rejection\n\t\t// before ready() is awaited.\n\t\tthis.readyPromise.catch(() => {});\n\n\t\tthis.proc.on(\"error\", (err) => {\n\t\t\tthis.closed = true;\n\t\t\tfor (const p of this.queue.splice(0)) p.reject(err);\n\t\t});\n\t\tthis.proc.on(\"exit\", (code) => {\n\t\t\tthis.closed = true;\n\t\t\tconst err = new Error(`embsearch daemon exited (code ${code})`);\n\t\t\tfor (const p of this.queue.splice(0)) p.reject(err);\n\t\t});\n\t}\n\n\t/** Resolves once the daemon has loaded the model + index. */\n\tready(): Promise<void> {\n\t\treturn this.readyPromise;\n\t}\n\n\tget isClosed(): boolean {\n\t\treturn this.closed;\n\t}\n\n\tprivate onStdout(chunk: string): void {\n\t\tthis.buffer += chunk;\n\t\t// Each response is one line; dispatch FIFO against the pending queue.\n\t\tfor (let nl = this.buffer.indexOf(\"\\n\"); nl !== -1; nl = this.buffer.indexOf(\"\\n\")) {\n\t\t\tconst line = this.buffer.slice(0, nl).trim();\n\t\t\tthis.buffer = this.buffer.slice(nl + 1);\n\t\t\tif (!line) continue;\n\t\t\tconst pending = this.queue.shift();\n\t\t\tif (!pending) continue;\n\t\t\tlet msg: EmbSearchRawResponse;\n\t\t\ttry {\n\t\t\t\tmsg = JSON.parse(line) as EmbSearchRawResponse;\n\t\t\t} catch {\n\t\t\t\tpending.reject(new Error(`bad response: ${line}`));\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (msg.ok) pending.resolve(msg);\n\t\t\telse pending.reject(new Error(msg.error ?? \"unknown error\"));\n\t\t}\n\t}\n\n\tprivate send(req: Record<string, unknown>): Promise<EmbSearchRawResponse> {\n\t\tif (this.closed) return Promise.reject(new Error(\"embsearch client is closed\"));\n\t\treturn new Promise<EmbSearchRawResponse>((resolve, reject) => {\n\t\t\tthis.queue.push({ resolve, reject });\n\t\t\tthis.proc.stdin.write(`${JSON.stringify(req)}\\n`);\n\t\t});\n\t}\n\n\t/**\n\t * Search for the top-`k` matches for `text`.\n\t *\n\t * `retriever` selects which leg answers:\n\t * - `dense` (default) — vector search, the historical behaviour;\n\t * - `lexical` — BM25 only, raw scores, no embedding computed;\n\t * - `hybrid` — both, pre-fused by the daemon's own RRF constant.\n\t *\n\t * `lexical` and `hybrid` need a store created with `--hybrid`. Prefer\n\t * `lexical` over `hybrid` when fusing here: `hybrid` collapses both legs\n\t * into one RRF score, discarding the per-retriever ranks the trace records\n\t * and preventing n-way fusion with the grep leg.\n\t */\n\tasync query(text: string, k = 10, retriever: DaemonRetriever = \"dense\"): Promise<EmbSearchResult[]> {\n\t\tconst res = await this.send(\n\t\t\tretriever === \"dense\" ? { op: \"query\", text, k } : { op: \"query\", text, k, retriever },\n\t\t);\n\t\treturn res.results ?? [];\n\t}\n\n\t/**\n\t * Score `passages` against `query` with the daemon's cross-encoder and\n\t * return the best `k`, best first.\n\t *\n\t * Passages are sent inline rather than referenced by id: the caller has the\n\t * exact spans it intends to show the model, and a cross-encoder scores the\n\t * text it is given, so sending anything else would score the wrong thing.\n\t * Requires an onnx build with reranker weights (embsearch >= 0.3.0).\n\t */\n\tasync rerank(query: string, passages: EmbSearchRerankPassage[], k: number): Promise<EmbSearchRerankResult[]> {\n\t\tconst res = await this.send({ op: \"rerank\", query, passages, k });\n\t\treturn res.reranked ?? [];\n\t}\n\n\t/**\n\t * Batched insert-or-replace. One embedding inference for the whole batch —\n\t * the fast path for bulk indexing. Keep batches modest (e.g. 32–64) so a\n\t * concurrent query is not stuck behind a huge inference.\n\t */\n\tasync bulk(items: Array<{ id: string; text: string }>): Promise<EmbSearchBulkResult> {\n\t\tconst res = await this.send({ op: \"bulk\", items });\n\t\treturn { inserted: res.inserted_count ?? 0, updated: res.updated_count ?? 0 };\n\t}\n\n\t/** Model id, dimensionality, and live vector count of the daemon. */\n\tasync info(): Promise<EmbSearchDaemonInfo> {\n\t\tconst res = await this.send({ op: \"info\" });\n\t\treturn { modelId: res.model_id ?? \"\", dim: res.dim ?? 0, count: res.count ?? 0 };\n\t}\n\n\t/** Remove a record. Resolves to `true` if it existed. */\n\tasync remove(id: string): Promise<boolean> {\n\t\tconst res = await this.send({ op: \"remove\", id });\n\t\treturn res.removed === true;\n\t}\n\n\t/** Reclaim tombstoned rows left behind by `remove`. */\n\tasync compact(): Promise<void> {\n\t\tawait this.send({ op: \"compact\" });\n\t}\n\n\t/** Persist the index to the store directory. */\n\tasync save(): Promise<void> {\n\t\tawait this.send({ op: \"save\" });\n\t}\n\n\t/** Shut the daemon down, closing stdin so it exits cleanly. */\n\tasync close(): Promise<void> {\n\t\tif (this.closed) return;\n\t\tthis.proc.stdin.end();\n\t\tawait new Promise<void>((resolve) => this.proc.on(\"exit\", () => resolve()));\n\t}\n}\n"]}
|
|
@@ -17,6 +17,11 @@ export class EmbSearchClient {
|
|
|
17
17
|
const args = ["serve", "--path", opts.storePath];
|
|
18
18
|
if (opts.metric)
|
|
19
19
|
args.push("--metric", opts.metric);
|
|
20
|
+
// Hybrid-ness is fixed when a store is created: passing --hybrid against
|
|
21
|
+
// an existing non-hybrid store warns and is ignored daemon-side, so a
|
|
22
|
+
// hybrid store needs its own directory.
|
|
23
|
+
if (opts.hybrid)
|
|
24
|
+
args.push("--hybrid");
|
|
20
25
|
this.proc = spawn(opts.binaryPath, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
21
26
|
this.proc.stdout.setEncoding("utf8");
|
|
22
27
|
this.proc.stdout.on("data", (chunk) => this.onStdout(chunk));
|
|
@@ -80,11 +85,36 @@ export class EmbSearchClient {
|
|
|
80
85
|
this.proc.stdin.write(`${JSON.stringify(req)}\n`);
|
|
81
86
|
});
|
|
82
87
|
}
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Search for the top-`k` matches for `text`.
|
|
90
|
+
*
|
|
91
|
+
* `retriever` selects which leg answers:
|
|
92
|
+
* - `dense` (default) — vector search, the historical behaviour;
|
|
93
|
+
* - `lexical` — BM25 only, raw scores, no embedding computed;
|
|
94
|
+
* - `hybrid` — both, pre-fused by the daemon's own RRF constant.
|
|
95
|
+
*
|
|
96
|
+
* `lexical` and `hybrid` need a store created with `--hybrid`. Prefer
|
|
97
|
+
* `lexical` over `hybrid` when fusing here: `hybrid` collapses both legs
|
|
98
|
+
* into one RRF score, discarding the per-retriever ranks the trace records
|
|
99
|
+
* and preventing n-way fusion with the grep leg.
|
|
100
|
+
*/
|
|
101
|
+
async query(text, k = 10, retriever = "dense") {
|
|
102
|
+
const res = await this.send(retriever === "dense" ? { op: "query", text, k } : { op: "query", text, k, retriever });
|
|
86
103
|
return res.results ?? [];
|
|
87
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Score `passages` against `query` with the daemon's cross-encoder and
|
|
107
|
+
* return the best `k`, best first.
|
|
108
|
+
*
|
|
109
|
+
* Passages are sent inline rather than referenced by id: the caller has the
|
|
110
|
+
* exact spans it intends to show the model, and a cross-encoder scores the
|
|
111
|
+
* text it is given, so sending anything else would score the wrong thing.
|
|
112
|
+
* Requires an onnx build with reranker weights (embsearch >= 0.3.0).
|
|
113
|
+
*/
|
|
114
|
+
async rerank(query, passages, k) {
|
|
115
|
+
const res = await this.send({ op: "rerank", query, passages, k });
|
|
116
|
+
return res.reranked ?? [];
|
|
117
|
+
}
|
|
88
118
|
/**
|
|
89
119
|
* Batched insert-or-replace. One embedding inference for the whole batch —
|
|
90
120
|
* the fast path for bulk indexing. Keep batches modest (e.g. 32–64) so a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../../src/core/embsearch/client.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Client for the `embsearch` stdio daemon (vendored from\n * github.com/kolisachint/embeddingsearchtools ts/client.ts).\n *\n * Spawns `embsearch serve` once and talks newline-delimited JSON over its\n * stdin/stdout. The process stays alive so the model and index load a single\n * time; every query after startup is hot.\n */\n\nimport { type ChildProcessWithoutNullStreams, spawn } from \"child_process\";\n\nexport interface EmbSearchResult {\n\tid: string;\n\tscore: number;\n}\n\nexport interface EmbSearchDaemonInfo {\n\tmodelId: string;\n\tdim: number;\n\tcount: number;\n}\n\nexport interface EmbSearchBulkResult {\n\tinserted: number;\n\tupdated: number;\n}\n\nexport interface EmbSearchClientOptions {\n\t/** Path to the `embsearch` binary. */\n\tbinaryPath: string;\n\t/** Store directory passed as `--path`. */\n\tstorePath: string;\n\t/** Metric for a freshly created store. Default: \"cosine\". */\n\tmetric?: \"cosine\" | \"dot\" | \"euclidean\";\n}\n\ninterface Pending {\n\tresolve: (value: EmbSearchRawResponse) => void;\n\treject: (err: Error) => void;\n}\n\ninterface EmbSearchRawResponse {\n\tok: boolean;\n\terror?: string;\n\tresults?: EmbSearchResult[];\n\tinserted?: boolean;\n\tremoved?: boolean;\n\tcount?: number;\n\tinserted_count?: number;\n\tupdated_count?: number;\n\tmodel_id?: string;\n\tdim?: number;\n}\n\nexport class EmbSearchClient {\n\tprivate proc: ChildProcessWithoutNullStreams;\n\tprivate queue: Pending[] = [];\n\tprivate buffer = \"\";\n\tprivate closed = false;\n\tprivate readyPromise: Promise<void>;\n\n\tconstructor(opts: EmbSearchClientOptions) {\n\t\tconst args = [\"serve\", \"--path\", opts.storePath];\n\t\tif (opts.metric) args.push(\"--metric\", opts.metric);\n\n\t\tthis.proc = spawn(opts.binaryPath, args, { stdio: [\"pipe\", \"pipe\", \"pipe\"] });\n\n\t\tthis.proc.stdout.setEncoding(\"utf8\");\n\t\tthis.proc.stdout.on(\"data\", (chunk: string) => this.onStdout(chunk));\n\t\t// Swallow the informational stderr banner; errors surface via responses/exit.\n\t\tthis.proc.stderr.resume();\n\n\t\t// Readiness = the daemon answering a ping (probes the actual request loop).\n\t\tthis.readyPromise = this.send({ op: \"ping\" }).then(() => undefined);\n\t\t// Spawn failures reject the pending ping via the exit handler; mark the\n\t\t// promise handled so a failed spawn doesn't raise an unhandled rejection\n\t\t// before ready() is awaited.\n\t\tthis.readyPromise.catch(() => {});\n\n\t\tthis.proc.on(\"error\", (err) => {\n\t\t\tthis.closed = true;\n\t\t\tfor (const p of this.queue.splice(0)) p.reject(err);\n\t\t});\n\t\tthis.proc.on(\"exit\", (code) => {\n\t\t\tthis.closed = true;\n\t\t\tconst err = new Error(`embsearch daemon exited (code ${code})`);\n\t\t\tfor (const p of this.queue.splice(0)) p.reject(err);\n\t\t});\n\t}\n\n\t/** Resolves once the daemon has loaded the model + index. */\n\tready(): Promise<void> {\n\t\treturn this.readyPromise;\n\t}\n\n\tget isClosed(): boolean {\n\t\treturn this.closed;\n\t}\n\n\tprivate onStdout(chunk: string): void {\n\t\tthis.buffer += chunk;\n\t\t// Each response is one line; dispatch FIFO against the pending queue.\n\t\tfor (let nl = this.buffer.indexOf(\"\\n\"); nl !== -1; nl = this.buffer.indexOf(\"\\n\")) {\n\t\t\tconst line = this.buffer.slice(0, nl).trim();\n\t\t\tthis.buffer = this.buffer.slice(nl + 1);\n\t\t\tif (!line) continue;\n\t\t\tconst pending = this.queue.shift();\n\t\t\tif (!pending) continue;\n\t\t\tlet msg: EmbSearchRawResponse;\n\t\t\ttry {\n\t\t\t\tmsg = JSON.parse(line) as EmbSearchRawResponse;\n\t\t\t} catch {\n\t\t\t\tpending.reject(new Error(`bad response: ${line}`));\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (msg.ok) pending.resolve(msg);\n\t\t\telse pending.reject(new Error(msg.error ?? \"unknown error\"));\n\t\t}\n\t}\n\n\tprivate send(req: Record<string, unknown>): Promise<EmbSearchRawResponse> {\n\t\tif (this.closed) return Promise.reject(new Error(\"embsearch client is closed\"));\n\t\treturn new Promise<EmbSearchRawResponse>((resolve, reject) => {\n\t\t\tthis.queue.push({ resolve, reject });\n\t\t\tthis.proc.stdin.write(`${JSON.stringify(req)}\\n`);\n\t\t});\n\t}\n\n\t/** Search for the top-`k` matches for `text`. */\n\tasync query(text: string, k = 10): Promise<EmbSearchResult[]> {\n\t\tconst res = await this.send({ op: \"query\", text, k });\n\t\treturn res.results ?? [];\n\t}\n\n\t/**\n\t * Batched insert-or-replace. One embedding inference for the whole batch —\n\t * the fast path for bulk indexing. Keep batches modest (e.g. 32–64) so a\n\t * concurrent query is not stuck behind a huge inference.\n\t */\n\tasync bulk(items: Array<{ id: string; text: string }>): Promise<EmbSearchBulkResult> {\n\t\tconst res = await this.send({ op: \"bulk\", items });\n\t\treturn { inserted: res.inserted_count ?? 0, updated: res.updated_count ?? 0 };\n\t}\n\n\t/** Model id, dimensionality, and live vector count of the daemon. */\n\tasync info(): Promise<EmbSearchDaemonInfo> {\n\t\tconst res = await this.send({ op: \"info\" });\n\t\treturn { modelId: res.model_id ?? \"\", dim: res.dim ?? 0, count: res.count ?? 0 };\n\t}\n\n\t/** Remove a record. Resolves to `true` if it existed. */\n\tasync remove(id: string): Promise<boolean> {\n\t\tconst res = await this.send({ op: \"remove\", id });\n\t\treturn res.removed === true;\n\t}\n\n\t/** Reclaim tombstoned rows left behind by `remove`. */\n\tasync compact(): Promise<void> {\n\t\tawait this.send({ op: \"compact\" });\n\t}\n\n\t/** Persist the index to the store directory. */\n\tasync save(): Promise<void> {\n\t\tawait this.send({ op: \"save\" });\n\t}\n\n\t/** Shut the daemon down, closing stdin so it exits cleanly. */\n\tasync close(): Promise<void> {\n\t\tif (this.closed) return;\n\t\tthis.proc.stdin.end();\n\t\tawait new Promise<void>((resolve) => this.proc.on(\"exit\", () => resolve()));\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../../src/core/embsearch/client.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Client for the `embsearch` stdio daemon (vendored from\n * github.com/kolisachint/embeddingsearchtools ts/client.ts).\n *\n * Spawns `embsearch serve` once and talks newline-delimited JSON over its\n * stdin/stdout. The process stays alive so the model and index load a single\n * time; every query after startup is hot.\n */\n\nimport { type ChildProcessWithoutNullStreams, spawn } from \"child_process\";\n\nexport interface EmbSearchResult {\n\tid: string;\n\tscore: number;\n}\n\nexport interface EmbSearchDaemonInfo {\n\tmodelId: string;\n\tdim: number;\n\tcount: number;\n}\n\n/** One candidate sent for cross-encoder scoring. */\nexport interface EmbSearchRerankPassage {\n\tid: string;\n\ttext: string;\n}\n\n/** A cross-encoder relevance logit. Higher is more relevant, but the scale is\n * unnormalized and comparable only within one call. */\nexport interface EmbSearchRerankResult {\n\tid: string;\n\tscore: number;\n}\n\nexport interface EmbSearchBulkResult {\n\tinserted: number;\n\tupdated: number;\n}\n\n/** Which daemon-side retriever answers a query (embsearch >= 0.2.0). */\nexport type DaemonRetriever = \"dense\" | \"lexical\" | \"hybrid\";\n\nexport interface EmbSearchClientOptions {\n\t/** Open/create the store with a BM25 lexical index alongside the vectors. */\n\thybrid?: boolean;\n\t/** Path to the `embsearch` binary. */\n\tbinaryPath: string;\n\t/** Store directory passed as `--path`. */\n\tstorePath: string;\n\t/** Metric for a freshly created store. Default: \"cosine\". */\n\tmetric?: \"cosine\" | \"dot\" | \"euclidean\";\n}\n\ninterface Pending {\n\tresolve: (value: EmbSearchRawResponse) => void;\n\treject: (err: Error) => void;\n}\n\ninterface EmbSearchRawResponse {\n\tok: boolean;\n\terror?: string;\n\tresults?: EmbSearchResult[];\n\tinserted?: boolean;\n\tremoved?: boolean;\n\tcount?: number;\n\tinserted_count?: number;\n\tupdated_count?: number;\n\tmodel_id?: string;\n\tdim?: number;\n\t/** Cross-encoder scores. Separate from `results` because the daemon's\n\t * logits are not comparable to retrieval scores. */\n\treranked?: EmbSearchRerankResult[];\n}\n\nexport class EmbSearchClient {\n\tprivate proc: ChildProcessWithoutNullStreams;\n\tprivate queue: Pending[] = [];\n\tprivate buffer = \"\";\n\tprivate closed = false;\n\tprivate readyPromise: Promise<void>;\n\n\tconstructor(opts: EmbSearchClientOptions) {\n\t\tconst args = [\"serve\", \"--path\", opts.storePath];\n\t\tif (opts.metric) args.push(\"--metric\", opts.metric);\n\t\t// Hybrid-ness is fixed when a store is created: passing --hybrid against\n\t\t// an existing non-hybrid store warns and is ignored daemon-side, so a\n\t\t// hybrid store needs its own directory.\n\t\tif (opts.hybrid) args.push(\"--hybrid\");\n\n\t\tthis.proc = spawn(opts.binaryPath, args, { stdio: [\"pipe\", \"pipe\", \"pipe\"] });\n\n\t\tthis.proc.stdout.setEncoding(\"utf8\");\n\t\tthis.proc.stdout.on(\"data\", (chunk: string) => this.onStdout(chunk));\n\t\t// Swallow the informational stderr banner; errors surface via responses/exit.\n\t\tthis.proc.stderr.resume();\n\n\t\t// Readiness = the daemon answering a ping (probes the actual request loop).\n\t\tthis.readyPromise = this.send({ op: \"ping\" }).then(() => undefined);\n\t\t// Spawn failures reject the pending ping via the exit handler; mark the\n\t\t// promise handled so a failed spawn doesn't raise an unhandled rejection\n\t\t// before ready() is awaited.\n\t\tthis.readyPromise.catch(() => {});\n\n\t\tthis.proc.on(\"error\", (err) => {\n\t\t\tthis.closed = true;\n\t\t\tfor (const p of this.queue.splice(0)) p.reject(err);\n\t\t});\n\t\tthis.proc.on(\"exit\", (code) => {\n\t\t\tthis.closed = true;\n\t\t\tconst err = new Error(`embsearch daemon exited (code ${code})`);\n\t\t\tfor (const p of this.queue.splice(0)) p.reject(err);\n\t\t});\n\t}\n\n\t/** Resolves once the daemon has loaded the model + index. */\n\tready(): Promise<void> {\n\t\treturn this.readyPromise;\n\t}\n\n\tget isClosed(): boolean {\n\t\treturn this.closed;\n\t}\n\n\tprivate onStdout(chunk: string): void {\n\t\tthis.buffer += chunk;\n\t\t// Each response is one line; dispatch FIFO against the pending queue.\n\t\tfor (let nl = this.buffer.indexOf(\"\\n\"); nl !== -1; nl = this.buffer.indexOf(\"\\n\")) {\n\t\t\tconst line = this.buffer.slice(0, nl).trim();\n\t\t\tthis.buffer = this.buffer.slice(nl + 1);\n\t\t\tif (!line) continue;\n\t\t\tconst pending = this.queue.shift();\n\t\t\tif (!pending) continue;\n\t\t\tlet msg: EmbSearchRawResponse;\n\t\t\ttry {\n\t\t\t\tmsg = JSON.parse(line) as EmbSearchRawResponse;\n\t\t\t} catch {\n\t\t\t\tpending.reject(new Error(`bad response: ${line}`));\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (msg.ok) pending.resolve(msg);\n\t\t\telse pending.reject(new Error(msg.error ?? \"unknown error\"));\n\t\t}\n\t}\n\n\tprivate send(req: Record<string, unknown>): Promise<EmbSearchRawResponse> {\n\t\tif (this.closed) return Promise.reject(new Error(\"embsearch client is closed\"));\n\t\treturn new Promise<EmbSearchRawResponse>((resolve, reject) => {\n\t\t\tthis.queue.push({ resolve, reject });\n\t\t\tthis.proc.stdin.write(`${JSON.stringify(req)}\\n`);\n\t\t});\n\t}\n\n\t/**\n\t * Search for the top-`k` matches for `text`.\n\t *\n\t * `retriever` selects which leg answers:\n\t * - `dense` (default) — vector search, the historical behaviour;\n\t * - `lexical` — BM25 only, raw scores, no embedding computed;\n\t * - `hybrid` — both, pre-fused by the daemon's own RRF constant.\n\t *\n\t * `lexical` and `hybrid` need a store created with `--hybrid`. Prefer\n\t * `lexical` over `hybrid` when fusing here: `hybrid` collapses both legs\n\t * into one RRF score, discarding the per-retriever ranks the trace records\n\t * and preventing n-way fusion with the grep leg.\n\t */\n\tasync query(text: string, k = 10, retriever: DaemonRetriever = \"dense\"): Promise<EmbSearchResult[]> {\n\t\tconst res = await this.send(\n\t\t\tretriever === \"dense\" ? { op: \"query\", text, k } : { op: \"query\", text, k, retriever },\n\t\t);\n\t\treturn res.results ?? [];\n\t}\n\n\t/**\n\t * Score `passages` against `query` with the daemon's cross-encoder and\n\t * return the best `k`, best first.\n\t *\n\t * Passages are sent inline rather than referenced by id: the caller has the\n\t * exact spans it intends to show the model, and a cross-encoder scores the\n\t * text it is given, so sending anything else would score the wrong thing.\n\t * Requires an onnx build with reranker weights (embsearch >= 0.3.0).\n\t */\n\tasync rerank(query: string, passages: EmbSearchRerankPassage[], k: number): Promise<EmbSearchRerankResult[]> {\n\t\tconst res = await this.send({ op: \"rerank\", query, passages, k });\n\t\treturn res.reranked ?? [];\n\t}\n\n\t/**\n\t * Batched insert-or-replace. One embedding inference for the whole batch —\n\t * the fast path for bulk indexing. Keep batches modest (e.g. 32–64) so a\n\t * concurrent query is not stuck behind a huge inference.\n\t */\n\tasync bulk(items: Array<{ id: string; text: string }>): Promise<EmbSearchBulkResult> {\n\t\tconst res = await this.send({ op: \"bulk\", items });\n\t\treturn { inserted: res.inserted_count ?? 0, updated: res.updated_count ?? 0 };\n\t}\n\n\t/** Model id, dimensionality, and live vector count of the daemon. */\n\tasync info(): Promise<EmbSearchDaemonInfo> {\n\t\tconst res = await this.send({ op: \"info\" });\n\t\treturn { modelId: res.model_id ?? \"\", dim: res.dim ?? 0, count: res.count ?? 0 };\n\t}\n\n\t/** Remove a record. Resolves to `true` if it existed. */\n\tasync remove(id: string): Promise<boolean> {\n\t\tconst res = await this.send({ op: \"remove\", id });\n\t\treturn res.removed === true;\n\t}\n\n\t/** Reclaim tombstoned rows left behind by `remove`. */\n\tasync compact(): Promise<void> {\n\t\tawait this.send({ op: \"compact\" });\n\t}\n\n\t/** Persist the index to the store directory. */\n\tasync save(): Promise<void> {\n\t\tawait this.send({ op: \"save\" });\n\t}\n\n\t/** Shut the daemon down, closing stdin so it exits cleanly. */\n\tasync close(): Promise<void> {\n\t\tif (this.closed) return;\n\t\tthis.proc.stdin.end();\n\t\tawait new Promise<void>((resolve) => this.proc.on(\"exit\", () => resolve()));\n\t}\n}\n"]}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Every failure degrades to `unavailable` with a reason; nothing here ever
|
|
14
14
|
* blocks session startup or affects grep/find.
|
|
15
15
|
*/
|
|
16
|
+
import { type DaemonRetriever, type EmbSearchRerankPassage, type EmbSearchRerankResult } from "./client.js";
|
|
16
17
|
export type EmbsearchState = {
|
|
17
18
|
phase: "idle";
|
|
18
19
|
} | {
|
|
@@ -39,6 +40,17 @@ export interface EmbsearchServiceOptions {
|
|
|
39
40
|
binaryPath?: string;
|
|
40
41
|
/** Minimum indexable bytes before indexing kicks in. */
|
|
41
42
|
thresholdBytes: number;
|
|
43
|
+
/**
|
|
44
|
+
* Override the store location. Only the eval harness sets this, so a
|
|
45
|
+
* second index (e.g. a BM25-hybrid store) can exist for the same repo
|
|
46
|
+
* without colliding with the primary one.
|
|
47
|
+
*/
|
|
48
|
+
storeDir?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Create the store with the daemon's BM25 lexical index. Fixed at store
|
|
51
|
+
* creation, so this must pair with a distinct `storeDir`.
|
|
52
|
+
*/
|
|
53
|
+
hybridStore?: boolean;
|
|
42
54
|
/** Progress callback for UI (footer / stderr lines). */
|
|
43
55
|
onProgress?: (state: EmbsearchState) => void;
|
|
44
56
|
}
|
|
@@ -58,11 +70,28 @@ export declare class EmbsearchService {
|
|
|
58
70
|
private meta;
|
|
59
71
|
private state;
|
|
60
72
|
private disposed;
|
|
73
|
+
/** Whether the resolved binary serves `retriever: "lexical"`. */
|
|
74
|
+
private lexicalRetriever;
|
|
75
|
+
/** Whether the resolved binary serves the cross-encoder `rerank` op. */
|
|
76
|
+
private crossEncoder;
|
|
61
77
|
constructor(options: EmbsearchServiceOptions);
|
|
62
78
|
getState(): EmbsearchState;
|
|
63
79
|
/** Semantic search is usable (index ready, or still building with partial data). */
|
|
64
80
|
isAvailable(): boolean;
|
|
65
81
|
private setState;
|
|
82
|
+
/** Whether the running daemon can serve a BM25-only query. */
|
|
83
|
+
supportsLexicalRetriever(): boolean;
|
|
84
|
+
/** Whether the running daemon can score with a cross-encoder. */
|
|
85
|
+
supportsCrossEncoder(): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Cross-encoder rerank of caller-supplied passages.
|
|
88
|
+
*
|
|
89
|
+
* Unlike the retrievers this does not consult the index at all — it scores
|
|
90
|
+
* exactly the text passed in, which is why the caller sends its expanded
|
|
91
|
+
* windows rather than chunk ids.
|
|
92
|
+
*/
|
|
93
|
+
rerank(query: string, passages: EmbSearchRerankPassage[], k: number): Promise<EmbSearchRerankResult[]>;
|
|
94
|
+
private probeBinaryVersion;
|
|
66
95
|
private resolveBinary;
|
|
67
96
|
/**
|
|
68
97
|
* Scan, threshold-check, and (when needed) index in the background.
|
|
@@ -75,7 +104,7 @@ export declare class EmbsearchService {
|
|
|
75
104
|
/** Top-`k` semantic hits as `path` + line range + score. */
|
|
76
105
|
search(query: string, k?: number): Promise<SemanticHit[]>;
|
|
77
106
|
/** Top-`k` semantic hits including their chunk ids, for rank fusion. */
|
|
78
|
-
searchChunks(query: string, k?: number, glob?: string): Promise<SemanticChunkHit[]>;
|
|
107
|
+
searchChunks(query: string, k?: number, glob?: string, retriever?: DaemonRetriever): Promise<SemanticChunkHit[]>;
|
|
79
108
|
/**
|
|
80
109
|
* Resolve a repo-relative path + line to its enclosing indexed chunk, or
|
|
81
110
|
* undefined when the file/line is not covered by the index. Chunks overlap
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"embsearch-service.d.ts","sourceRoot":"","sources":["../../../src/core/embsearch/embsearch-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AA2BH,MAAM,MAAM,cAAc,GACvB;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GACjB;IAAE,KAAK,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,KAAK,EAAE,aAAa,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAC1E;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAClD;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,KAAK,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5C,MAAM,WAAW,uBAAuB;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,cAAc,EAAE,MAAM,CAAC;IACvB,wDAAwD;IACxD,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC7C;AAED,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACpD,sFAAoF;IACpF,EAAE,EAAE,MAAM,CAAC;CACX;AAED,qBAAa,gBAAgB;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,IAAI,CAAwB;IACpC,OAAO,CAAC,KAAK,CAAqC;IAClD,OAAO,CAAC,QAAQ,CAAS;IAEzB,YAAY,OAAO,EAAE,uBAAuB,EAE3C;IAED,QAAQ,IAAI,cAAc,CAEzB;IAED,oFAAoF;IACpF,WAAW,IAAI,OAAO,CAErB;IAED,OAAO,CAAC,QAAQ;YAKF,aAAa;IAY3B;;;OAGG;IACG,KAAK,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ/C;YAEa,GAAG;YAmCH,iBAAiB;IAqF/B,OAAO,CAAC,WAAW;IAMnB,4DAA4D;IACtD,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,SAAK,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAE1D;IAED,wEAAwE;IAClE,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,SAAK,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAqBpF;IAED;;;;;OAKG;IACH,kBAAkB,CACjB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,GACV;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAU9E;YAEa,WAAW;IAYzB,kEAAkE;IAC5D,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAW7B;CACD;AAWD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAMrF;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAE7E;AAED,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAE5D","sourcesContent":["/**\n * Orchestrates semantic indexing and search for a repository.\n *\n * Lifecycle (all behind --enable-embsearchtools):\n * 1. `start()` — resolve the embsearch binary, scan the repo (ignore-aware),\n * apply the byte threshold. Under threshold → dormant. Over → spawn the\n * daemon, verify the backend is not the mock embedder, then index changed\n * files in the background in small batches, reporting progress.\n * 2. `search()` — top-k semantic query, mapping chunk ids back to\n * `path:start-end` via the sidecar metadata.\n * 3. `dispose()` — save + close the daemon.\n *\n * Every failure degrades to `unavailable` with a reason; nothing here ever\n * blocks session startup or affects grep/find.\n */\n\nimport { readFileSync } from \"fs\";\nimport { minimatch } from \"minimatch\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { chunkFile } from \"./chunker.js\";\nimport { EmbSearchClient } from \"./client.js\";\nimport {\n\temptyIndexMeta,\n\ttype FileMeta,\n\tgetEmbsearchStoreDir,\n\tgetVectorStoreDir,\n\thashContent,\n\ttype IndexMeta,\n\tloadIndexMeta,\n\tsaveIndexMeta,\n} from \"./index-meta.js\";\nimport { type RepoScanFile, scanRepo } from \"./repo-scan.js\";\n\n/** Chunks per bulk request. Small enough that a concurrent query is never\n * stuck long behind one padded batch inference. */\nconst BULK_BATCH_SIZE = 48;\n/** Yield between batches so background indexing doesn't starve the session. */\nconst BATCH_YIELD_MS = 15;\n/** The Rust mock backend's model id — semantically meaningless, never index with it. */\nconst MOCK_MODEL_ID = \"mock-hash-v1\";\n\nexport type EmbsearchState =\n\t| { phase: \"idle\" }\n\t| { phase: \"skipped\"; reason: string }\n\t| { phase: \"downloading\"; receivedBytes: number; totalBytes: number | null }\n\t| { phase: \"indexing\"; done: number; total: number }\n\t| { phase: \"ready\"; chunkCount: number }\n\t| { phase: \"unavailable\"; reason: string };\n\nexport interface EmbsearchServiceOptions {\n\tcwd: string;\n\t/** Explicit binary path (settings override). Default: \"embsearch\" from PATH. */\n\tbinaryPath?: string;\n\t/** Minimum indexable bytes before indexing kicks in. */\n\tthresholdBytes: number;\n\t/** Progress callback for UI (footer / stderr lines). */\n\tonProgress?: (state: EmbsearchState) => void;\n}\n\nexport interface SemanticHit {\n\tpath: string;\n\tstartLine: number;\n\tendLine: number;\n\tscore: number;\n}\n\nexport interface SemanticChunkHit extends SemanticHit {\n\t/** Per-build chunk id (`relpath#index`) — the fusion identity for hybrid search. */\n\tid: string;\n}\n\nexport class EmbsearchService {\n\tprivate readonly options: EmbsearchServiceOptions;\n\tprivate client: EmbSearchClient | undefined;\n\tprivate meta: IndexMeta | undefined;\n\tprivate state: EmbsearchState = { phase: \"idle\" };\n\tprivate disposed = false;\n\n\tconstructor(options: EmbsearchServiceOptions) {\n\t\tthis.options = options;\n\t}\n\n\tgetState(): EmbsearchState {\n\t\treturn this.state;\n\t}\n\n\t/** Semantic search is usable (index ready, or still building with partial data). */\n\tisAvailable(): boolean {\n\t\treturn this.state.phase === \"ready\" || this.state.phase === \"indexing\";\n\t}\n\n\tprivate setState(state: EmbsearchState): void {\n\t\tthis.state = state;\n\t\tthis.options.onProgress?.(state);\n\t}\n\n\tprivate async resolveBinary(): Promise<string | undefined> {\n\t\tif (this.options.binaryPath) {\n\t\t\treturn this.options.binaryPath;\n\t\t}\n\t\t// Surface the on-demand binary download through the same progress channel as\n\t\t// indexing, so the first-run fetch renders a progress bar instead of a stall.\n\t\t// A cached binary resolves without ever invoking this callback.\n\t\treturn await ensureTool(\"embsearch\", true, (receivedBytes, totalBytes) => {\n\t\t\tthis.setState({ phase: \"downloading\", receivedBytes, totalBytes });\n\t\t});\n\t}\n\n\t/**\n\t * Scan, threshold-check, and (when needed) index in the background.\n\t * Resolves when indexing completes or the feature settles dormant.\n\t */\n\tasync start(signal?: AbortSignal): Promise<void> {\n\t\ttry {\n\t\t\tawait this.run(signal);\n\t\t} catch (e) {\n\t\t\tconst reason = e instanceof Error ? e.message : String(e);\n\t\t\tthis.setState({ phase: \"unavailable\", reason });\n\t\t\tawait this.closeClient();\n\t\t}\n\t}\n\n\tprivate async run(signal?: AbortSignal): Promise<void> {\n\t\tconst binary = await this.resolveBinary();\n\t\tif (!binary) {\n\t\t\tthis.setState({\n\t\t\t\tphase: \"unavailable\",\n\t\t\t\treason: \"embsearch binary not found (PATH or embsearchBinaryPath setting)\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst scan = scanRepo(this.options.cwd, signal);\n\t\tif (scan.totalBytes < this.options.thresholdBytes) {\n\t\t\tthis.setState({\n\t\t\t\tphase: \"skipped\",\n\t\t\t\treason: `repo under threshold (${scan.totalBytes} < ${this.options.thresholdBytes} bytes)`,\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst storeDir = getEmbsearchStoreDir(this.options.cwd);\n\t\tthis.client = new EmbSearchClient({ binaryPath: binary, storePath: getVectorStoreDir(storeDir) });\n\t\tawait this.client.ready();\n\n\t\tconst info = await this.client.info();\n\t\tif (info.modelId === MOCK_MODEL_ID) {\n\t\t\tthrow new Error(\"embsearch binary uses the mock embedder (not semantic); install an onnx build\");\n\t\t}\n\n\t\t// Missing/stale sidecar (format, chunker, or model changed) → clean rebuild.\n\t\tthis.meta = loadIndexMeta(storeDir, info.modelId) ?? emptyIndexMeta(this.options.cwd, info.modelId);\n\t\tthis.meta.lastUsedMs = Date.now();\n\n\t\tawait this.indexChangedFiles(scan.files, storeDir, signal);\n\t}\n\n\tprivate async indexChangedFiles(files: RepoScanFile[], storeDir: string, signal?: AbortSignal): Promise<void> {\n\t\tconst meta = this.meta!;\n\t\tconst client = this.client!;\n\n\t\t// Diff scan vs sidecar: cheap mtime+size check first, hash only on delta.\n\t\tconst toIndex: Array<{ file: RepoScanFile; content: string; hash: string }> = [];\n\t\tconst seen = new Set<string>();\n\t\tfor (const file of files) {\n\t\t\tseen.add(file.rel);\n\t\t\tconst known = meta.files[file.rel];\n\t\t\tif (known && known.mtimeMs === file.mtimeMs && known.size === file.size) continue;\n\t\t\tlet content: string;\n\t\t\ttry {\n\t\t\t\tcontent = readFileSync(file.abs, \"utf-8\");\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst hash = hashContent(content);\n\t\t\tif (known && known.hash === hash) {\n\t\t\t\t// Touched but unchanged — refresh stat info only.\n\t\t\t\tknown.mtimeMs = file.mtimeMs;\n\t\t\t\tknown.size = file.size;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\ttoIndex.push({ file, content, hash });\n\t\t}\n\t\tconst toRemove = Object.keys(meta.files).filter((rel) => !seen.has(rel));\n\n\t\tif (toIndex.length === 0 && toRemove.length === 0) {\n\t\t\tsaveIndexMeta(storeDir, meta);\n\t\t\tthis.setState({ phase: \"ready\", chunkCount: this.countChunks(meta) });\n\t\t\treturn;\n\t\t}\n\n\t\t// Chunk changed files; count total upserts for exact progress.\n\t\tconst work: Array<{ rel: string; fileMeta: FileMeta; chunks: Array<{ id: string; text: string }> }> = [];\n\t\tlet totalChunks = 0;\n\t\tfor (const { file, content, hash } of toIndex) {\n\t\t\tconst chunks = chunkFile(file.rel, content);\n\t\t\twork.push({\n\t\t\t\trel: file.rel,\n\t\t\t\tfileMeta: {\n\t\t\t\t\tmtimeMs: file.mtimeMs,\n\t\t\t\t\tsize: file.size,\n\t\t\t\t\thash,\n\t\t\t\t\tchunks: chunks.map((c) => [c.startLine, c.endLine]),\n\t\t\t\t},\n\t\t\t\tchunks: chunks.map((c) => ({ id: c.id, text: c.text })),\n\t\t\t});\n\t\t\ttotalChunks += chunks.length;\n\t\t}\n\n\t\tthis.setState({ phase: \"indexing\", done: 0, total: totalChunks });\n\n\t\t// Drop vectors of deleted files and superseded chunk tails.\n\t\tfor (const rel of toRemove) {\n\t\t\tfor (let i = 0; i < meta.files[rel].chunks.length; i++) await client.remove(`${rel}#${i}`);\n\t\t\tdelete meta.files[rel];\n\t\t}\n\n\t\tlet done = 0;\n\t\tfor (const item of work) {\n\t\t\tif (signal?.aborted || this.disposed) return;\n\t\t\tconst oldChunkCount = meta.files[item.rel]?.chunks.length ?? 0;\n\t\t\t// Remove old chunks beyond the new count (upsert covers the rest).\n\t\t\tfor (let i = item.chunks.length; i < oldChunkCount; i++) await client.remove(`${item.rel}#${i}`);\n\n\t\t\tfor (let offset = 0; offset < item.chunks.length; offset += BULK_BATCH_SIZE) {\n\t\t\t\tif (signal?.aborted || this.disposed) return;\n\t\t\t\tconst batch = item.chunks.slice(offset, offset + BULK_BATCH_SIZE);\n\t\t\t\tawait client.bulk(batch);\n\t\t\t\tdone += batch.length;\n\t\t\t\tthis.setState({ phase: \"indexing\", done, total: totalChunks });\n\t\t\t\t// Yield so queries and the event loop stay responsive.\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, BATCH_YIELD_MS));\n\t\t\t}\n\t\t\tmeta.files[item.rel] = item.fileMeta;\n\t\t}\n\n\t\tawait client.compact();\n\t\tawait client.save();\n\t\tsaveIndexMeta(storeDir, meta);\n\t\tthis.setState({ phase: \"ready\", chunkCount: this.countChunks(meta) });\n\t}\n\n\tprivate countChunks(meta: IndexMeta): number {\n\t\tlet n = 0;\n\t\tfor (const rel of Object.keys(meta.files)) n += meta.files[rel].chunks.length;\n\t\treturn n;\n\t}\n\n\t/** Top-`k` semantic hits as `path` + line range + score. */\n\tasync search(query: string, k = 10): Promise<SemanticHit[]> {\n\t\treturn await this.searchChunks(query, k);\n\t}\n\n\t/** Top-`k` semantic hits including their chunk ids, for rank fusion. */\n\tasync searchChunks(query: string, k = 10, glob?: string): Promise<SemanticChunkHit[]> {\n\t\tif (!this.client || this.client.isClosed || !this.meta) {\n\t\t\tthrow new Error(\"semantic index is not available\");\n\t\t}\n\t\tconst results = await this.client.query(query, k);\n\t\tconst hits: SemanticChunkHit[] = [];\n\t\tconst matchGlob = (rel: string): boolean => {\n\t\t\tif (!glob) return true;\n\t\t\treturn minimatch(rel, glob, { dot: true, matchBase: !glob.includes(\"/\") });\n\t\t};\n\t\tfor (const result of results) {\n\t\t\tconst sep = result.id.lastIndexOf(\"#\");\n\t\t\tif (sep === -1) continue;\n\t\t\tconst rel = result.id.slice(0, sep);\n\t\t\tif (!matchGlob(rel)) continue;\n\t\t\tconst chunkIndex = Number.parseInt(result.id.slice(sep + 1), 10);\n\t\t\tconst range = this.meta.files[rel]?.chunks[chunkIndex];\n\t\t\tif (!range) continue;\n\t\t\thits.push({ id: result.id, path: rel, startLine: range[0], endLine: range[1], score: result.score });\n\t\t}\n\t\treturn hits;\n\t}\n\n\t/**\n\t * Resolve a repo-relative path + line to its enclosing indexed chunk, or\n\t * undefined when the file/line is not covered by the index. Chunks overlap\n\t * by a few lines; the first (lowest-index) containing chunk wins so the\n\t * mapping is deterministic.\n\t */\n\tfindEnclosingChunk(\n\t\trel: string,\n\t\tline: number,\n\t): { id: string; path: string; startLine: number; endLine: number } | undefined {\n\t\tconst file = this.meta?.files[rel];\n\t\tif (!file) return undefined;\n\t\tfor (let i = 0; i < file.chunks.length; i++) {\n\t\t\tconst [startLine, endLine] = file.chunks[i];\n\t\t\tif (line >= startLine && line <= endLine) {\n\t\t\t\treturn { id: `${rel}#${i}`, path: rel, startLine, endLine };\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate async closeClient(): Promise<void> {\n\t\tconst client = this.client;\n\t\tthis.client = undefined;\n\t\tif (client && !client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait client.close();\n\t\t\t} catch {\n\t\t\t\t// already dead\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Persist state and shut the daemon down. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\t\tif (this.client && !this.client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait this.client.save();\n\t\t\t} catch {\n\t\t\t\t// daemon may have exited; nothing to save\n\t\t\t}\n\t\t}\n\t\tawait this.closeClient();\n\t}\n}\n\n// --- Per-cwd service registry ---\n//\n// The search tool is constructed by the generic tool factory table and\n// only receives `cwd`; the service is created later during session init (flag\n// gated). This registry connects the two without threading a service instance\n// through every layer between main.ts and the tool factories.\n\nconst services = new Map<string, EmbsearchService>();\n\nexport function registerEmbsearchService(cwd: string, service: EmbsearchService): void {\n\tconst old = services.get(cwd);\n\tif (old && old !== service) {\n\t\told.dispose().catch(() => {});\n\t}\n\tservices.set(cwd, service);\n}\n\nexport function getEmbsearchService(cwd: string): EmbsearchService | undefined {\n\treturn services.get(cwd);\n}\n\nexport function unregisterEmbsearchService(cwd: string): void {\n\tservices.delete(cwd);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"embsearch-service.d.ts","sourceRoot":"","sources":["../../../src/core/embsearch/embsearch-service.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Orchestrates semantic indexing and search for a repository.\n *\n * Lifecycle (all behind --enable-embsearchtools):\n * 1. `start()` — resolve the embsearch binary, scan the repo (ignore-aware),\n * apply the byte threshold. Under threshold → dormant. Over → spawn the\n * daemon, verify the backend is not the mock embedder, then index changed\n * files in the background in small batches, reporting progress.\n * 2. `search()` — top-k semantic query, mapping chunk ids back to\n * `path:start-end` via the sidecar metadata.\n * 3. `dispose()` — save + close the daemon.\n *\n * Every failure degrades to `unavailable` with a reason; nothing here ever\n * blocks session startup or affects grep/find.\n */\n\nimport { execFileSync } from \"child_process\";\nimport { readFileSync } from \"fs\";\nimport { minimatch } from \"minimatch\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { chunkFile } from \"./chunker.js\";\nimport {\n\ttype DaemonRetriever,\n\tEmbSearchClient,\n\ttype EmbSearchRerankPassage,\n\ttype EmbSearchRerankResult,\n} from \"./client.js\";\nimport {\n\temptyIndexMeta,\n\ttype FileMeta,\n\tgetEmbsearchStoreDir,\n\tgetVectorStoreDir,\n\thashContent,\n\ttype IndexMeta,\n\tloadIndexMeta,\n\tsaveIndexMeta,\n} from \"./index-meta.js\";\nimport { type RepoScanFile, scanRepo } from \"./repo-scan.js\";\n\n/** Chunks per bulk request. Small enough that a concurrent query is never\n * stuck long behind one padded batch inference. */\nconst BULK_BATCH_SIZE = 48;\n/** Yield between batches so background indexing doesn't starve the session. */\nconst BATCH_YIELD_MS = 15;\n/** The Rust mock backend's model id — semantically meaningless, never index with it. */\nconst MOCK_MODEL_ID = \"mock-hash-v1\";\n/**\n * First embsearch release serving `retriever: \"lexical\"`.\n *\n * The guard matters because the daemon does not reject unknown request fields:\n * an older binary silently ignores `retriever` and answers with dense results.\n * Fusing that list a second time as a \"bm25\" leg would double-count it and\n * corrupt the ranking with no error anywhere — so refuse instead of degrading.\n */\nconst MIN_LEXICAL_RETRIEVER_VERSION = [0, 2, 0] as const;\n/** First embsearch release bundling cross-encoder reranker weights. */\nconst MIN_RERANK_VERSION = [0, 3, 0] as const;\n\n/** `embsearch 0.2.0` -> [0, 2, 0]; undefined when it cannot be parsed. */\nfunction parseBinaryVersion(output: string): number[] | undefined {\n\tconst match = output.match(/(\\d+)\\.(\\d+)\\.(\\d+)/);\n\treturn match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;\n}\n\nfunction atLeast(version: readonly number[], minimum: readonly number[]): boolean {\n\tfor (let i = 0; i < minimum.length; i++) {\n\t\tconst part = version[i] ?? 0;\n\t\tif (part !== minimum[i]) return part > minimum[i];\n\t}\n\treturn true;\n}\n\nexport type EmbsearchState =\n\t| { phase: \"idle\" }\n\t| { phase: \"skipped\"; reason: string }\n\t| { phase: \"downloading\"; receivedBytes: number; totalBytes: number | null }\n\t| { phase: \"indexing\"; done: number; total: number }\n\t| { phase: \"ready\"; chunkCount: number }\n\t| { phase: \"unavailable\"; reason: string };\n\nexport interface EmbsearchServiceOptions {\n\tcwd: string;\n\t/** Explicit binary path (settings override). Default: \"embsearch\" from PATH. */\n\tbinaryPath?: string;\n\t/** Minimum indexable bytes before indexing kicks in. */\n\tthresholdBytes: number;\n\t/**\n\t * Override the store location. Only the eval harness sets this, so a\n\t * second index (e.g. a BM25-hybrid store) can exist for the same repo\n\t * without colliding with the primary one.\n\t */\n\tstoreDir?: string;\n\t/**\n\t * Create the store with the daemon's BM25 lexical index. Fixed at store\n\t * creation, so this must pair with a distinct `storeDir`.\n\t */\n\thybridStore?: boolean;\n\t/** Progress callback for UI (footer / stderr lines). */\n\tonProgress?: (state: EmbsearchState) => void;\n}\n\nexport interface SemanticHit {\n\tpath: string;\n\tstartLine: number;\n\tendLine: number;\n\tscore: number;\n}\n\nexport interface SemanticChunkHit extends SemanticHit {\n\t/** Per-build chunk id (`relpath#index`) — the fusion identity for hybrid search. */\n\tid: string;\n}\n\nexport class EmbsearchService {\n\tprivate readonly options: EmbsearchServiceOptions;\n\tprivate client: EmbSearchClient | undefined;\n\tprivate meta: IndexMeta | undefined;\n\tprivate state: EmbsearchState = { phase: \"idle\" };\n\tprivate disposed = false;\n\t/** Whether the resolved binary serves `retriever: \"lexical\"`. */\n\tprivate lexicalRetriever = false;\n\t/** Whether the resolved binary serves the cross-encoder `rerank` op. */\n\tprivate crossEncoder = false;\n\n\tconstructor(options: EmbsearchServiceOptions) {\n\t\tthis.options = options;\n\t}\n\n\tgetState(): EmbsearchState {\n\t\treturn this.state;\n\t}\n\n\t/** Semantic search is usable (index ready, or still building with partial data). */\n\tisAvailable(): boolean {\n\t\treturn this.state.phase === \"ready\" || this.state.phase === \"indexing\";\n\t}\n\n\tprivate setState(state: EmbsearchState): void {\n\t\tthis.state = state;\n\t\tthis.options.onProgress?.(state);\n\t}\n\n\t/** Whether the running daemon can serve a BM25-only query. */\n\tsupportsLexicalRetriever(): boolean {\n\t\treturn this.lexicalRetriever;\n\t}\n\n\t/** Whether the running daemon can score with a cross-encoder. */\n\tsupportsCrossEncoder(): boolean {\n\t\treturn this.crossEncoder;\n\t}\n\n\t/**\n\t * Cross-encoder rerank of caller-supplied passages.\n\t *\n\t * Unlike the retrievers this does not consult the index at all — it scores\n\t * exactly the text passed in, which is why the caller sends its expanded\n\t * windows rather than chunk ids.\n\t */\n\tasync rerank(query: string, passages: EmbSearchRerankPassage[], k: number): Promise<EmbSearchRerankResult[]> {\n\t\tif (!this.client || this.client.isClosed) {\n\t\t\tthrow new Error(\"semantic index is not available\");\n\t\t}\n\t\tif (!this.crossEncoder) {\n\t\t\tthrow new Error(\n\t\t\t\t`embsearch is too old to rerank (needs >= ${MIN_RERANK_VERSION.join(\".\")}); ` +\n\t\t\t\t\t\"an older daemon has no cross-encoder weights\",\n\t\t\t);\n\t\t}\n\t\treturn await this.client.rerank(query, passages, k);\n\t}\n\n\tprivate probeBinaryVersion(binary: string): number[] | undefined {\n\t\ttry {\n\t\t\treturn parseBinaryVersion(execFileSync(binary, [\"--version\"], { encoding: \"utf-8\", timeout: 10_000 }));\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate async resolveBinary(): Promise<string | undefined> {\n\t\tif (this.options.binaryPath) {\n\t\t\treturn this.options.binaryPath;\n\t\t}\n\t\t// Surface the on-demand binary download through the same progress channel as\n\t\t// indexing, so the first-run fetch renders a progress bar instead of a stall.\n\t\t// A cached binary resolves without ever invoking this callback.\n\t\treturn await ensureTool(\"embsearch\", true, (receivedBytes, totalBytes) => {\n\t\t\tthis.setState({ phase: \"downloading\", receivedBytes, totalBytes });\n\t\t});\n\t}\n\n\t/**\n\t * Scan, threshold-check, and (when needed) index in the background.\n\t * Resolves when indexing completes or the feature settles dormant.\n\t */\n\tasync start(signal?: AbortSignal): Promise<void> {\n\t\ttry {\n\t\t\tawait this.run(signal);\n\t\t} catch (e) {\n\t\t\tconst reason = e instanceof Error ? e.message : String(e);\n\t\t\tthis.setState({ phase: \"unavailable\", reason });\n\t\t\tawait this.closeClient();\n\t\t}\n\t}\n\n\tprivate async run(signal?: AbortSignal): Promise<void> {\n\t\tconst binary = await this.resolveBinary();\n\t\tif (!binary) {\n\t\t\tthis.setState({\n\t\t\t\tphase: \"unavailable\",\n\t\t\t\treason: \"embsearch binary not found (PATH or embsearchBinaryPath setting)\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst scan = scanRepo(this.options.cwd, signal);\n\t\tif (scan.totalBytes < this.options.thresholdBytes) {\n\t\t\tthis.setState({\n\t\t\t\tphase: \"skipped\",\n\t\t\t\treason: `repo under threshold (${scan.totalBytes} < ${this.options.thresholdBytes} bytes)`,\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst binaryVersion = this.probeBinaryVersion(binary);\n\t\tthis.lexicalRetriever = binaryVersion !== undefined && atLeast(binaryVersion, MIN_LEXICAL_RETRIEVER_VERSION);\n\t\tthis.crossEncoder = binaryVersion !== undefined && atLeast(binaryVersion, MIN_RERANK_VERSION);\n\n\t\tconst storeDir = this.options.storeDir ?? getEmbsearchStoreDir(this.options.cwd);\n\t\tthis.client = new EmbSearchClient({\n\t\t\tbinaryPath: binary,\n\t\t\tstorePath: getVectorStoreDir(storeDir),\n\t\t\thybrid: this.options.hybridStore,\n\t\t});\n\t\tawait this.client.ready();\n\n\t\tconst info = await this.client.info();\n\t\tif (info.modelId === MOCK_MODEL_ID) {\n\t\t\tthrow new Error(\"embsearch binary uses the mock embedder (not semantic); install an onnx build\");\n\t\t}\n\n\t\t// Missing/stale sidecar (format, chunker, or model changed) → clean rebuild.\n\t\tthis.meta = loadIndexMeta(storeDir, info.modelId) ?? emptyIndexMeta(this.options.cwd, info.modelId);\n\t\tthis.meta.lastUsedMs = Date.now();\n\n\t\tawait this.indexChangedFiles(scan.files, storeDir, signal);\n\t}\n\n\tprivate async indexChangedFiles(files: RepoScanFile[], storeDir: string, signal?: AbortSignal): Promise<void> {\n\t\tconst meta = this.meta!;\n\t\tconst client = this.client!;\n\n\t\t// Diff scan vs sidecar: cheap mtime+size check first, hash only on delta.\n\t\tconst toIndex: Array<{ file: RepoScanFile; content: string; hash: string }> = [];\n\t\tconst seen = new Set<string>();\n\t\tfor (const file of files) {\n\t\t\tseen.add(file.rel);\n\t\t\tconst known = meta.files[file.rel];\n\t\t\tif (known && known.mtimeMs === file.mtimeMs && known.size === file.size) continue;\n\t\t\tlet content: string;\n\t\t\ttry {\n\t\t\t\tcontent = readFileSync(file.abs, \"utf-8\");\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst hash = hashContent(content);\n\t\t\tif (known && known.hash === hash) {\n\t\t\t\t// Touched but unchanged — refresh stat info only.\n\t\t\t\tknown.mtimeMs = file.mtimeMs;\n\t\t\t\tknown.size = file.size;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\ttoIndex.push({ file, content, hash });\n\t\t}\n\t\tconst toRemove = Object.keys(meta.files).filter((rel) => !seen.has(rel));\n\n\t\tif (toIndex.length === 0 && toRemove.length === 0) {\n\t\t\tsaveIndexMeta(storeDir, meta);\n\t\t\tthis.setState({ phase: \"ready\", chunkCount: this.countChunks(meta) });\n\t\t\treturn;\n\t\t}\n\n\t\t// Chunk changed files; count total upserts for exact progress.\n\t\tconst work: Array<{ rel: string; fileMeta: FileMeta; chunks: Array<{ id: string; text: string }> }> = [];\n\t\tlet totalChunks = 0;\n\t\tfor (const { file, content, hash } of toIndex) {\n\t\t\tconst chunks = chunkFile(file.rel, content);\n\t\t\twork.push({\n\t\t\t\trel: file.rel,\n\t\t\t\tfileMeta: {\n\t\t\t\t\tmtimeMs: file.mtimeMs,\n\t\t\t\t\tsize: file.size,\n\t\t\t\t\thash,\n\t\t\t\t\tchunks: chunks.map((c) => [c.startLine, c.endLine]),\n\t\t\t\t},\n\t\t\t\tchunks: chunks.map((c) => ({ id: c.id, text: c.text })),\n\t\t\t});\n\t\t\ttotalChunks += chunks.length;\n\t\t}\n\n\t\tthis.setState({ phase: \"indexing\", done: 0, total: totalChunks });\n\n\t\t// Drop vectors of deleted files and superseded chunk tails.\n\t\tfor (const rel of toRemove) {\n\t\t\tfor (let i = 0; i < meta.files[rel].chunks.length; i++) await client.remove(`${rel}#${i}`);\n\t\t\tdelete meta.files[rel];\n\t\t}\n\n\t\tlet done = 0;\n\t\tfor (const item of work) {\n\t\t\tif (signal?.aborted || this.disposed) return;\n\t\t\tconst oldChunkCount = meta.files[item.rel]?.chunks.length ?? 0;\n\t\t\t// Remove old chunks beyond the new count (upsert covers the rest).\n\t\t\tfor (let i = item.chunks.length; i < oldChunkCount; i++) await client.remove(`${item.rel}#${i}`);\n\n\t\t\tfor (let offset = 0; offset < item.chunks.length; offset += BULK_BATCH_SIZE) {\n\t\t\t\tif (signal?.aborted || this.disposed) return;\n\t\t\t\tconst batch = item.chunks.slice(offset, offset + BULK_BATCH_SIZE);\n\t\t\t\tawait client.bulk(batch);\n\t\t\t\tdone += batch.length;\n\t\t\t\tthis.setState({ phase: \"indexing\", done, total: totalChunks });\n\t\t\t\t// Yield so queries and the event loop stay responsive.\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, BATCH_YIELD_MS));\n\t\t\t}\n\t\t\tmeta.files[item.rel] = item.fileMeta;\n\t\t}\n\n\t\tawait client.compact();\n\t\tawait client.save();\n\t\tsaveIndexMeta(storeDir, meta);\n\t\tthis.setState({ phase: \"ready\", chunkCount: this.countChunks(meta) });\n\t}\n\n\tprivate countChunks(meta: IndexMeta): number {\n\t\tlet n = 0;\n\t\tfor (const rel of Object.keys(meta.files)) n += meta.files[rel].chunks.length;\n\t\treturn n;\n\t}\n\n\t/** Top-`k` semantic hits as `path` + line range + score. */\n\tasync search(query: string, k = 10): Promise<SemanticHit[]> {\n\t\treturn await this.searchChunks(query, k);\n\t}\n\n\t/** Top-`k` semantic hits including their chunk ids, for rank fusion. */\n\tasync searchChunks(\n\t\tquery: string,\n\t\tk = 10,\n\t\tglob?: string,\n\t\tretriever: DaemonRetriever = \"dense\",\n\t): Promise<SemanticChunkHit[]> {\n\t\tif (!this.client || this.client.isClosed || !this.meta) {\n\t\t\tthrow new Error(\"semantic index is not available\");\n\t\t}\n\t\tif (retriever === \"lexical\" && !this.lexicalRetriever) {\n\t\t\tthrow new Error(\n\t\t\t\t`embsearch is too old for retriever \"lexical\" (needs >= ${MIN_LEXICAL_RETRIEVER_VERSION.join(\".\")}); ` +\n\t\t\t\t\t\"an older daemon ignores the field and answers with dense results\",\n\t\t\t);\n\t\t}\n\t\tconst results = await this.client.query(query, k, retriever);\n\t\tconst hits: SemanticChunkHit[] = [];\n\t\tconst matchGlob = (rel: string): boolean => {\n\t\t\tif (!glob) return true;\n\t\t\treturn minimatch(rel, glob, { dot: true, matchBase: !glob.includes(\"/\") });\n\t\t};\n\t\tfor (const result of results) {\n\t\t\tconst sep = result.id.lastIndexOf(\"#\");\n\t\t\tif (sep === -1) continue;\n\t\t\tconst rel = result.id.slice(0, sep);\n\t\t\tif (!matchGlob(rel)) continue;\n\t\t\tconst chunkIndex = Number.parseInt(result.id.slice(sep + 1), 10);\n\t\t\tconst range = this.meta.files[rel]?.chunks[chunkIndex];\n\t\t\tif (!range) continue;\n\t\t\thits.push({ id: result.id, path: rel, startLine: range[0], endLine: range[1], score: result.score });\n\t\t}\n\t\treturn hits;\n\t}\n\n\t/**\n\t * Resolve a repo-relative path + line to its enclosing indexed chunk, or\n\t * undefined when the file/line is not covered by the index. Chunks overlap\n\t * by a few lines; the first (lowest-index) containing chunk wins so the\n\t * mapping is deterministic.\n\t */\n\tfindEnclosingChunk(\n\t\trel: string,\n\t\tline: number,\n\t): { id: string; path: string; startLine: number; endLine: number } | undefined {\n\t\tconst file = this.meta?.files[rel];\n\t\tif (!file) return undefined;\n\t\tfor (let i = 0; i < file.chunks.length; i++) {\n\t\t\tconst [startLine, endLine] = file.chunks[i];\n\t\t\tif (line >= startLine && line <= endLine) {\n\t\t\t\treturn { id: `${rel}#${i}`, path: rel, startLine, endLine };\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate async closeClient(): Promise<void> {\n\t\tconst client = this.client;\n\t\tthis.client = undefined;\n\t\tif (client && !client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait client.close();\n\t\t\t} catch {\n\t\t\t\t// already dead\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Persist state and shut the daemon down. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\t\tif (this.client && !this.client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait this.client.save();\n\t\t\t} catch {\n\t\t\t\t// daemon may have exited; nothing to save\n\t\t\t}\n\t\t}\n\t\tawait this.closeClient();\n\t}\n}\n\n// --- Per-cwd service registry ---\n//\n// The search tool is constructed by the generic tool factory table and\n// only receives `cwd`; the service is created later during session init (flag\n// gated). This registry connects the two without threading a service instance\n// through every layer between main.ts and the tool factories.\n\nconst services = new Map<string, EmbsearchService>();\n\nexport function registerEmbsearchService(cwd: string, service: EmbsearchService): void {\n\tconst old = services.get(cwd);\n\tif (old && old !== service) {\n\t\told.dispose().catch(() => {});\n\t}\n\tservices.set(cwd, service);\n}\n\nexport function getEmbsearchService(cwd: string): EmbsearchService | undefined {\n\treturn services.get(cwd);\n}\n\nexport function unregisterEmbsearchService(cwd: string): void {\n\tservices.delete(cwd);\n}\n"]}
|
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
* Every failure degrades to `unavailable` with a reason; nothing here ever
|
|
14
14
|
* blocks session startup or affects grep/find.
|
|
15
15
|
*/
|
|
16
|
+
import { execFileSync } from "child_process";
|
|
16
17
|
import { readFileSync } from "fs";
|
|
17
18
|
import { minimatch } from "minimatch";
|
|
18
19
|
import { ensureTool } from "../../utils/tools-manager.js";
|
|
19
20
|
import { chunkFile } from "./chunker.js";
|
|
20
|
-
import { EmbSearchClient } from "./client.js";
|
|
21
|
+
import { EmbSearchClient, } from "./client.js";
|
|
21
22
|
import { emptyIndexMeta, getEmbsearchStoreDir, getVectorStoreDir, hashContent, loadIndexMeta, saveIndexMeta, } from "./index-meta.js";
|
|
22
23
|
import { scanRepo } from "./repo-scan.js";
|
|
23
24
|
/** Chunks per bulk request. Small enough that a concurrent query is never
|
|
@@ -27,12 +28,40 @@ const BULK_BATCH_SIZE = 48;
|
|
|
27
28
|
const BATCH_YIELD_MS = 15;
|
|
28
29
|
/** The Rust mock backend's model id — semantically meaningless, never index with it. */
|
|
29
30
|
const MOCK_MODEL_ID = "mock-hash-v1";
|
|
31
|
+
/**
|
|
32
|
+
* First embsearch release serving `retriever: "lexical"`.
|
|
33
|
+
*
|
|
34
|
+
* The guard matters because the daemon does not reject unknown request fields:
|
|
35
|
+
* an older binary silently ignores `retriever` and answers with dense results.
|
|
36
|
+
* Fusing that list a second time as a "bm25" leg would double-count it and
|
|
37
|
+
* corrupt the ranking with no error anywhere — so refuse instead of degrading.
|
|
38
|
+
*/
|
|
39
|
+
const MIN_LEXICAL_RETRIEVER_VERSION = [0, 2, 0];
|
|
40
|
+
/** First embsearch release bundling cross-encoder reranker weights. */
|
|
41
|
+
const MIN_RERANK_VERSION = [0, 3, 0];
|
|
42
|
+
/** `embsearch 0.2.0` -> [0, 2, 0]; undefined when it cannot be parsed. */
|
|
43
|
+
function parseBinaryVersion(output) {
|
|
44
|
+
const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
45
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
|
|
46
|
+
}
|
|
47
|
+
function atLeast(version, minimum) {
|
|
48
|
+
for (let i = 0; i < minimum.length; i++) {
|
|
49
|
+
const part = version[i] ?? 0;
|
|
50
|
+
if (part !== minimum[i])
|
|
51
|
+
return part > minimum[i];
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
30
55
|
export class EmbsearchService {
|
|
31
56
|
options;
|
|
32
57
|
client;
|
|
33
58
|
meta;
|
|
34
59
|
state = { phase: "idle" };
|
|
35
60
|
disposed = false;
|
|
61
|
+
/** Whether the resolved binary serves `retriever: "lexical"`. */
|
|
62
|
+
lexicalRetriever = false;
|
|
63
|
+
/** Whether the resolved binary serves the cross-encoder `rerank` op. */
|
|
64
|
+
crossEncoder = false;
|
|
36
65
|
constructor(options) {
|
|
37
66
|
this.options = options;
|
|
38
67
|
}
|
|
@@ -47,6 +76,39 @@ export class EmbsearchService {
|
|
|
47
76
|
this.state = state;
|
|
48
77
|
this.options.onProgress?.(state);
|
|
49
78
|
}
|
|
79
|
+
/** Whether the running daemon can serve a BM25-only query. */
|
|
80
|
+
supportsLexicalRetriever() {
|
|
81
|
+
return this.lexicalRetriever;
|
|
82
|
+
}
|
|
83
|
+
/** Whether the running daemon can score with a cross-encoder. */
|
|
84
|
+
supportsCrossEncoder() {
|
|
85
|
+
return this.crossEncoder;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Cross-encoder rerank of caller-supplied passages.
|
|
89
|
+
*
|
|
90
|
+
* Unlike the retrievers this does not consult the index at all — it scores
|
|
91
|
+
* exactly the text passed in, which is why the caller sends its expanded
|
|
92
|
+
* windows rather than chunk ids.
|
|
93
|
+
*/
|
|
94
|
+
async rerank(query, passages, k) {
|
|
95
|
+
if (!this.client || this.client.isClosed) {
|
|
96
|
+
throw new Error("semantic index is not available");
|
|
97
|
+
}
|
|
98
|
+
if (!this.crossEncoder) {
|
|
99
|
+
throw new Error(`embsearch is too old to rerank (needs >= ${MIN_RERANK_VERSION.join(".")}); ` +
|
|
100
|
+
"an older daemon has no cross-encoder weights");
|
|
101
|
+
}
|
|
102
|
+
return await this.client.rerank(query, passages, k);
|
|
103
|
+
}
|
|
104
|
+
probeBinaryVersion(binary) {
|
|
105
|
+
try {
|
|
106
|
+
return parseBinaryVersion(execFileSync(binary, ["--version"], { encoding: "utf-8", timeout: 10_000 }));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
50
112
|
async resolveBinary() {
|
|
51
113
|
if (this.options.binaryPath) {
|
|
52
114
|
return this.options.binaryPath;
|
|
@@ -89,8 +151,15 @@ export class EmbsearchService {
|
|
|
89
151
|
});
|
|
90
152
|
return;
|
|
91
153
|
}
|
|
92
|
-
const
|
|
93
|
-
this.
|
|
154
|
+
const binaryVersion = this.probeBinaryVersion(binary);
|
|
155
|
+
this.lexicalRetriever = binaryVersion !== undefined && atLeast(binaryVersion, MIN_LEXICAL_RETRIEVER_VERSION);
|
|
156
|
+
this.crossEncoder = binaryVersion !== undefined && atLeast(binaryVersion, MIN_RERANK_VERSION);
|
|
157
|
+
const storeDir = this.options.storeDir ?? getEmbsearchStoreDir(this.options.cwd);
|
|
158
|
+
this.client = new EmbSearchClient({
|
|
159
|
+
binaryPath: binary,
|
|
160
|
+
storePath: getVectorStoreDir(storeDir),
|
|
161
|
+
hybrid: this.options.hybridStore,
|
|
162
|
+
});
|
|
94
163
|
await this.client.ready();
|
|
95
164
|
const info = await this.client.info();
|
|
96
165
|
if (info.modelId === MOCK_MODEL_ID) {
|
|
@@ -194,11 +263,15 @@ export class EmbsearchService {
|
|
|
194
263
|
return await this.searchChunks(query, k);
|
|
195
264
|
}
|
|
196
265
|
/** Top-`k` semantic hits including their chunk ids, for rank fusion. */
|
|
197
|
-
async searchChunks(query, k = 10, glob) {
|
|
266
|
+
async searchChunks(query, k = 10, glob, retriever = "dense") {
|
|
198
267
|
if (!this.client || this.client.isClosed || !this.meta) {
|
|
199
268
|
throw new Error("semantic index is not available");
|
|
200
269
|
}
|
|
201
|
-
|
|
270
|
+
if (retriever === "lexical" && !this.lexicalRetriever) {
|
|
271
|
+
throw new Error(`embsearch is too old for retriever "lexical" (needs >= ${MIN_LEXICAL_RETRIEVER_VERSION.join(".")}); ` +
|
|
272
|
+
"an older daemon ignores the field and answers with dense results");
|
|
273
|
+
}
|
|
274
|
+
const results = await this.client.query(query, k, retriever);
|
|
202
275
|
const hits = [];
|
|
203
276
|
const matchGlob = (rel) => {
|
|
204
277
|
if (!glob)
|