@tryformation/querylight-cli 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,7 @@ It is designed for local, inspectable workflows:
16
16
  - chunk documents for retrieval
17
17
  - build a portable local Querylight index
18
18
  - search and generate retrieval context for external agents and tools
19
+ - serve an OpenSearch-like `_search` API over one or more local knowledge bases
19
20
  - inspect workspace state, diffs, and change reports
20
21
 
21
22
  ## Install
@@ -108,6 +109,9 @@ Search it:
108
109
  qli search "API authentication"
109
110
  qli search --source-type rss --since 2026-05-01 --has-publication-date
110
111
  qli search-json '{"query":{"match":{"text":"API authentication"}},"size":5}'
112
+ curl -X POST http://127.0.0.1:3000/_search \
113
+ -H 'content-type: application/json' \
114
+ -d '{"query":{"match":{"text":"API authentication"}},"size":5}'
111
115
  ```
112
116
 
113
117
  Find related documents for an existing one:
@@ -122,6 +126,16 @@ Generate retrieval context:
122
126
  qli context "How do I authenticate API requests?" --top-k 8
123
127
  ```
124
128
 
129
+ Serve the lexical index over HTTP:
130
+
131
+ ```bash
132
+ qli serve
133
+ ```
134
+
135
+ `qli serve` loads the current workspace index once at startup and reuses it for each request.
136
+ Use `POST /_search` or `POST /<configured-index-name>/_search` for a single workspace.
137
+ Use `POST /<directory-name>/_search` when `--workspace` points to a directory whose children each contain their own `.kb` workspace.
138
+
125
139
  ## Example Skill: `qli` with `bunx` and `uv`
126
140
 
127
141
  The repository includes an example skill for running `qli` without a global install and calling it from Python with `uv`:
@@ -374,6 +388,18 @@ qli context "How do I configure the API?"
374
388
  qli context "What changed in pricing?" --top-k 12 --max-chars 12000
375
389
  ```
376
390
 
391
+ Serve the lexical index over HTTP:
392
+
393
+ ```bash
394
+ qli serve
395
+ qli serve --workspace ./docs/.kb --port 4000
396
+ qli serve --workspace ./kbs --host 0.0.0.0 --port 4000
397
+ ```
398
+
399
+ For a single workspace, use `POST /_search` or `POST /<configured-index-name>/_search`.
400
+ For a directory of knowledge bases, use `POST /<directory-name>/_search`.
401
+ The request body must be a Querylight JSON DSL object.
402
+
377
403
  ### Change Inspection
378
404
 
379
405
  ```bash
package/dist/cli/main.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/cli/run-cli.ts
4
4
  import { Command, Option } from "commander";
5
- import { readFile as readFile11, stat as stat4 } from "fs/promises";
6
- import path21 from "path";
5
+ import { readFile as readFile11, stat as stat5 } from "fs/promises";
6
+ import path22 from "path";
7
7
 
8
8
  // src/chunk/chunker.ts
9
9
  import { readFile as readFile3 } from "fs/promises";
