{"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;IACd;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CACjB;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;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,MAAM,CAAC,EAAE,QAAQ,GAAG,KAAK,GAAG,WAAW,CAAC;CACxC;AA4BD,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,EAgCvC;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,CASzC;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\t/**\n\t * Whether the open store carries a BM25 index alongside its vectors.\n\t *\n\t * Fixed when the store is created — passing `--hybrid` at a non-hybrid\n\t * store only warns — so this is the only reliable way to find out, and the\n\t * signal that an existing store has to be rebuilt rather than reused.\n\t */\n\thybrid?: boolean;\n\t/**\n\t * Whether this daemon can actually serve `rerank`, or `undefined` from a\n\t * daemon too old to say.\n\t *\n\t * Reported separately from the version because the two came apart:\n\t * released binaries carry the `rerank` op but no longer bundle the ~23 MB\n\t * cross-encoder weights, so a version check alone would advertise a\n\t * reranker that fails on first call.\n\t */\n\trerank?: boolean;\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/**\n\t * Model directory passed as `--model` (onnx builds only): a dir holding\n\t * `model.onnx`, `tokenizer.json` and `model.json`.\n\t *\n\t * Omitted, the daemon uses the model bundled into the binary. Set, the\n\t * binary no longer determines which model produced a vector — which is why\n\t * the eval harness records `info.model_id` rather than the binary version.\n\t */\n\tmodelDir?: 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\t/** `info` only: whether the open store has a BM25 index. */\n\thybrid?: boolean;\n\t/** `info` only: whether `rerank` will work. Absent on daemons too old to\n\t *  report it. */\n\trerank?: boolean;\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\tif (opts.modelDir) args.push(\"--model\", opts.modelDir);\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 {\n\t\t\tmodelId: res.model_id ?? \"\",\n\t\t\tdim: res.dim ?? 0,\n\t\t\tcount: res.count ?? 0,\n\t\t\thybrid: res.hybrid,\n\t\t\trerank: res.rerank,\n\t\t};\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"]}