@kolisachint/hoocode-agent 0.4.165 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/dist/core/embsearch/client.d.ts +21 -0
  3. package/dist/core/embsearch/client.d.ts.map +1 -1
  4. package/dist/core/embsearch/client.js +13 -0
  5. package/dist/core/embsearch/client.js.map +1 -1
  6. package/dist/core/embsearch/embsearch-service.d.ts +14 -2
  7. package/dist/core/embsearch/embsearch-service.d.ts.map +1 -1
  8. package/dist/core/embsearch/embsearch-service.js +32 -7
  9. package/dist/core/embsearch/embsearch-service.js.map +1 -1
  10. package/dist/core/search/cross-rerank.d.ts +44 -0
  11. package/dist/core/search/cross-rerank.d.ts.map +1 -0
  12. package/dist/core/search/cross-rerank.js +77 -0
  13. package/dist/core/search/cross-rerank.js.map +1 -0
  14. package/dist/core/search/eval-live.d.ts +50 -0
  15. package/dist/core/search/eval-live.d.ts.map +1 -0
  16. package/dist/core/search/eval-live.js +48 -0
  17. package/dist/core/search/eval-live.js.map +1 -0
  18. package/dist/core/search/eval.d.ts +3 -0
  19. package/dist/core/search/eval.d.ts.map +1 -1
  20. package/dist/core/search/eval.js +10 -0
  21. package/dist/core/search/eval.js.map +1 -1
  22. package/dist/core/search/hybrid-search.d.ts +6 -0
  23. package/dist/core/search/hybrid-search.d.ts.map +1 -1
  24. package/dist/core/search/hybrid-search.js +7 -1
  25. package/dist/core/search/hybrid-search.js.map +1 -1
  26. package/dist/core/search/rerank.d.ts +21 -0
  27. package/dist/core/search/rerank.d.ts.map +1 -1
  28. package/dist/core/search/rerank.js +139 -1
  29. package/dist/core/search/rerank.js.map +1 -1
  30. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  31. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  32. package/examples/extensions/sandbox/package.json +1 -1
  33. package/examples/extensions/with-deps/package.json +1 -1
  34. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.166] - 2026-08-08
4
+
3
5
  ## [0.4.165] - 2026-08-08
4
6
 
5
7
  ## [0.4.164] - 2026-08-08