@@ -53,7 +53,7 @@ var defaultConfig = () => ({
53
53
  defaultMode: "lexical",
54
54
  dense: {
55
55
  enabled: true,
56
- modelId: "Xenova/all-MiniLM-L6-v2",
56
+ modelId: "Xenova/paraphrase-MiniLM-L3-v2",
57
57
  cacheDir: DEFAULT_SHARED_MODEL_CACHE_DIR,
58
58
  indexHashTables: 8,
59
59
  indexRandomSeed: 42,
@@ -61,7 +61,7 @@ var defaultConfig = () => ({
61
61
  },
62
62
  sparse: {
63
63
  enabled: true,
64
- modelId: "opensearch-project/opensearch-neural-sparse-encoding-doc-v3-distill",
64
+ modelId: "opensearch-project/opensearch-neural-sparse-encoding-doc-v2-mini",
65
65
  cacheDir: DEFAULT_SHARED_MODEL_CACHE_DIR,
66
66
  documentTopTokens: 128,
67
67
  queryEncoding: "tokenizer-token-weights",
@@ -682,15 +682,26 @@ function createSparseChunkText(chunk) {
682
682
  // src/vector/dense.ts
683
683
  var denseEmbedderFactory = null;
684
684
  var EXACT_DENSE_RERANK_THRESHOLD = 5e3;
685
+ function normalizeDenseEmbedder(embedder) {
686
+ if (typeof embedder === "function") {
687
+ return { embed: embedder };
688
+ }
689
+ return embedder;
690
+ }
685
691
  async function createEmbedder(cacheDir, modelId) {
686
692
  if (denseEmbedderFactory) {
687
- return denseEmbedderFactory(cacheDir, modelId);
693
+ return normalizeDenseEmbedder(await denseEmbedderFactory(cacheDir, modelId));
688
694
  }
689
695
  const runtime = await getDenseTransformersRuntime(cacheDir);
690
696
  const extractor = await runtime.pipeline("feature-extraction", modelId);
691
- return async (text) => {
692
- const output = await extractor(text, { pooling: "mean", normalize: true });
693
- return output.tolist()[0];
697
+ return {
698
+ async embed(text) {
699
+ const output = await extractor(text, { pooling: "mean", normalize: true });
700
+ return output.tolist()[0];
701
+ },
702
+ async dispose() {
703
+ await extractor.dispose();
704
+ }
694
705
  };
695
706
  }
696
707
  function exactDenseQuery(payload, vector, topK) {
@@ -699,8 +710,12 @@ function exactDenseQuery(payload, vector, topK) {
699
710
  async function pullDenseModel(workspacePath, config) {
700
711
  const cacheDir = resolveCacheDir(workspacePath, config.cacheDir);
701
712
  await mkdir4(cacheDir, { recursive: true });
702
- const embed = await createEmbedder(cacheDir, config.modelId);
703
- await embed("warm dense model cache");
713
+ const embedder = await createEmbedder(cacheDir, config.modelId);
714
+ try {
715
+ await embedder.embed("warm dense model cache");
716
+ } finally {
717
+ await embedder.dispose?.();
718
+ }
704
719
  }
705
720
  async function buildDenseVectors({
706
721
  workspacePath,
@@ -710,53 +725,57 @@ async function buildDenseVectors({
710
725
  const chunks = await readJsonl(path8.join(workspacePath, "chunks", "chunks.jsonl"));
711
726
  const cacheDir = resolveCacheDir(workspacePath, config.cacheDir);
712
727
  await mkdir4(cacheDir, { recursive: true });
713
- const embed = await createEmbedder(cacheDir, config.modelId);
714
- const records = [];
715
- let dimensions = 0;
716
- reportProgress(progress, `Encoding ${chunks.length} chunk${chunks.length === 1 ? "" : "s"} for dense retrieval`);
717
- for (const chunk of chunks) {
718
- const embedding = await embed(createDenseChunkText(chunk));
719
- dimensions ||= embedding.length;
720
- records.push({
721
- chunkId: chunk.id,
722
- documentId: chunk.documentId,
723
- sourceId: chunk.sourceId,
724
- title: chunk.title,
725
- uri: chunk.uri,
726
- headingPath: chunk.headingPath,
727
- text: chunk.text,
728
- embedding
728
+ const embedder = await createEmbedder(cacheDir, config.modelId);
729
+ try {
730
+ const records = [];
731
+ let dimensions = 0;
732
+ reportProgress(progress, `Encoding ${chunks.length} chunk${chunks.length === 1 ? "" : "s"} for dense retrieval`);
733
+ for (const chunk of chunks) {
734
+ const embedding = await embedder.embed(createDenseChunkText(chunk));
735
+ dimensions ||= embedding.length;
736
+ records.push({
737
+ chunkId: chunk.id,
738
+ documentId: chunk.documentId,
739
+ sourceId: chunk.sourceId,
740
+ title: chunk.title,
741
+ uri: chunk.uri,
742
+ headingPath: chunk.headingPath,
743
+ text: chunk.text,
744
+ embedding
745
+ });
746
+ if (records.length === 1 || records.length % 100 === 0 || records.length === chunks.length) {
747
+ reportProgressDetail(progress, `Encoded ${records.length}/${chunks.length} chunks for dense retrieval`);
748
+ }
749
+ }
750
+ reportProgress(progress, "Building dense vector index");
751
+ const index = new VectorFieldIndex({
752
+ numHashTables: config.indexHashTables,
753
+ dimensions,
754
+ random: createSeededRandom(config.indexRandomSeed)
729
755
  });
730
- if (records.length === 1 || records.length % 100 === 0 || records.length === chunks.length) {
731
- reportProgressDetail(progress, `Encoded ${records.length}/${chunks.length} chunks for dense retrieval`);
756
+ for (const record of records) {
757
+ index.insert(record.chunkId, [record.embedding]);
732
758
  }
759
+ const metadata = {
760
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
761
+ modelId: config.modelId,
762
+ dimensions,
763
+ hashTables: config.indexHashTables,
764
+ randomSeed: config.indexRandomSeed,
765
+ chunkCount: records.length,
766
+ indexHash: sha256(JSON.stringify(index.indexState))
767
+ };
768
+ const payload = {
769
+ metadata,
770
+ indexState: index.indexState,
771
+ chunks: records
772
+ };
773
+ await writeDensePayload(workspacePath, payload);
774
+ reportProgress(progress, `Dense vectors written for ${records.length} chunk${records.length === 1 ? "" : "s"}`);
775
+ return payload;
776
+ } finally {
777
+ await embedder.dispose?.();
733
778
  }
734
- reportProgress(progress, "Building dense vector index");
735
- const index = new VectorFieldIndex({
736
- numHashTables: config.indexHashTables,
737
- dimensions,
738
- random: createSeededRandom(config.indexRandomSeed)
739
- });
740
- for (const record of records) {
741
- index.insert(record.chunkId, [record.embedding]);
742
- }
743
- const metadata = {
744
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
745
- modelId: config.modelId,
746
- dimensions,
747
- hashTables: config.indexHashTables,
748
- randomSeed: config.indexRandomSeed,
749
- chunkCount: records.length,
750
- indexHash: sha256(JSON.stringify(index.indexState))
751
- };
752
- const payload = {
753
- metadata,
754
- indexState: index.indexState,
755
- chunks: records
756
- };
757
- await writeDensePayload(workspacePath, payload);
758
- reportProgress(progress, `Dense vectors written for ${records.length} chunk${records.length === 1 ? "" : "s"}`);
759
- return payload;
760
779
  }
761
780
  async function denseQuery({
762
781
  workspacePath,
@@ -766,21 +785,25 @@ async function denseQuery({
766
785
  }) {
767
786
  const payload = await readDensePayload(workspacePath);
768
787
  const cacheDir = resolveCacheDir(workspacePath, config.cacheDir);
769
- const embed = await createEmbedder(cacheDir, config.modelId);
770
- const vector = await embed(query);
771
- if (payload.chunks.length <= EXACT_DENSE_RERANK_THRESHOLD) {
788
+ const embedder = await createEmbedder(cacheDir, config.modelId);
789
+ try {
790
+ const vector = await embedder.embed(query);
791
+ if (payload.chunks.length <= EXACT_DENSE_RERANK_THRESHOLD) {
792
+ return exactDenseQuery(payload, vector, topK);
793
+ }
794
+ const index = new VectorFieldIndex({
795
+ numHashTables: payload.metadata.hashTables,
796
+ dimensions: payload.metadata.dimensions,
797
+ random: createSeededRandom(payload.metadata.randomSeed)
798
+ }).loadState(payload.indexState);
799
+ const approximateHits = index.query(vector, topK);
800
+ if (approximateHits.length >= topK) {
801
+ return approximateHits;
802
+ }
772
803
  return exactDenseQuery(payload, vector, topK);
804
+ } finally {
805
+ await embedder.dispose?.();
773
806
  }
774
- const index = new VectorFieldIndex({
775
- numHashTables: payload.metadata.hashTables,
776
- dimensions: payload.metadata.dimensions,
777
- random: createSeededRandom(payload.metadata.randomSeed)
778
- }).loadState(payload.indexState);
779
- const approximateHits = index.query(vector, topK);
780
- if (approximateHits.length >= topK) {
781
- return approximateHits;
782
- }
783
- return exactDenseQuery(payload, vector, topK);
784
807
  }
785
808
 
786
809
  // src/vector/sparse.ts
@@ -2173,13 +2196,17 @@ function isAllowed(url, baseUrl, includePatterns, excludePatterns, disallowRules
2173
2196
  if (url.search.length > 0) {
2174
2197
  return false;
2175
2198
  }
2176
- if (url.pathname.endsWith(".xml")) {
2199
+ const pathname = url.pathname.toLowerCase();
2200
+ if (pathname.endsWith(".xml")) {
2177
2201
  return false;
2178
2202
  }
2179
- if (url.pathname.includes("/cdn-cgi/")) {
2203
+ if (pathname.endsWith(".pdf")) {
2180
2204
  return false;
2181
2205
  }
2182
- if (url.pathname === "/search" || url.pathname === "/search/" || url.pathname.endsWith("/search/")) {
2206
+ if (pathname.includes("/cdn-cgi/")) {
2207
+ return false;
2208
+ }
2209
+ if (pathname === "/search" || pathname === "/search/" || pathname.endsWith("/search/")) {
2183
2210
  return false;
2184
2211
  }
2185
2212
  if (disallowRules.some((rule) => rule !== "/" && url.pathname.startsWith(rule))) {
@@ -3203,13 +3230,20 @@ function searchResultsFromResponse(response2, showChunks = false) {
3203
3230
  metadata: hit._source.metadata
3204
3231
  }));
3205
3232
  }
3233
+ async function searchJsonRequest({
3234
+ index,
3235
+ request,
3236
+ indexName = "querylight"
3237
+ }) {
3238
+ return searchJsonDsl({ index, request, indexName });
3239
+ }
3206
3240
  async function searchJsonIndex({
3207
3241
  workspacePath,
3208
3242
  request,
3209
3243
  indexName = "querylight"
3210
3244
  }) {
3211
3245
  const index = await loadHydratedIndex(workspacePath);
3212
- return searchJsonDsl({ index, request, indexName });
3246
+ return searchJsonRequest({ index, request, indexName });
3213
3247
  }
3214
3248
  function normalizeDisplayTitle(title) {
3215
3249
  return title.replace(/\s*\|\s*Querylight TS Demo\s*$/i, "").replace(/\s+/g, " ").trim();
@@ -3525,8 +3559,197 @@ async function searchIndex({
3525
3559
  return createSearchResponse(mode, finalHits, Date.now() - startedAt);
3526
3560
  }
3527
3561
 
3528
- // src/query/related-service.ts
3562
+ // src/server/search-api.ts
3563
+ import { createServer } from "http";
3564
+ import { readdir, stat as stat4 } from "fs/promises";
3529
3565
  import path19 from "path";
3566
+ async function pathIsDirectory(candidatePath) {
3567
+ try {
3568
+ return (await stat4(candidatePath)).isDirectory();
3569
+ } catch {
3570
+ return false;
3571
+ }
3572
+ }
3573
+ async function discoverKnowledgeBases(workspacePath) {
3574
+ try {
3575
+ const singleWorkspace = await assertWorkspaceExists(workspacePath);
3576
+ const config = await loadConfig(singleWorkspace);
3577
+ const index = await loadHydratedIndex(singleWorkspace);
3578
+ return {
3579
+ mode: "single",
3580
+ knowledgeBases: [{
3581
+ name: config.index.name,
3582
+ workspacePath: singleWorkspace,
3583
+ configuredIndexName: config.index.name,
3584
+ index
3585
+ }]
3586
+ };
3587
+ } catch (error) {
3588
+ if (!(error instanceof CliError) || error.code !== "WORKSPACE_ERROR") {
3589
+ throw error;
3590
+ }
3591
+ }
3592
+ const resolvedRoot = path19.resolve(workspacePath);
3593
+ if (!await pathIsDirectory(resolvedRoot)) {
3594
+ throw new CliError(`workspace path does not exist: ${resolvedRoot}`, "WORKSPACE_ERROR", 3 /* WorkspaceError */);
3595
+ }
3596
+ const entries = await readdir(resolvedRoot, { withFileTypes: true });
3597
+ const knowledgeBases = (await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
3598
+ const candidateWorkspace = path19.join(resolvedRoot, entry.name, ".kb");
3599
+ try {
3600
+ const workspace = await assertWorkspaceExists(candidateWorkspace);
3601
+ const config = await loadConfig(workspace);
3602
+ const index = await loadHydratedIndex(workspace);
3603
+ return {
3604
+ name: entry.name,
3605
+ workspacePath: workspace,
3606
+ configuredIndexName: config.index.name,
3607
+ index
3608
+ };
3609
+ } catch (error) {
3610
+ if (error instanceof CliError && error.code === "WORKSPACE_ERROR") {
3611
+ return null;
3612
+ }
3613
+ throw error;
3614
+ }
3615
+ }))).filter((knowledgeBase) => knowledgeBase != null);
3616
+ if (knowledgeBases.length === 0) {
3617
+ throw new CliError(
3618
+ `no knowledge bases found at ${resolvedRoot}; use a .kb workspace or a directory of named subdirectories that each contain .kb`,
3619
+ "WORKSPACE_ERROR",
3620
+ 3 /* WorkspaceError */
3621
+ );
3622
+ }
3623
+ return { mode: "multi", knowledgeBases };
3624
+ }
3625
+ function sendJson(response2, statusCode, payload) {
3626
+ response2.statusCode = statusCode;
3627
+ response2.setHeader("content-type", "application/json; charset=utf-8");
3628
+ response2.end(JSON.stringify(payload));
3629
+ }
3630
+ function sendError(response2, statusCode, type, reason) {
3631
+ sendJson(response2, statusCode, {
3632
+ error: {
3633
+ type,
3634
+ reason
3635
+ },
3636
+ status: statusCode
3637
+ });
3638
+ }
3639
+ async function readRequestBody(request) {
3640
+ const chunks = [];
3641
+ for await (const chunk of request) {
3642
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3643
+ }
3644
+ return Buffer.concat(chunks).toString("utf8");
3645
+ }
3646
+ function parseSearchRequest(raw) {
3647
+ const normalized = raw.trim();
3648
+ if (normalized.length === 0) {
3649
+ return {};
3650
+ }
3651
+ try {
3652
+ const parsed = JSON.parse(normalized);
3653
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3654
+ throw new Error("expected a JSON object");
3655
+ }
3656
+ return parsed;
3657
+ } catch (error) {
3658
+ const message = error instanceof Error ? error.message : String(error);
3659
+ throw new CliError(`invalid JSON request: ${message}`, "INVALID_ARGUMENT", 2 /* InvalidArguments */);
3660
+ }
3661
+ }
3662
+ function routeForKnowledgeBase(mode, knowledgeBase) {
3663
+ return mode === "single" ? "/_search" : `/${knowledgeBase.name}/_search`;
3664
+ }
3665
+ function resolveKnowledgeBaseForPath(pathname, mode, knowledgeBases) {
3666
+ const segments = pathname.split("/").filter(Boolean);
3667
+ if (mode === "single") {
3668
+ const knowledgeBase = [...knowledgeBases.values()][0];
3669
+ if (!knowledgeBase) {
3670
+ return null;
3671
+ }
3672
+ if (segments.length === 1 && segments[0] === "_search") {
3673
+ return knowledgeBase;
3674
+ }
3675
+ if (segments.length === 2 && segments[1] === "_search" && segments[0] === knowledgeBase.configuredIndexName) {
3676
+ return knowledgeBase;
3677
+ }
3678
+ return null;
3679
+ }
3680
+ if (segments.length === 2 && segments[1] === "_search") {
3681
+ return knowledgeBases.get(segments[0]) ?? null;
3682
+ }
3683
+ return null;
3684
+ }
3685
+ async function handleSearchRequest(request, response2, pathname, mode, knowledgeBases) {
3686
+ if (request.method !== "GET" && request.method !== "POST") {
3687
+ response2.setHeader("allow", "GET, POST");
3688
+ sendError(response2, 405, "method_not_allowed", `unsupported method for ${pathname}`);
3689
+ return;
3690
+ }
3691
+ const knowledgeBase = resolveKnowledgeBaseForPath(pathname, mode, knowledgeBases);
3692
+ if (!knowledgeBase) {
3693
+ sendError(response2, 404, "resource_not_found_exception", `unknown search route: ${pathname}`);
3694
+ return;
3695
+ }
3696
+ try {
3697
+ const requestBody = parseSearchRequest(await readRequestBody(request));
3698
+ const indexName = mode === "multi" ? knowledgeBase.name : knowledgeBase.configuredIndexName;
3699
+ const result = await searchJsonRequest({
3700
+ index: knowledgeBase.index,
3701
+ request: requestBody,
3702
+ indexName
3703
+ });
3704
+ sendJson(response2, 200, result);
3705
+ } catch (error) {
3706
+ if (error instanceof CliError && error.code === "INVALID_ARGUMENT") {
3707
+ sendError(response2, 400, "parse_exception", error.message);
3708
+ return;
3709
+ }
3710
+ const message = error instanceof Error ? error.message : String(error);
3711
+ sendError(response2, 500, "search_phase_execution_exception", message);
3712
+ }
3713
+ }
3714
+ async function startSearchApiServer({
3715
+ workspacePath,
3716
+ host = "127.0.0.1",
3717
+ port = 3e3
3718
+ }) {
3719
+ const { mode, knowledgeBases } = await discoverKnowledgeBases(workspacePath);
3720
+ const byName = new Map(knowledgeBases.map((knowledgeBase) => [knowledgeBase.name, knowledgeBase]));
3721
+ const server = createServer(async (request, response2) => {
3722
+ const url2 = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
3723
+ await handleSearchRequest(request, response2, url2.pathname, mode, byName);
3724
+ });
3725
+ await new Promise((resolve2, reject) => {
3726
+ server.once("error", reject);
3727
+ server.listen(port, host, () => {
3728
+ server.off("error", reject);
3729
+ resolve2();
3730
+ });
3731
+ });
3732
+ const address = server.address();
3733
+ if (!address || typeof address === "string") {
3734
+ throw new CliError("server failed to bind to a TCP address", "SERVER_ERROR", 1 /* GeneralError */);
3735
+ }
3736
+ const url = `http://${host}:${address.port}`;
3737
+ return {
3738
+ mode,
3739
+ url,
3740
+ knowledgeBases: knowledgeBases.map((knowledgeBase) => ({
3741
+ name: knowledgeBase.name,
3742
+ workspacePath: knowledgeBase.workspacePath,
3743
+ route: routeForKnowledgeBase(mode, knowledgeBase)
3744
+ })),
3745
+ close: async () => new Promise((resolve2, reject) => {
3746
+ server.close((error) => error ? reject(error) : resolve2());
3747
+ })
3748
+ };
3749
+ }
3750
+
3751
+ // src/query/related-service.ts
3752
+ import path20 from "path";
3530
3753
  function cosineSimilarity2(left, right) {
3531
3754
  let dot = 0;
3532
3755
  let leftNorm = 0;
@@ -3602,7 +3825,7 @@ async function findRelatedDocuments({
3602
3825
  if (!await fileExists(denseVectorPath(workspacePath))) {
3603
3826
  throw new CliError("dense vector index is not built; run `qli models pull --dense` and `qli rebuild`", "DENSE_INDEX_MISSING", 7 /* QueryError */);
3604
3827
  }
3605
- const documents = await readJsonl(path19.join(workspacePath, "documents", "documents.jsonl"));
3828
+ const documents = await readJsonl(path20.join(workspacePath, "documents", "documents.jsonl"));
3606
3829
  const selected = resolveDocumentSelector(documents, document);
3607
3830
  const densePayload = await readDensePayload(workspacePath);
3608
3831
  const vectors = buildDocumentVectors(documents, densePayload.chunks, densePayload.metadata.dimensions);
@@ -3675,7 +3898,7 @@ async function createContext({
3675
3898
  }
3676
3899
 
3677
3900
  // src/report/diff-service.ts
3678
- import path20 from "path";
3901
+ import path21 from "path";
3679
3902
  function chooseBaselineRun(runs, since) {
3680
3903
  if (since === "last-run") {
3681
3904
  return runs.at(-1);
@@ -3691,7 +3914,7 @@ async function diffWorkspace({
3691
3914
  documentId,
3692
3915
  since
3693
3916
  }) {
3694
- const current = await readJsonl(path20.join(workspacePath, "documents", "documents.jsonl"));
3917
+ const current = await readJsonl(path21.join(workspacePath, "documents", "documents.jsonl"));
3695
3918
  const baseline = chooseBaselineRun(await listRuns(workspacePath), since);
3696
3919
  const previous = new Map((baseline?.documentsSnapshot ?? []).map((document) => [document.id, document]));
3697
3920
  const changedDocuments = current.filter((document) => (!sourceId || document.sourceId === sourceId) && (!documentId || document.id === documentId)).filter((document) => {
@@ -4050,7 +4273,7 @@ function parseDateValue(input, optionName) {
4050
4273
  return parsed.toISOString();
4051
4274
  }
4052
4275
  async function parseJsonArgument(input) {
4053
- const raw = input.startsWith("@") ? await readFile11(path21.resolve(input.slice(1)), "utf8") : input;
4276
+ const raw = input.startsWith("@") ? await readFile11(path22.resolve(input.slice(1)), "utf8") : input;
4054
4277
  try {
4055
4278
  const parsed = JSON.parse(raw);
4056
4279
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
@@ -4094,14 +4317,14 @@ function searchDateRanges(options) {
4094
4317
  return entries;
4095
4318
  }
4096
4319
  async function resolveWorkspace(options) {
4097
- return path21.resolve(options.workspace ?? DEFAULT_WORKSPACE);
4320
+ return path22.resolve(options.workspace ?? DEFAULT_WORKSPACE);
4098
4321
  }
4099
4322
  function workspaceFromArgv(argv) {
4100
4323
  const index = argv.findIndex((arg) => arg === "--workspace");
4101
4324
  if (index >= 0 && argv[index + 1]) {
4102
- return path21.resolve(argv[index + 1]);
4325
+ return path22.resolve(argv[index + 1]);
4103
4326
  }
4104
- return path21.resolve(DEFAULT_WORKSPACE);
4327
+ return path22.resolve(DEFAULT_WORKSPACE);
4105
4328
  }
4106
4329
  async function runCli(argv, io = {}) {
4107
4330
  const capture = { stdout: [], stderr: [], ...io };
@@ -4195,7 +4418,7 @@ Notes:
4195
4418
  }
4196
4419
  const stored = await addSource(workspace, {
4197
4420
  type,
4198
- uri: ["file", "directory"].includes(type) ? path21.resolve(uri) : uri,
4421
+ uri: ["file", "directory"].includes(type) ? path22.resolve(uri) : uri,
4199
4422
  name: options.name,
4200
4423
  enabled: true,
4201
4424
  tags: options.tag ?? [],
@@ -4459,6 +4682,63 @@ Notes:
4459
4682
  });
4460
4683
  emit(global.json, capture, response("search-json", workspace, result), JSON.stringify(result, null, 2));
4461
4684
  });
4685
+ program.command("serve").description("Start a small HTTP API that exposes Querylight JSON DSL search through an OpenSearch-like _search endpoint.").option("--host <host>", "Host interface to bind. Defaults to 127.0.0.1.", "127.0.0.1").option("--port <n>", "Port to bind. Use 0 to let the OS choose a free port.", "3000").addHelpText("after", `
4686
+ Examples:
4687
+ qli serve
4688
+ qli serve --workspace ./docs/.kb --port 4000
4689
+ qli serve --workspace ./kbs --host 0.0.0.0 --port 4000
4690
+
4691
+ Routes:
4692
+ Single workspace: POST /_search
4693
+ Single workspace: POST /<configured-index-name>/_search
4694
+ Multi-KB root: POST /<directory-name>/_search
4695
+
4696
+ Notes:
4697
+ The request body must be a Querylight JSON DSL object.
4698
+ serve only exposes lexical _search for now.
4699
+ When --workspace points to a directory of knowledge bases, each child directory must contain its own .kb workspace.
4700
+ Index files are loaded once at startup and reused across requests.`).action(async function command(options) {
4701
+ const global = this.optsWithGlobals();
4702
+ const workspace = await resolveWorkspace({ workspace: global.workspace });
4703
+ const port = Number(options.port);
4704
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
4705
+ throw new CliError(`invalid port: ${options.port}`, "INVALID_ARGUMENT", 2 /* InvalidArguments */);
4706
+ }
4707
+ const server = await startSearchApiServer({
4708
+ workspacePath: workspace,
4709
+ host: options.host,
4710
+ port
4711
+ });
4712
+ const data = {
4713
+ url: server.url,
4714
+ mode: server.mode,
4715
+ knowledgeBases: server.knowledgeBases
4716
+ };
4717
+ const human = [
4718
+ `Listening on ${server.url}`,
4719
+ ...server.knowledgeBases.map((knowledgeBase) => `${knowledgeBase.route} -> ${knowledgeBase.workspacePath}`)
4720
+ ].join("\n");
4721
+ emit(global.json, capture, response("serve", workspace, data), human);
4722
+ const shutdown = async () => {
4723
+ for (const signal of ["SIGINT", "SIGTERM"]) {
4724
+ process.off(signal, stop);
4725
+ }
4726
+ await server.close();
4727
+ };
4728
+ const stop = () => {
4729
+ void shutdown().then(() => resolveStop(), rejectStop);
4730
+ };
4731
+ let resolveStop;
4732
+ let rejectStop;
4733
+ const waitForStop = new Promise((resolve2, reject) => {
4734
+ resolveStop = resolve2;
4735
+ rejectStop = reject;
4736
+ });
4737
+ for (const signal of ["SIGINT", "SIGTERM"]) {
4738
+ process.once(signal, stop);
4739
+ }
4740
+ await waitForStop;
4741
+ });
4462
4742
  program.command("related").description("Find documents similar to an existing document by id or URI.").argument("<document>", "Document id, uri, or canonical uri").option("--top-k <n>", "Maximum number of related documents to return.", "12").addHelpText("after", `
4463
4743
  Examples:
4464
4744
  qli related doc_123
@@ -4582,7 +4862,7 @@ Examples:
4582
4862
  try {
4583
4863
  const meta = await readLatestIndexMetadata(workspace);
4584
4864
  latestIndex = meta.createdAt;
4585
- indexSize = (await stat4(await resolveLatestIndexArtifactPath(workspace))).size;
4865
+ indexSize = (await stat5(await resolveLatestIndexArtifactPath(workspace))).size;
4586
4866
  } catch {
4587
4867
  latestIndex = void 0;
4588
4868
  }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from "./ingest/ingest-service.js";
6
6
  export * from "./chunk/chunker.js";
7
7
  export * from "./index/querylight-indexer.js";
8
8
  export * from "./query/search-service.js";
9
+ export * from "./server/search-api.js";
9
10
  export * from "./query/related-service.js";
10
11
  export * from "./query/context-builder.js";
11
12
  export * from "./report/diff-service.js";
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ var defaultConfig = () => ({
57
57
  defaultMode: "lexical",
58
58
  dense: {
59
59
  enabled: true,
60
- modelId: "Xenova/all-MiniLM-L6-v2",
60
+ modelId: "Xenova/paraphrase-MiniLM-L3-v2",
61
61
  cacheDir: DEFAULT_SHARED_MODEL_CACHE_DIR,
62
62
  indexHashTables: 8,
63
63
  indexRandomSeed: 42,
@@ -65,7 +65,7 @@ var defaultConfig = () => ({
65
65
  },
66
66
  sparse: {
67
67
  enabled: true,
68
- modelId: "opensearch-project/opensearch-neural-sparse-encoding-doc-v3-distill",
68
+ modelId: "opensearch-project/opensearch-neural-sparse-encoding-doc-v2-mini",
69
69
  cacheDir: DEFAULT_SHARED_MODEL_CACHE_DIR,
70
70
  documentTopTokens: 128,
71
71
  queryEncoding: "tokenizer-token-weights",
@@ -1213,13 +1213,17 @@ function isAllowed(url, baseUrl, includePatterns, excludePatterns, disallowRules
1213
1213
  if (url.search.length > 0) {
1214
1214
  return false;
1215
1215
  }
1216
- if (url.pathname.endsWith(".xml")) {
1216
+ const pathname = url.pathname.toLowerCase();
1217
+ if (pathname.endsWith(".xml")) {
1217
1218
  return false;
1218
1219
  }
1219
- if (url.pathname.includes("/cdn-cgi/")) {
1220
+ if (pathname.endsWith(".pdf")) {
1220
1221
  return false;
1221
1222
  }
1222
- if (url.pathname === "/search" || url.pathname === "/search/" || url.pathname.endsWith("/search/")) {
1223
+ if (pathname.includes("/cdn-cgi/")) {
1224
+ return false;
1225
+ }
1226
+ if (pathname === "/search" || pathname === "/search/" || pathname.endsWith("/search/")) {
1223
1227
  return false;
1224
1228
  }
1225
1229
  if (disallowRules.some((rule) => rule !== "/" && url.pathname.startsWith(rule))) {
@@ -2058,15 +2062,26 @@ function createSparseChunkText(chunk) {
2058
2062
  // src/vector/dense.ts
2059
2063
  var denseEmbedderFactory = null;
2060
2064
  var EXACT_DENSE_RERANK_THRESHOLD = 5e3;
2065
+ function normalizeDenseEmbedder(embedder) {
2066
+ if (typeof embedder === "function") {
2067
+ return { embed: embedder };
2068
+ }
2069
+ return embedder;
2070
+ }
2061
2071
  async function createEmbedder(cacheDir, modelId) {
2062
2072
  if (denseEmbedderFactory) {
2063
- return denseEmbedderFactory(cacheDir, modelId);
2073
+ return normalizeDenseEmbedder(await denseEmbedderFactory(cacheDir, modelId));
2064
2074
  }
2065
2075
  const runtime = await getDenseTransformersRuntime(cacheDir);
2066
2076
  const extractor = await runtime.pipeline("feature-extraction", modelId);
2067
- return async (text) => {
2068
- const output = await extractor(text, { pooling: "mean", normalize: true });
2069
- return output.tolist()[0];
2077
+ return {
2078
+ async embed(text) {
2079
+ const output = await extractor(text, { pooling: "mean", normalize: true });
2080
+ return output.tolist()[0];
2081
+ },
2082
+ async dispose() {
2083
+ await extractor.dispose();
2084
+ }
2070
2085
  };
2071
2086
  }
2072
2087
  function exactDenseQuery(payload, vector, topK) {
@@ -2080,53 +2095,57 @@ async function buildDenseVectors({
2080
2095
  const chunks = await readJsonl(path14.join(workspacePath, "chunks", "chunks.jsonl"));
2081
2096
  const cacheDir = resolveCacheDir(workspacePath, config.cacheDir);
2082
2097
  await mkdir7(cacheDir, { recursive: true });
2083
- const embed = await createEmbedder(cacheDir, config.modelId);
2084
- const records = [];
2085
- let dimensions = 0;
2086
- reportProgress(progress, `Encoding ${chunks.length} chunk${chunks.length === 1 ? "" : "s"} for dense retrieval`);
2087
- for (const chunk of chunks) {
2088
- const embedding = await embed(createDenseChunkText(chunk));
2089
- dimensions ||= embedding.length;
2090
- records.push({
2091
- chunkId: chunk.id,
2092
- documentId: chunk.documentId,
2093
- sourceId: chunk.sourceId,
2094
- title: chunk.title,
2095
- uri: chunk.uri,
2096
- headingPath: chunk.headingPath,
2097
- text: chunk.text,
2098
- embedding
2099
- });
2100
- if (records.length === 1 || records.length % 100 === 0 || records.length === chunks.length) {
2101
- reportProgressDetail(progress, `Encoded ${records.length}/${chunks.length} chunks for dense retrieval`);
2098
+ const embedder = await createEmbedder(cacheDir, config.modelId);
2099
+ try {
2100
+ const records = [];
2101
+ let dimensions = 0;
2102
+ reportProgress(progress, `Encoding ${chunks.length} chunk${chunks.length === 1 ? "" : "s"} for dense retrieval`);
2103
+ for (const chunk of chunks) {
2104
+ const embedding = await embedder.embed(createDenseChunkText(chunk));
2105
+ dimensions ||= embedding.length;
2106
+ records.push({
2107
+ chunkId: chunk.id,
2108
+ documentId: chunk.documentId,
2109
+ sourceId: chunk.sourceId,
2110
+ title: chunk.title,
2111
+ uri: chunk.uri,
2112
+ headingPath: chunk.headingPath,
2113
+ text: chunk.text,
2114
+ embedding
2115
+ });
2116
+ if (records.length === 1 || records.length % 100 === 0 || records.length === chunks.length) {
2117
+ reportProgressDetail(progress, `Encoded ${records.length}/${chunks.length} chunks for dense retrieval`);
2118
+ }
2102
2119
  }
2120
+ reportProgress(progress, "Building dense vector index");
2121
+ const index = new VectorFieldIndex({
2122
+ numHashTables: config.indexHashTables,
2123
+ dimensions,
2124
+ random: createSeededRandom(config.indexRandomSeed)
2125
+ });
2126
+ for (const record of records) {
2127
+ index.insert(record.chunkId, [record.embedding]);
2128
+ }
2129
+ const metadata = {
2130
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2131
+ modelId: config.modelId,
2132
+ dimensions,
2133
+ hashTables: config.indexHashTables,
2134
+ randomSeed: config.indexRandomSeed,
2135
+ chunkCount: records.length,
2136
+ indexHash: sha256(JSON.stringify(index.indexState))
2137
+ };
2138
+ const payload = {
2139
+ metadata,
2140
+ indexState: index.indexState,
2141
+ chunks: records
2142
+ };
2143
+ await writeDensePayload(workspacePath, payload);
2144
+ reportProgress(progress, `Dense vectors written for ${records.length} chunk${records.length === 1 ? "" : "s"}`);
2145
+ return payload;
2146
+ } finally {
2147
+ await embedder.dispose?.();
2103
2148
  }
2104
- reportProgress(progress, "Building dense vector index");
2105
- const index = new VectorFieldIndex({
2106
- numHashTables: config.indexHashTables,
2107
- dimensions,
2108
- random: createSeededRandom(config.indexRandomSeed)
2109
- });
2110
- for (const record of records) {
2111
- index.insert(record.chunkId, [record.embedding]);
2112
- }
2113
- const metadata = {
2114
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2115
- modelId: config.modelId,
2116
- dimensions,
2117
- hashTables: config.indexHashTables,
2118
- randomSeed: config.indexRandomSeed,
2119
- chunkCount: records.length,
2120
- indexHash: sha256(JSON.stringify(index.indexState))
2121
- };
2122
- const payload = {
2123
- metadata,
2124
- indexState: index.indexState,
2125
- chunks: records
2126
- };
2127
- await writeDensePayload(workspacePath, payload);
2128
- reportProgress(progress, `Dense vectors written for ${records.length} chunk${records.length === 1 ? "" : "s"}`);
2129
- return payload;
2130
2149
  }
2131
2150
  async function denseQuery({
2132
2151
  workspacePath,
@@ -2136,21 +2155,25 @@ async function denseQuery({
2136
2155
  }) {
2137
2156
  const payload = await readDensePayload(workspacePath);
2138
2157
  const cacheDir = resolveCacheDir(workspacePath, config.cacheDir);
2139
- const embed = await createEmbedder(cacheDir, config.modelId);
2140
- const vector = await embed(query);
2141
- if (payload.chunks.length <= EXACT_DENSE_RERANK_THRESHOLD) {
2158
+ const embedder = await createEmbedder(cacheDir, config.modelId);
2159
+ try {
2160
+ const vector = await embedder.embed(query);
2161
+ if (payload.chunks.length <= EXACT_DENSE_RERANK_THRESHOLD) {
2162
+ return exactDenseQuery(payload, vector, topK);
2163
+ }
2164
+ const index = new VectorFieldIndex({
2165
+ numHashTables: payload.metadata.hashTables,
2166
+ dimensions: payload.metadata.dimensions,
2167
+ random: createSeededRandom(payload.metadata.randomSeed)
2168
+ }).loadState(payload.indexState);
2169
+ const approximateHits = index.query(vector, topK);
2170
+ if (approximateHits.length >= topK) {
2171
+ return approximateHits;
2172
+ }
2142
2173
  return exactDenseQuery(payload, vector, topK);
2174
+ } finally {
2175
+ await embedder.dispose?.();
2143
2176
  }
2144
- const index = new VectorFieldIndex({
2145
- numHashTables: payload.metadata.hashTables,
2146
- dimensions: payload.metadata.dimensions,
2147
- random: createSeededRandom(payload.metadata.randomSeed)
2148
- }).loadState(payload.indexState);
2149
- const approximateHits = index.query(vector, topK);
2150
- if (approximateHits.length >= topK) {
2151
- return approximateHits;
2152
- }
2153
- return exactDenseQuery(payload, vector, topK);
2154
2177
  }
2155
2178
 
2156
2179
  // src/vector/sparse.ts
@@ -2894,13 +2917,20 @@ function searchResultsFromResponse(response, showChunks = false) {
2894
2917
  metadata: hit._source.metadata
2895
2918
  }));
2896
2919
  }
2920
+ async function searchJsonRequest({
2921
+ index,
2922
+ request,
2923
+ indexName = "querylight"
2924
+ }) {
2925
+ return searchJsonDsl({ index, request, indexName });
2926
+ }
2897
2927
  async function searchJsonIndex({
2898
2928
  workspacePath,
2899
2929
  request,
2900
2930
  indexName = "querylight"
2901
2931
  }) {
2902
2932
  const index = await loadHydratedIndex(workspacePath);
2903
- return searchJsonDsl({ index, request, indexName });
2933
+ return searchJsonRequest({ index, request, indexName });
2904
2934
  }
2905
2935
  function normalizeDisplayTitle(title) {
2906
2936
  return title.replace(/\s*\|\s*Querylight TS Demo\s*$/i, "").replace(/\s+/g, " ").trim();
@@ -3216,8 +3246,197 @@ async function searchIndex({
3216
3246
  return createSearchResponse(mode, finalHits, Date.now() - startedAt);
3217
3247
  }
3218
3248
 
3219
- // src/query/related-service.ts
3249
+ // src/server/search-api.ts
3250
+ import { createServer } from "http";
3251
+ import { readdir, stat as stat4 } from "fs/promises";
3220
3252
  import path19 from "path";
3253
+ async function pathIsDirectory(candidatePath) {
3254
+ try {
3255
+ return (await stat4(candidatePath)).isDirectory();
3256
+ } catch {
3257
+ return false;
3258
+ }
3259
+ }
3260
+ async function discoverKnowledgeBases(workspacePath) {
3261
+ try {
3262
+ const singleWorkspace = await assertWorkspaceExists(workspacePath);
3263
+ const config = await loadConfig(singleWorkspace);
3264
+ const index = await loadHydratedIndex(singleWorkspace);
3265
+ return {
3266
+ mode: "single",
3267
+ knowledgeBases: [{
3268
+ name: config.index.name,
3269
+ workspacePath: singleWorkspace,
3270
+ configuredIndexName: config.index.name,
3271
+ index
3272
+ }]
3273
+ };
3274
+ } catch (error) {
3275
+ if (!(error instanceof CliError) || error.code !== "WORKSPACE_ERROR") {
3276
+ throw error;
3277
+ }
3278
+ }
3279
+ const resolvedRoot = path19.resolve(workspacePath);
3280
+ if (!await pathIsDirectory(resolvedRoot)) {
3281
+ throw new CliError(`workspace path does not exist: ${resolvedRoot}`, "WORKSPACE_ERROR", 3 /* WorkspaceError */);
3282
+ }
3283
+ const entries = await readdir(resolvedRoot, { withFileTypes: true });
3284
+ const knowledgeBases = (await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
3285
+ const candidateWorkspace = path19.join(resolvedRoot, entry.name, ".kb");
3286
+ try {
3287
+ const workspace = await assertWorkspaceExists(candidateWorkspace);
3288
+ const config = await loadConfig(workspace);
3289
+ const index = await loadHydratedIndex(workspace);
3290
+ return {
3291
+ name: entry.name,
3292
+ workspacePath: workspace,
3293
+ configuredIndexName: config.index.name,
3294
+ index
3295
+ };
3296
+ } catch (error) {
3297
+ if (error instanceof CliError && error.code === "WORKSPACE_ERROR") {
3298
+ return null;
3299
+ }
3300
+ throw error;
3301
+ }
3302
+ }))).filter((knowledgeBase) => knowledgeBase != null);
3303
+ if (knowledgeBases.length === 0) {
3304
+ throw new CliError(
3305
+ `no knowledge bases found at ${resolvedRoot}; use a .kb workspace or a directory of named subdirectories that each contain .kb`,
3306
+ "WORKSPACE_ERROR",
3307
+ 3 /* WorkspaceError */
3308
+ );
3309
+ }
3310
+ return { mode: "multi", knowledgeBases };
3311
+ }
3312
+ function sendJson(response, statusCode, payload) {
3313
+ response.statusCode = statusCode;
3314
+ response.setHeader("content-type", "application/json; charset=utf-8");
3315
+ response.end(JSON.stringify(payload));
3316
+ }
3317
+ function sendError(response, statusCode, type, reason) {
3318
+ sendJson(response, statusCode, {
3319
+ error: {
3320
+ type,
3321
+ reason
3322
+ },
3323
+ status: statusCode
3324
+ });
3325
+ }
3326
+ async function readRequestBody(request) {
3327
+ const chunks = [];
3328
+ for await (const chunk of request) {
3329
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3330
+ }
3331
+ return Buffer.concat(chunks).toString("utf8");
3332
+ }
3333
+ function parseSearchRequest(raw) {
3334
+ const normalized = raw.trim();
3335
+ if (normalized.length === 0) {
3336
+ return {};
3337
+ }
3338
+ try {
3339
+ const parsed = JSON.parse(normalized);
3340
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3341
+ throw new Error("expected a JSON object");
3342
+ }
3343
+ return parsed;
3344
+ } catch (error) {
3345
+ const message = error instanceof Error ? error.message : String(error);
3346
+ throw new CliError(`invalid JSON request: ${message}`, "INVALID_ARGUMENT", 2 /* InvalidArguments */);
3347
+ }
3348
+ }
3349
+ function routeForKnowledgeBase(mode, knowledgeBase) {
3350
+ return mode === "single" ? "/_search" : `/${knowledgeBase.name}/_search`;
3351
+ }
3352
+ function resolveKnowledgeBaseForPath(pathname, mode, knowledgeBases) {
3353
+ const segments = pathname.split("/").filter(Boolean);
3354
+ if (mode === "single") {
3355
+ const knowledgeBase = [...knowledgeBases.values()][0];
3356
+ if (!knowledgeBase) {
3357
+ return null;
3358
+ }
3359
+ if (segments.length === 1 && segments[0] === "_search") {
3360
+ return knowledgeBase;
3361
+ }
3362
+ if (segments.length === 2 && segments[1] === "_search" && segments[0] === knowledgeBase.configuredIndexName) {
3363
+ return knowledgeBase;
3364
+ }
3365
+ return null;
3366
+ }
3367
+ if (segments.length === 2 && segments[1] === "_search") {
3368
+ return knowledgeBases.get(segments[0]) ?? null;
3369
+ }
3370
+ return null;
3371
+ }
3372
+ async function handleSearchRequest(request, response, pathname, mode, knowledgeBases) {
3373
+ if (request.method !== "GET" && request.method !== "POST") {
3374
+ response.setHeader("allow", "GET, POST");
3375
+ sendError(response, 405, "method_not_allowed", `unsupported method for ${pathname}`);
3376
+ return;
3377
+ }
3378
+ const knowledgeBase = resolveKnowledgeBaseForPath(pathname, mode, knowledgeBases);
3379
+ if (!knowledgeBase) {
3380
+ sendError(response, 404, "resource_not_found_exception", `unknown search route: ${pathname}`);
3381
+ return;
3382
+ }
3383
+ try {
3384
+ const requestBody = parseSearchRequest(await readRequestBody(request));
3385
+ const indexName = mode === "multi" ? knowledgeBase.name : knowledgeBase.configuredIndexName;
3386
+ const result = await searchJsonRequest({
3387
+ index: knowledgeBase.index,
3388
+ request: requestBody,
3389
+ indexName
3390
+ });
3391
+ sendJson(response, 200, result);
3392
+ } catch (error) {
3393
+ if (error instanceof CliError && error.code === "INVALID_ARGUMENT") {
3394
+ sendError(response, 400, "parse_exception", error.message);
3395
+ return;
3396
+ }
3397
+ const message = error instanceof Error ? error.message : String(error);
3398
+ sendError(response, 500, "search_phase_execution_exception", message);
3399
+ }
3400
+ }
3401
+ async function startSearchApiServer({
3402
+ workspacePath,
3403
+ host = "127.0.0.1",
3404
+ port = 3e3
3405
+ }) {
3406
+ const { mode, knowledgeBases } = await discoverKnowledgeBases(workspacePath);
3407
+ const byName = new Map(knowledgeBases.map((knowledgeBase) => [knowledgeBase.name, knowledgeBase]));
3408
+ const server = createServer(async (request, response) => {
3409
+ const url2 = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
3410
+ await handleSearchRequest(request, response, url2.pathname, mode, byName);
3411
+ });
3412
+ await new Promise((resolve2, reject) => {
3413
+ server.once("error", reject);
3414
+ server.listen(port, host, () => {
3415
+ server.off("error", reject);
3416
+ resolve2();
3417
+ });
3418
+ });
3419
+ const address = server.address();
3420
+ if (!address || typeof address === "string") {
3421
+ throw new CliError("server failed to bind to a TCP address", "SERVER_ERROR", 1 /* GeneralError */);
3422
+ }
3423
+ const url = `http://${host}:${address.port}`;
3424
+ return {
3425
+ mode,
3426
+ url,
3427
+ knowledgeBases: knowledgeBases.map((knowledgeBase) => ({
3428
+ name: knowledgeBase.name,
3429
+ workspacePath: knowledgeBase.workspacePath,
3430
+ route: routeForKnowledgeBase(mode, knowledgeBase)
3431
+ })),
3432
+ close: async () => new Promise((resolve2, reject) => {
3433
+ server.close((error) => error ? reject(error) : resolve2());
3434
+ })
3435
+ };
3436
+ }
3437
+
3438
+ // src/query/related-service.ts
3439
+ import path20 from "path";
3221
3440
  function cosineSimilarity2(left, right) {
3222
3441
  let dot = 0;
3223
3442
  let leftNorm = 0;
@@ -3293,7 +3512,7 @@ async function findRelatedDocuments({
3293
3512
  if (!await fileExists(denseVectorPath(workspacePath))) {
3294
3513
  throw new CliError("dense vector index is not built; run `qli models pull --dense` and `qli rebuild`", "DENSE_INDEX_MISSING", 7 /* QueryError */);
3295
3514
  }
3296
- const documents = await readJsonl(path19.join(workspacePath, "documents", "documents.jsonl"));
3515
+ const documents = await readJsonl(path20.join(workspacePath, "documents", "documents.jsonl"));
3297
3516
  const selected = resolveDocumentSelector(documents, document);
3298
3517
  const densePayload = await readDensePayload(workspacePath);
3299
3518
  const vectors = buildDocumentVectors(documents, densePayload.chunks, densePayload.metadata.dimensions);
@@ -3366,7 +3585,7 @@ async function createContext({
3366
3585
  }
3367
3586
 
3368
3587
  // src/report/diff-service.ts
3369
- import path20 from "path";
3588
+ import path21 from "path";
3370
3589
  function chooseBaselineRun(runs, since) {
3371
3590
  if (since === "last-run") {
3372
3591
  return runs.at(-1);
@@ -3382,7 +3601,7 @@ async function diffWorkspace({
3382
3601
  documentId,
3383
3602
  since
3384
3603
  }) {
3385
- const current = await readJsonl(path20.join(workspacePath, "documents", "documents.jsonl"));
3604
+ const current = await readJsonl(path21.join(workspacePath, "documents", "documents.jsonl"));
3386
3605
  const baseline = chooseBaselineRun(await listRuns(workspacePath), since);
3387
3606
  const previous = new Map((baseline?.documentsSnapshot ?? []).map((document) => [document.id, document]));
3388
3607
  const changedDocuments = current.filter((document) => (!sourceId || document.sourceId === sourceId) && (!documentId || document.id === documentId)).filter((document) => {
@@ -3438,12 +3657,15 @@ export {
3438
3657
  ingestSources,
3439
3658
  listSources,
3440
3659
  loadConfig,
3660
+ loadHydratedIndex,
3441
3661
  removeSource,
3442
3662
  renderChangeReport,
3443
3663
  reprocessDocuments,
3444
3664
  searchIndex,
3445
3665
  searchJsonIndex,
3666
+ searchJsonRequest,
3446
3667
  searchResultsFromResponse,
3668
+ startSearchApiServer,
3447
3669
  updateSource,
3448
3670
  writeDefaultConfig
3449
3671
  };
@@ -1,5 +1,6 @@
1
- import { type JsonDslRequest, type JsonDslResponse } from "@tryformation/querylight-ts";
1
+ import { type DocumentIndex, type JsonDslRequest, type JsonDslResponse } from "@tryformation/querylight-ts";
2
2
  import type { RetrievalMode, SearchResponseData, SearchResult } from "../types/models.js";
3
+ export declare function loadHydratedIndex(workspacePath: string): Promise<DocumentIndex>;
3
4
  type SearchDateField = "publicationDate" | "firstSeenAt" | "lastSeenAt" | "lastChangedAt" | "crawledAt";
4
5
  type SearchDateRange = {
5
6
  field: SearchDateField;
@@ -7,6 +8,11 @@ type SearchDateRange = {
7
8
  to?: string;
8
9
  };
9
10
  export declare function searchResultsFromResponse(response: SearchResponseData, showChunks?: boolean): SearchResult[];
11
+ export declare function searchJsonRequest({ index, request, indexName }: {
12
+ index: DocumentIndex;
13
+ request: JsonDslRequest;
14
+ indexName?: string;
15
+ }): Promise<JsonDslResponse>;
10
16
  export declare function searchJsonIndex({ workspacePath, request, indexName }: {
11
17
  workspacePath: string;
12
18
  request: JsonDslRequest;
@@ -0,0 +1,15 @@
1
+ export type SearchApiServerInfo = {
2
+ mode: "single" | "multi";
3
+ url: string;
4
+ knowledgeBases: Array<{
5
+ name: string;
6
+ workspacePath: string;
7
+ route: string;
8
+ }>;
9
+ close: () => Promise<void>;
10
+ };
11
+ export declare function startSearchApiServer({ workspacePath, host, port }: {
12
+ workspacePath: string;
13
+ host?: string;
14
+ port?: number;
15
+ }): Promise<SearchApiServerInfo>;
@@ -1,6 +1,10 @@
1
1
  import { type ProgressHandler } from "../core/progress.js";
2
2
  import type { DenseVectorPayload, WorkspaceConfig } from "../types/models.js";
3
- export declare function setDenseEmbedderFactoryForTests(factory: ((cacheDir: string, modelId: string) => Promise<(text: string) => Promise<number[]>>) | null): void;
3
+ type DenseEmbedder = {
4
+ embed(text: string): Promise<number[]>;
5
+ dispose?: () => Promise<void>;
6
+ };
7
+ export declare function setDenseEmbedderFactoryForTests(factory: ((cacheDir: string, modelId: string) => Promise<DenseEmbedder | ((text: string) => Promise<number[]>)>) | null): void;
4
8
  export declare function pullDenseModel(workspacePath: string, config: WorkspaceConfig["retrieval"]["dense"]): Promise<void>;
5
9
  export declare function buildDenseVectors({ workspacePath, config, progress }: {
6
10
  workspacePath: string;
@@ -13,3 +17,4 @@ export declare function denseQuery({ workspacePath, config, query, topK }: {
13
17
  query: string;
14
18
  topK: number;
15
19
  }): Promise<Array<[string, number]>>;
20
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryformation/querylight-cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Querylight CLI for building and querying local knowledge bases.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/formation-res/querylight-cli#readme",
@@ -7,19 +7,40 @@ from huggingface_hub import hf_hub_download
7
7
  from transformers import AutoModelForMaskedLM, AutoTokenizer
8
8
 
9
9
 
10
+ def _load_query_weights_file(model_id: str, filename: str):
11
+ try:
12
+ return hf_hub_download(repo_id=model_id, filename=filename)
13
+ except Exception:
14
+ return None
15
+
16
+
10
17
  def build_query_token_weight_vector(tokenizer, model_id: str):
11
- local_cached_path = hf_hub_download(repo_id=model_id, filename="query_token_weights.txt")
12
18
  vector = [0.0] * tokenizer.vocab_size
13
-
14
- with open(local_cached_path, encoding="utf-8") as handle:
15
- for line in handle:
16
- line = line.rstrip("\n")
17
- if not line:
18
- continue
19
- token, weight = line.split("\t", 1)
19
+ local_cached_path = _load_query_weights_file(model_id, "query_token_weights.txt")
20
+
21
+ if local_cached_path is not None:
22
+ with open(local_cached_path, encoding="utf-8") as handle:
23
+ for line in handle:
24
+ line = line.rstrip("\n")
25
+ if not line:
26
+ continue
27
+ token, weight = line.split("\t", 1)
28
+ token_id = tokenizer._convert_token_to_id_with_added_voc(token)
29
+ if token_id is not None and token_id >= 0:
30
+ vector[token_id] = float(weight)
31
+ return vector
32
+
33
+ local_cached_path = _load_query_weights_file(model_id, "idf.json")
34
+ if local_cached_path is not None:
35
+ with open(local_cached_path, encoding="utf-8") as handle:
36
+ idf = json.load(handle)
37
+ for token, weight in idf.items():
20
38
  token_id = tokenizer._convert_token_to_id_with_added_voc(token)
21
39
  if token_id is not None and token_id >= 0:
22
40
  vector[token_id] = float(weight)
41
+ return vector
42
+
43
+ raise FileNotFoundError(f"missing query token weights for {model_id}: expected query_token_weights.txt or idf.json")
23
44
 
24
45
  return vector
25
46