@@ -15,6 +15,17 @@ 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;
@@ -57,6 +68,16 @@ export declare class EmbSearchClient {
57
68
  * and preventing n-way fusion with the grep leg.
58
69
  */
59
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[]>;
60
81
  /**
61
82
  * Batched insert-or-replace. One embedding inference for the whole batch —
62
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,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;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,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;;;;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\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}\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 * 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"]}
@@ -102,6 +102,19 @@ export class EmbSearchClient {
102
102
  const res = await this.send(retriever === "dense" ? { op: "query", text, k } : { op: "query", text, k, retriever });
103
103
  return res.results ?? [];
104
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
+ }
105
118
  /**
106
119
  * Batched insert-or-replace. One embedding inference for the whole batch —
107
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\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}\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 * 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,7 +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 } from "./client.js";
16
+ import { type DaemonRetriever, type EmbSearchRerankPassage, type EmbSearchRerankResult } from "./client.js";
17
17
  export type EmbsearchState = {
18
18
  phase: "idle";
19
19
  } | {
@@ -72,6 +72,8 @@ export declare class EmbsearchService {
72
72
  private disposed;
73
73
  /** Whether the resolved binary serves `retriever: "lexical"`. */
74
74
  private lexicalRetriever;
75
+ /** Whether the resolved binary serves the cross-encoder `rerank` op. */
76
+ private crossEncoder;
75
77
  constructor(options: EmbsearchServiceOptions);
76
78
  getState(): EmbsearchState;
77
79
  /** Semantic search is usable (index ready, or still building with partial data). */
@@ -79,7 +81,17 @@ export declare class EmbsearchService {
79
81
  private setState;
80
82
  /** Whether the running daemon can serve a BM25-only query. */
81
83
  supportsLexicalRetriever(): boolean;
82
- private probeLexicalRetrieverSupport;
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;
83
95
  private resolveBinary;
84
96
  /**
85
97
  * Scan, threshold-check, and (when needed) index in the background.
@@ -1 +1 @@
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 { type DaemonRetriever, 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/**\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\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\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\tprivate probeLexicalRetrieverSupport(binary: string): boolean {\n\t\ttry {\n\t\t\tconst out = execFileSync(binary, [\"--version\"], { encoding: \"utf-8\", timeout: 10_000 });\n\t\t\tconst version = parseBinaryVersion(out);\n\t\t\treturn version !== undefined && atLeast(version, MIN_LEXICAL_RETRIEVER_VERSION);\n\t\t} catch {\n\t\t\treturn false;\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\tthis.lexicalRetriever = this.probeLexicalRetrieverSupport(binary);\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"]}
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"]}
@@ -18,7 +18,7 @@ import { readFileSync } from "fs";
18
18
  import { minimatch } from "minimatch";
19
19
  import { ensureTool } from "../../utils/tools-manager.js";
20
20
  import { chunkFile } from "./chunker.js";
21
- import { EmbSearchClient } from "./client.js";
21
+ import { EmbSearchClient, } from "./client.js";
22
22
  import { emptyIndexMeta, getEmbsearchStoreDir, getVectorStoreDir, hashContent, loadIndexMeta, saveIndexMeta, } from "./index-meta.js";
23
23
  import { scanRepo } from "./repo-scan.js";
24
24
  /** Chunks per bulk request. Small enough that a concurrent query is never
@@ -37,6 +37,8 @@ const MOCK_MODEL_ID = "mock-hash-v1";
37
37
  * corrupt the ranking with no error anywhere — so refuse instead of degrading.
38
38
  */
39
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];
40
42
  /** `embsearch 0.2.0` -> [0, 2, 0]; undefined when it cannot be parsed. */
41
43
  function parseBinaryVersion(output) {
42
44
  const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
@@ -58,6 +60,8 @@ export class EmbsearchService {
58
60
  disposed = false;
59
61
  /** Whether the resolved binary serves `retriever: "lexical"`. */
60
62
  lexicalRetriever = false;
63
+ /** Whether the resolved binary serves the cross-encoder `rerank` op. */
64
+ crossEncoder = false;
61
65
  constructor(options) {
62
66
  this.options = options;
63
67
  }
@@ -76,14 +80,33 @@ export class EmbsearchService {
76
80
  supportsLexicalRetriever() {
77
81
  return this.lexicalRetriever;
78
82
  }
79
- probeLexicalRetrieverSupport(binary) {
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) {
80
105
  try {
81
- const out = execFileSync(binary, ["--version"], { encoding: "utf-8", timeout: 10_000 });
82
- const version = parseBinaryVersion(out);
83
- return version !== undefined && atLeast(version, MIN_LEXICAL_RETRIEVER_VERSION);
106
+ return parseBinaryVersion(execFileSync(binary, ["--version"], { encoding: "utf-8", timeout: 10_000 }));
84
107
  }
85
108
  catch {
86
- return false;
109
+ return undefined;
87
110
  }
88
111
  }
89
112
  async resolveBinary() {
@@ -128,7 +151,9 @@ export class EmbsearchService {
128
151
  });
129
152
  return;
130
153
  }
131
- this.lexicalRetriever = this.probeLexicalRetrieverSupport(binary);
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);
132
157
  const storeDir = this.options.storeDir ?? getEmbsearchStoreDir(this.options.cwd);
133
158
  this.client = new EmbSearchClient({
134
159
  binaryPath: binary,
@@ -1 +1 @@
1
- {"version":3,"file":"embsearch-service.js","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 { type DaemonRetriever, 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/**\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\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\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\tprivate probeLexicalRetrieverSupport(binary: string): boolean {\n\t\ttry {\n\t\t\tconst out = execFileSync(binary, [\"--version\"], { encoding: \"utf-8\", timeout: 10_000 });\n\t\t\tconst version = parseBinaryVersion(out);\n\t\t\treturn version !== undefined && atLeast(version, MIN_LEXICAL_RETRIEVER_VERSION);\n\t\t} catch {\n\t\t\treturn false;\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\tthis.lexicalRetriever = this.probeLexicalRetrieverSupport(binary);\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"]}
1
+ {"version":3,"file":"embsearch-service.js","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"]}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cross-encoder reranking via the embsearch daemon.
3
+ *
4
+ * The deterministic reranker in `rerank.ts` scores candidates with lexical
5
+ * evidence — term coverage, path affinity, whether the window declares a query
6
+ * term. That took Recall@1 from 0.10 to 0.24 and then stopped paying: a
7
+ * term-proximity signal aimed at the classes it still handles worst moved
8
+ * nothing (p = 1.00 on every metric).
9
+ *
10
+ * What is left is a ranking problem the lexical view cannot see. Across the
11
+ * 62-query gold set the right span reaches the fused top-50 far more often
12
+ * than the top-10, so the candidates are in hand and merely ordered badly. A
13
+ * cross-encoder reads the query and the candidate *together*, which is exactly
14
+ * the evidence term counting lacks.
15
+ *
16
+ * It is also far more expensive — one model pass per candidate, with no
17
+ * precomputation possible — so it runs over a shortlist and its depth is
18
+ * capped separately from the fused window.
19
+ */
20
+ import type { EmbsearchService } from "../embsearch/embsearch-service.js";
21
+ import type { FusedCandidate } from "./types.js";
22
+ /**
23
+ * Candidates sent to the cross-encoder per query.
24
+ *
25
+ * Each one is a model pass, so this is a latency dial, not a quality dial:
26
+ * every candidate past here keeps its fused order rather than being scored.
27
+ */
28
+ export declare const CROSS_ENCODER_DEPTH = 30;
29
+ export interface CrossRerankResult {
30
+ candidates: FusedCandidate[];
31
+ latencyMs: number;
32
+ /** How many candidates the model actually scored. */
33
+ scored: number;
34
+ }
35
+ /**
36
+ * Reorder `candidates` by cross-encoder relevance.
37
+ *
38
+ * Only the first {@link CROSS_ENCODER_DEPTH} are scored; the remainder keep
39
+ * their incoming order and follow. Candidates whose window cannot be read are
40
+ * left unscored for the same reason — there is no text to give the model, and
41
+ * inventing one would score a fiction.
42
+ */
43
+ export declare function crossEncoderRerank(query: string, candidates: readonly FusedCandidate[], cwd: string, service: EmbsearchService): Promise<CrossRerankResult>;
44
+ //# sourceMappingURL=cross-rerank.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cross-rerank.d.ts","sourceRoot":"","sources":["../../../src/core/search/cross-rerank.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAE1E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,MAAM,WAAW,iBAAiB;IACjC,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,MAAM,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACvC,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,SAAS,cAAc,EAAE,EACrC,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,gBAAgB,GACvB,OAAO,CAAC,iBAAiB,CAAC,CA4C5B","sourcesContent":["/**\n * Cross-encoder reranking via the embsearch daemon.\n *\n * The deterministic reranker in `rerank.ts` scores candidates with lexical\n * evidence — term coverage, path affinity, whether the window declares a query\n * term. That took Recall@1 from 0.10 to 0.24 and then stopped paying: a\n * term-proximity signal aimed at the classes it still handles worst moved\n * nothing (p = 1.00 on every metric).\n *\n * What is left is a ranking problem the lexical view cannot see. Across the\n * 62-query gold set the right span reaches the fused top-50 far more often\n * than the top-10, so the candidates are in hand and merely ordered badly. A\n * cross-encoder reads the query and the candidate *together*, which is exactly\n * the evidence term counting lacks.\n *\n * It is also far more expensive — one model pass per candidate, with no\n * precomputation possible — so it runs over a shortlist and its depth is\n * capped separately from the fused window.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { readCandidateWindows } from \"./rerank.js\";\nimport type { FusedCandidate } from \"./types.js\";\n\n/**\n * Candidates sent to the cross-encoder per query.\n *\n * Each one is a model pass, so this is a latency dial, not a quality dial:\n * every candidate past here keeps its fused order rather than being scored.\n */\nexport const CROSS_ENCODER_DEPTH = 30;\n\nexport interface CrossRerankResult {\n\tcandidates: FusedCandidate[];\n\tlatencyMs: number;\n\t/** How many candidates the model actually scored. */\n\tscored: number;\n}\n\n/**\n * Reorder `candidates` by cross-encoder relevance.\n *\n * Only the first {@link CROSS_ENCODER_DEPTH} are scored; the remainder keep\n * their incoming order and follow. Candidates whose window cannot be read are\n * left unscored for the same reason — there is no text to give the model, and\n * inventing one would score a fiction.\n */\nexport async function crossEncoderRerank(\n\tquery: string,\n\tcandidates: readonly FusedCandidate[],\n\tcwd: string,\n\tservice: EmbsearchService,\n): Promise<CrossRerankResult> {\n\tconst startedMs = Date.now();\n\tif (candidates.length < 2) {\n\t\treturn { candidates: [...candidates], latencyMs: Date.now() - startedMs, scored: 0 };\n\t}\n\n\tconst head = candidates.slice(0, CROSS_ENCODER_DEPTH);\n\tconst tail = candidates.slice(CROSS_ENCODER_DEPTH);\n\tconst windows = readCandidateWindows(head, cwd);\n\n\tconst passages: Array<{ id: string; text: string }> = [];\n\tconst byId = new Map<string, FusedCandidate>();\n\thead.forEach((candidate, i) => {\n\t\tconst text = windows[i];\n\t\tif (text === undefined) return;\n\t\t// Fused ids are unique per query, so they round-trip as passage ids.\n\t\tpassages.push({ id: candidate.id, text });\n\t\tbyId.set(candidate.id, candidate);\n\t});\n\n\tif (passages.length === 0) {\n\t\treturn { candidates: [...candidates], latencyMs: Date.now() - startedMs, scored: 0 };\n\t}\n\n\tconst scored = await service.rerank(query, passages, passages.length);\n\n\t// Reassemble: scored candidates in model order, then anything the model did\n\t// not see, in fused order. An id the daemon did not return would otherwise\n\t// vanish from the results entirely.\n\tconst ordered: FusedCandidate[] = [];\n\tconst seen = new Set<string>();\n\tfor (const result of scored) {\n\t\tconst candidate = byId.get(result.id);\n\t\tif (candidate && !seen.has(result.id)) {\n\t\t\tordered.push(candidate);\n\t\t\tseen.add(result.id);\n\t\t}\n\t}\n\tfor (const candidate of head) {\n\t\tif (!seen.has(candidate.id)) ordered.push(candidate);\n\t}\n\tordered.push(...tail);\n\n\treturn { candidates: ordered, latencyMs: Date.now() - startedMs, scored: passages.length };\n}\n"]}