@lincoln504/pi-research 1.6.18 → 1.6.20

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/dist/cli.mjs CHANGED
@@ -2389,10 +2389,13 @@ var init_atomic_replace = __esm({
2389
2389
 
2390
2390
  // src/knowledge/availability.ts
2391
2391
  import { createRequire as createRequire3 } from "node:module";
2392
- function probeKnowledgeStoreAvailability(resolver = defaultResolver) {
2393
- if (cachedIsValidFor(resolver)) return _cached;
2392
+ function requiredPackages(retrieval) {
2393
+ return retrieval === "bm25" ? ["@lancedb/lancedb"] : ["@lancedb/lancedb", "@huggingface/transformers"];
2394
+ }
2395
+ function probeKnowledgeStoreAvailability(retrieval = "vector", resolver = defaultResolver) {
2396
+ if (cachedIsValidFor(retrieval, resolver)) return _cached;
2394
2397
  const missing = [];
2395
- for (const pkg of REQUIRED_PACKAGES) {
2398
+ for (const pkg of requiredPackages(retrieval)) {
2396
2399
  try {
2397
2400
  resolver(pkg);
2398
2401
  } catch {
@@ -2400,11 +2403,14 @@ function probeKnowledgeStoreAvailability(resolver = defaultResolver) {
2400
2403
  }
2401
2404
  }
2402
2405
  const result = { available: missing.length === 0, missing };
2403
- if (resolver === defaultResolver) _cached = result;
2406
+ if (resolver === defaultResolver) {
2407
+ _cached = result;
2408
+ _cachedRetrieval = retrieval;
2409
+ }
2404
2410
  return result;
2405
2411
  }
2406
- function cachedIsValidFor(resolver) {
2407
- return _cached !== null && resolver === defaultResolver;
2412
+ function cachedIsValidFor(retrieval, resolver) {
2413
+ return _cached !== null && _cachedRetrieval === retrieval && resolver === defaultResolver;
2408
2414
  }
2409
2415
  function describeKnowledgeStoreUnavailability(availability) {
2410
2416
  if (availability.available) return "";
@@ -2413,12 +2419,12 @@ function describeKnowledgeStoreUnavailability(availability) {
2413
2419
  );
2414
2420
  return `knowledge store is OFF: required package${parts.length > 1 ? "s" : ""} not resolvable \u2014 ${parts.join("; ")}. Repair with a full install of optional dependencies (e.g. 'npm install' including optional deps, or pi install npm:@lincoln504/pi-research without --omit=optional).`;
2415
2421
  }
2416
- var REQUIRED_PACKAGES, _cached, defaultResolver;
2422
+ var _cached, _cachedRetrieval, defaultResolver;
2417
2423
  var init_availability = __esm({
2418
2424
  "src/knowledge/availability.ts"() {
2419
2425
  "use strict";
2420
- REQUIRED_PACKAGES = ["@lancedb/lancedb", "@huggingface/transformers"];
2421
2426
  _cached = null;
2427
+ _cachedRetrieval = null;
2422
2428
  defaultResolver = (specifier) => (
2423
2429
  // import.meta.url survives esbuild's ESM output; createRequire only builds a
2424
2430
  // resolver — it never evaluates the specifier.
@@ -2611,37 +2617,39 @@ function getDbDir(config, cwd = process.cwd()) {
2611
2617
  }
2612
2618
  function describeKnowledgeStoreMode(cwd = process.cwd(), iface) {
2613
2619
  const cfg = getConfig(cwd, iface);
2614
- const KEY = "PI_RESEARCH_KNOWLEDGE_STORE_MODE";
2615
- const fileHasKey = (p) => {
2620
+ const fileHasKey = (key, p) => {
2616
2621
  try {
2617
- return fs5.existsSync(p) && parseDotEnv(fs5.readFileSync(p, "utf-8"))[KEY] !== void 0;
2622
+ return fs5.existsSync(p) && parseDotEnv(fs5.readFileSync(p, "utf-8"))[key] !== void 0;
2618
2623
  } catch {
2619
2624
  return false;
2620
2625
  }
2621
2626
  };
2622
- let origin = "built-in default";
2623
- if (process.env[KEY] !== void 0) {
2624
- origin = "environment variable";
2625
- } else {
2627
+ const originFor = (KEY) => {
2628
+ if (process.env[KEY] !== void 0) {
2629
+ return "environment variable";
2630
+ }
2626
2631
  let registryHasKey = false;
2627
2632
  try {
2628
2633
  const registry = loadProjectSettingsRegistry();
2629
2634
  registryHasKey = findRegistryEntry(registry, normalizeWorkspacePath(cwd))?.[KEY] !== void 0;
2630
2635
  } catch {
2631
2636
  }
2632
- if (registryHasKey) origin = "this directory (project settings)";
2633
- else if (iface && fileHasKey(getInterfaceEnvFilePath(iface))) origin = `${iface}.env overlay`;
2634
- else if (fileHasKey(getGlobalEnvFilePath())) origin = "config.env (machine-wide default)";
2635
- }
2637
+ if (registryHasKey) return "this directory (project settings)";
2638
+ if (iface && fileHasKey(KEY, getInterfaceEnvFilePath(iface))) return `${iface}.env overlay`;
2639
+ if (fileHasKey(KEY, getGlobalEnvFilePath())) return "config.env (machine-wide default)";
2640
+ return "built-in default";
2641
+ };
2636
2642
  return {
2637
2643
  mode: cfg.KNOWLEDGE_STORE_MODE,
2638
- origin,
2644
+ origin: originFor("PI_RESEARCH_KNOWLEDGE_STORE_MODE"),
2645
+ retrieval: cfg.KNOWLEDGE_STORE_RETRIEVAL,
2646
+ retrievalOrigin: originFor("PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL"),
2639
2647
  dbDir: getDbDir(cfg, cwd),
2640
- ...availabilityFields()
2648
+ ...availabilityFields(cfg)
2641
2649
  };
2642
2650
  }
2643
- function availabilityFields() {
2644
- const availability = probeKnowledgeStoreAvailability();
2651
+ function availabilityFields(cfg) {
2652
+ const availability = probeKnowledgeStoreAvailability(cfg?.KNOWLEDGE_STORE_RETRIEVAL ?? getConfig().KNOWLEDGE_STORE_RETRIEVAL);
2645
2653
  return {
2646
2654
  available: availability.available,
2647
2655
  unavailableReason: describeKnowledgeStoreUnavailability(availability)
@@ -2787,6 +2795,7 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
2787
2795
  PI_RESEARCH_WORKER_THREADS: String(config.WORKER_THREADS),
2788
2796
  PI_RESEARCH_WORKER_CONCURRENCY: String(config.WORKER_CONCURRENCY),
2789
2797
  PI_RESEARCH_KNOWLEDGE_STORE_MODE: config.KNOWLEDGE_STORE_MODE,
2798
+ PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL: config.KNOWLEDGE_STORE_RETRIEVAL,
2790
2799
  PI_RESEARCH_EMBEDDING_MODEL: config.EMBEDDING_MODEL,
2791
2800
  PI_RESEARCH_EMBEDDING_DEVICE: config.EMBEDDING_DEVICE,
2792
2801
  PI_RESEARCH_SCRAPE_TIMEOUT_MS: String(config.SCRAPE_TIMEOUT_MS),
@@ -2947,6 +2956,7 @@ function createConfig(env, processEnv) {
2947
2956
  WORKER_THREADS: parseEnvNumber(e, "PI_RESEARCH_WORKER_THREADS", DEFAULTS.WORKER_THREADS, 1, 10, true),
2948
2957
  WORKER_CONCURRENCY: parseEnvNumber(e, "PI_RESEARCH_WORKER_CONCURRENCY", DEFAULTS.WORKER_CONCURRENCY, 1, 10, true),
2949
2958
  KNOWLEDGE_STORE_MODE: parseEnvEnum(e, "PI_RESEARCH_KNOWLEDGE_STORE_MODE", ["none", "project", "global"], "global"),
2959
+ KNOWLEDGE_STORE_RETRIEVAL: parseEnvEnum(e, "PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL", ["vector", "bm25"], "vector"),
2950
2960
  EMBEDDING_MODEL: parseEnvString(e, "PI_RESEARCH_EMBEDDING_MODEL", DEFAULTS.EMBEDDING_MODEL),
2951
2961
  EMBEDDING_DEVICE: parseEnvEnum(e, "PI_RESEARCH_EMBEDDING_DEVICE", ["auto", "webgpu", "cpu"], DEFAULTS.EMBEDDING_DEVICE),
2952
2962
  SCRAPE_TIMEOUT_MS: parseEnvNumber(e, "PI_RESEARCH_SCRAPE_TIMEOUT_MS", DEFAULTS.SCRAPE_TIMEOUT_MS, 5e3, 12e4),
@@ -3082,6 +3092,13 @@ var init_config = __esm({
3082
3092
  WORKER_CONCURRENCY: Type.Number({ minimum: 1, maximum: 10, default: 2 }),
3083
3093
  /** Knowledge store isolation mode (default: 'global' — one shared store across every directory) */
3084
3094
  KNOWLEDGE_STORE_MODE: Type.Union([Type.Literal("none"), Type.Literal("project"), Type.Literal("global")], { default: "global" }),
3095
+ /** Knowledge store retrieval strategy (default: 'vector' — current hybrid dense+sparse behavior).
3096
+ * 'vector' = hybrid search (embedding similarity + BM25/FTS fused via RRF; requires the
3097
+ * optional @huggingface/transformers dependency and a downloaded embedding model).
3098
+ * 'bm25' = pure lexical/BM25 retrieval over the same stored findings — NO embedding model:
3099
+ * never initialized, downloaded, or invoked. Uses a separate lexical table in the same
3100
+ * LanceDB database, so existing vector stores are untouched (no migration). */
3101
+ KNOWLEDGE_STORE_RETRIEVAL: Type.Union([Type.Literal("vector"), Type.Literal("bm25")], { default: "vector" }),
3085
3102
  /** Embedding model to use for the knowledge store */
3086
3103
  EMBEDDING_MODEL: Type.String({ default: "onnx-community/granite-embedding-small-english-r2-ONNX" }),
3087
3104
  /** Hardware backend for embeddings: 'auto' (probe WebGPU viability out-of-process, fall back to CPU), 'webgpu' (force), or 'cpu' (force).
@@ -3225,7 +3242,8 @@ var init_config = __esm({
3225
3242
  DEFAULTS = Value.Create(ConfigSchema);
3226
3243
  LOCAL_SCOPE_KEYS = /* @__PURE__ */ new Set([
3227
3244
  "PI_RESEARCH_DEFAULT_RESEARCH_DEPTH",
3228
- "PI_RESEARCH_KNOWLEDGE_STORE_MODE"
3245
+ "PI_RESEARCH_KNOWLEDGE_STORE_MODE",
3246
+ "PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL"
3229
3247
  ]);
3230
3248
  USER_MIGRATION_KEYS = [
3231
3249
  // Core user-scoped from schema
@@ -7400,8 +7418,35 @@ function createStoreSchema(dim, modelName) {
7400
7418
  ["schema_version", CURRENT_SCHEMA_VERSION]
7401
7419
  ]));
7402
7420
  }
7421
+ function createBm25StoreSchema() {
7422
+ return new Schema([
7423
+ new Field("url", new Utf8(), false),
7424
+ new Field("text", new Utf8(), false),
7425
+ new Field("content", new Utf8(), true),
7426
+ // full page markdown, nullable
7427
+ new Field("metadata", new Utf8(), false),
7428
+ // JSON stringified
7429
+ new Field("workspace", new Utf8(), false),
7430
+ // local workspace path or 'global'
7431
+ new Field("is_global", new Bool(), false),
7432
+ // boolean indicating if it's shared globally
7433
+ new Field("ingestion_type", new Utf8(), false),
7434
+ // e.g. 'synthesis-description'
7435
+ new Field("timestamp", new Int64(), false)
7436
+ ], /* @__PURE__ */ new Map([
7437
+ ["embedding_model", BM25_EMBEDDING_MODEL_TAG],
7438
+ ["schema_version", CURRENT_SCHEMA_VERSION]
7439
+ ]));
7440
+ }
7403
7441
  async function createStoreTable(db, name, dim, modelName) {
7404
7442
  const schema = createStoreSchema(dim, modelName);
7443
+ return createKnowledgeTable(db, name, schema);
7444
+ }
7445
+ async function createBm25StoreTable(db, name = BM25_TABLE_NAME) {
7446
+ const schema = createBm25StoreSchema();
7447
+ return createKnowledgeTable(db, name, schema);
7448
+ }
7449
+ async function createKnowledgeTable(db, name, schema) {
7405
7450
  const { Index } = await getLancedb();
7406
7451
  const table = await db.createTable({
7407
7452
  name,
@@ -7417,12 +7462,14 @@ async function createStoreTable(db, name, dim, modelName) {
7417
7462
  await table.createIndex("ingestion_type", { config: Index.btree() });
7418
7463
  return table;
7419
7464
  }
7420
- var CURRENT_SCHEMA_VERSION;
7465
+ var CURRENT_SCHEMA_VERSION, BM25_TABLE_NAME, BM25_EMBEDDING_MODEL_TAG;
7421
7466
  var init_store_schema = __esm({
7422
7467
  "src/knowledge/store-schema.ts"() {
7423
7468
  "use strict";
7424
7469
  init_lancedb_loader();
7425
7470
  CURRENT_SCHEMA_VERSION = "4";
7471
+ BM25_TABLE_NAME = "knowledge-bm25";
7472
+ BM25_EMBEDDING_MODEL_TAG = "bm25-lexical";
7426
7473
  }
7427
7474
  });
7428
7475
 
@@ -7522,6 +7569,110 @@ async function searchStore(table, embedder, query, getReranker, limit, scopeFilt
7522
7569
  metrics.increment("knowledge_store_search_results_total", filteredResults.length);
7523
7570
  return filteredResults;
7524
7571
  }
7572
+ async function addDocumentsBm25ToStore(table, docs, isClosing, workspace, isGlobal) {
7573
+ if (docs.length === 0) return "empty";
7574
+ if (isClosing()) {
7575
+ logger.warn("[store] Ignoring addDocuments during close");
7576
+ return "ignored_closing";
7577
+ }
7578
+ const startTime = Date.now();
7579
+ try {
7580
+ const data = docs.map((doc) => ({
7581
+ url: doc.url,
7582
+ text: doc.text,
7583
+ content: doc.content ?? null,
7584
+ metadata: JSON.stringify(doc.metadata),
7585
+ workspace,
7586
+ is_global: isGlobal,
7587
+ ingestion_type: doc.ingestion_type || doc.metadata["ingestionType"] || "unknown",
7588
+ timestamp: BigInt(doc.timestamp)
7589
+ }));
7590
+ await table.add(data);
7591
+ const duration = Date.now() - startTime;
7592
+ metrics.observe("knowledge_store_add_documents_duration_ms", duration, { retrieval: "bm25" });
7593
+ metrics.increment("knowledge_store_chunks_added_total", docs.length);
7594
+ logger.log(`[store] Added ${docs.length} chunk(s) for ${docs[0]?.url} [retrieval=bm25, workspace=${workspace}, global=${isGlobal}]`);
7595
+ return "written";
7596
+ } catch (err) {
7597
+ const duration = Date.now() - startTime;
7598
+ metrics.observe("knowledge_store_add_documents_duration_ms", duration, { status: "error", retrieval: "bm25" });
7599
+ logger.debug(`[store] bm25 add attempt failed after ${duration}ms: ${err instanceof Error ? err.message : String(err)}`);
7600
+ throw err;
7601
+ }
7602
+ }
7603
+ async function searchStoreBm25(table, query, limit, scopeFilter) {
7604
+ const startTime = Date.now();
7605
+ const rowCount = scopeFilter ? await table.countRows(scopeFilter) : await table.countRows();
7606
+ if (rowCount === 0) {
7607
+ metrics.increment("knowledge_store_search_total", 1, { status: "empty", retrieval: "bm25" });
7608
+ return [];
7609
+ }
7610
+ let filter = "ingestion_type = 'synthesis-description'";
7611
+ if (scopeFilter) {
7612
+ filter = `(${filter}) AND (${scopeFilter})`;
7613
+ }
7614
+ const results = await table.query().where(filter).fullTextSearch(query, { columns: ["text", "content"] }).limit(limit).toArray();
7615
+ const filteredResults = results.map((r) => {
7616
+ let metadata;
7617
+ try {
7618
+ metadata = JSON.parse(r.metadata);
7619
+ } catch {
7620
+ logger.debug("[store] Corrupted metadata in searchStoreBm25, skipping row");
7621
+ return null;
7622
+ }
7623
+ return {
7624
+ url: r.url,
7625
+ text: r.text,
7626
+ content: r.content ?? void 0,
7627
+ metadata,
7628
+ timestamp: Number(r.timestamp)
7629
+ };
7630
+ }).filter((doc) => doc !== null);
7631
+ const duration = Date.now() - startTime;
7632
+ metrics.observe("knowledge_store_search_duration_ms", duration);
7633
+ metrics.increment("knowledge_store_search_total", 1, { status: "success", retrieval: "bm25" });
7634
+ metrics.increment("knowledge_store_search_results_total", filteredResults.length);
7635
+ return filteredResults;
7636
+ }
7637
+ async function findRelevantUrlsBm25(table, query, limit, scopeFilter) {
7638
+ const startTime = Date.now();
7639
+ const rowCount = scopeFilter ? await table.countRows(scopeFilter) : await table.countRows();
7640
+ if (rowCount === 0) {
7641
+ metrics.increment("knowledge_store_find_urls_total", 1, { status: "empty", retrieval: "bm25" });
7642
+ return [];
7643
+ }
7644
+ let filter = "ingestion_type = 'synthesis-description'";
7645
+ if (scopeFilter) {
7646
+ filter = `(${filter}) AND (${scopeFilter})`;
7647
+ }
7648
+ const results = await table.query().where(filter).fullTextSearch(query, { columns: ["text", "content"] }).limit(limit).toArray();
7649
+ const entries = [];
7650
+ const seen = /* @__PURE__ */ new Set();
7651
+ for (const r of results) {
7652
+ const url = r.url;
7653
+ if (seen.has(url)) continue;
7654
+ seen.add(url);
7655
+ let description = "";
7656
+ let provenance = "description-unverified";
7657
+ try {
7658
+ const meta = JSON.parse(r.metadata);
7659
+ description = meta.description ?? "";
7660
+ if (meta.provenance) provenance = meta.provenance;
7661
+ else if (meta.hasContent === true) provenance = "scraped-verified";
7662
+ } catch {
7663
+ logger.debug("[store] Corrupted metadata in findRelevantUrlsBm25, skipping row");
7664
+ }
7665
+ if (!description) description = (r.text ?? "").substring(0, 300);
7666
+ const scope = r.is_global === true ? "Global Store" : `Local Project (${r.workspace || "unknown"})`;
7667
+ const finalProvenance = `${provenance} [${scope}]`;
7668
+ entries.push({ url, description, provenance: finalProvenance });
7669
+ }
7670
+ const duration = Date.now() - startTime;
7671
+ metrics.observe("knowledge_store_find_urls_duration_ms", duration);
7672
+ metrics.increment("knowledge_store_find_urls_total", 1, { status: "success", retrieval: "bm25" });
7673
+ metrics.increment("knowledge_store_urls_found_total", entries.length);
7674
+ return entries;
7675
+ }
7525
7676
  async function findDocumentsByUrl(table, url, scopeFilter) {
7526
7677
  const startTime = Date.now();
7527
7678
  const escapedUrl = url.replace(/'/g, "''");
@@ -7631,7 +7782,12 @@ var init_store = __esm({
7631
7782
  // Coalesces concurrent open() calls (see open()).
7632
7783
  _openPromise = null;
7633
7784
  options;
7634
- tableName = "knowledge";
7785
+ tableName;
7786
+ // The OTHER retrieval mode's active table name, preserved across manifest
7787
+ // save/load so the two modes can share one dbDir without clobbering each
7788
+ // other's migration-renamed tables ('vector' owns activeTableName,
7789
+ // 'bm25' owns activeBm25TableName).
7790
+ otherModeTableName = null;
7635
7791
  manifestPath;
7636
7792
  isClosing = false;
7637
7793
  activeWrites = /* @__PURE__ */ new Set();
@@ -7660,8 +7816,23 @@ var init_store = __esm({
7660
7816
  });
7661
7817
  constructor(options) {
7662
7818
  this.options = options;
7819
+ this.tableName = this.retrieval === "bm25" ? BM25_TABLE_NAME : "knowledge";
7663
7820
  this.manifestPath = path15.join(this.options.dbDir, "store-manifest.json");
7664
7821
  }
7822
+ /** Normalized retrieval strategy: an unset option means the historical
7823
+ * 'vector' (hybrid) behavior, not bm25. Every mode check goes through this. */
7824
+ get retrieval() {
7825
+ return this.options.retrieval ?? "vector";
7826
+ }
7827
+ /** The embedding backend. Only callable on the vector (hybrid) path — the
7828
+ * single choke point that makes a null embedder in bm25 mode fail loud here
7829
+ * instead of surfacing as a mysterious TypeError deep in an embed call. */
7830
+ get embedder() {
7831
+ if (this.retrieval === "bm25" || !this.options.embedder) {
7832
+ throw new Error("[store] embedder accessed in bm25 (lexical) mode \u2014 internal invariant violated");
7833
+ }
7834
+ return this.options.embedder;
7835
+ }
7665
7836
  getScopeFilter() {
7666
7837
  const ws = this.getWorkspace().replace(/'/g, "''");
7667
7838
  switch (this.options.knowledgeMode) {
@@ -7687,8 +7858,12 @@ var init_store = __esm({
7687
7858
  if (fs13.existsSync(this.manifestPath)) {
7688
7859
  const content = await fsPromises.readFile(this.manifestPath, "utf-8");
7689
7860
  const manifest = JSON.parse(content);
7690
- if (manifest.activeTableName) {
7691
- this.tableName = manifest.activeTableName;
7861
+ const savedVectorTable = typeof manifest.activeTableName === "string" ? manifest.activeTableName : null;
7862
+ const savedBm25Table = typeof manifest.activeBm25TableName === "string" ? manifest.activeBm25TableName : null;
7863
+ this.otherModeTableName = this.retrieval === "bm25" ? savedVectorTable : savedBm25Table;
7864
+ const savedOwnTable = this.retrieval === "bm25" ? savedBm25Table : savedVectorTable;
7865
+ if (savedOwnTable) {
7866
+ this.tableName = savedOwnTable;
7692
7867
  logger.debug(`[store] Loaded active table name from manifest: ${this.tableName}`);
7693
7868
  }
7694
7869
  if (manifest.lastMigration && typeof manifest.lastMigration.model === "string") {
@@ -7707,7 +7882,11 @@ var init_store = __esm({
7707
7882
  const tempPath = `${this.manifestPath}.tmp`;
7708
7883
  try {
7709
7884
  const content = JSON.stringify({
7710
- activeTableName: this.tableName,
7885
+ activeTableName: this.retrieval === "vector" ? this.tableName : this.otherModeTableName,
7886
+ // bm25 keeps its OWN manifest key so a lexical store can share the dbDir
7887
+ // with a vector store — neither mode's table renames (migrations,
7888
+ // backups) can strand the other's manifest pointer.
7889
+ activeBm25TableName: this.retrieval === "bm25" ? this.tableName : this.otherModeTableName,
7711
7890
  // Record of the two most recent model/schema migrations. Read back on open
7712
7891
  // to detect ALTERNATION (two configurations sharing this dbDir each
7713
7892
  // migrating the table back and forth) — previously that thrash was
@@ -7732,6 +7911,7 @@ var init_store = __esm({
7732
7911
  }
7733
7912
  async open() {
7734
7913
  if (this.db) return;
7914
+ await this.loadManifest();
7735
7915
  if (this.isClosing) {
7736
7916
  throw new Error("KnowledgeStore.open() called after close() \u2014 construct a fresh store instead.");
7737
7917
  }
@@ -7757,7 +7937,13 @@ var init_store = __esm({
7757
7937
  }
7758
7938
  this.db = await (await getLancedb()).connect(this.options.dbDir);
7759
7939
  const tableNames = await this.db.tableNames();
7760
- if (tableNames.includes(this.tableName)) {
7940
+ if (this.retrieval === "bm25") {
7941
+ if (tableNames.includes(this.tableName)) {
7942
+ this.table = await this.db.openTable(this.tableName);
7943
+ } else {
7944
+ this.table = await this.createTable();
7945
+ }
7946
+ } else if (tableNames.includes(this.tableName)) {
7761
7947
  this.table = await this.db.openTable(this.tableName);
7762
7948
  let migrationInProgress = false;
7763
7949
  try {
@@ -7776,8 +7962,8 @@ var init_store = __esm({
7776
7962
  const vectorField = schema.fields.find((f) => f.name === "vector");
7777
7963
  if (vectorField && vectorField.type.constructor.name === "FixedSizeList") {
7778
7964
  const dim = vectorField.type.listSize;
7779
- if (this.options.embedder.getDimension() === null) {
7780
- this.options.embedder.setDimension(dim);
7965
+ if (this.embedder.getDimension() === null) {
7966
+ this.embedder.setDimension(dim);
7781
7967
  logger.debug(`[store] Extracted dimension ${dim} from existing table schema`);
7782
7968
  }
7783
7969
  }
@@ -7787,7 +7973,7 @@ var init_store = __esm({
7787
7973
  logger.warn(`[store] ${reason} detected: ${storedModel} (v${storedVersion}) \u2192 ${this.options.modelName} (v${CURRENT_SCHEMA_VERSION})`);
7788
7974
  const strategy = this.options.migrationStrategy || "backup";
7789
7975
  migrationInProgress = true;
7790
- if (this.options.embedder.getDimension() === null) {
7976
+ if (this.embedder.getDimension() === null) {
7791
7977
  await this.withEmbedderReconnect((embedder) => embedder.embedMany([" "]));
7792
7978
  }
7793
7979
  try {
@@ -7822,7 +8008,7 @@ var init_store = __esm({
7822
8008
  logger.warn("[store] Failed to read table schema:", schemaErr);
7823
8009
  }
7824
8010
  } else {
7825
- if (this.options.embedder.getDimension() === null) {
8011
+ if (this.embedder.getDimension() === null) {
7826
8012
  await this.withEmbedderReconnect((embedder) => embedder.embedMany([" "]));
7827
8013
  }
7828
8014
  this.table = await this.createTable();
@@ -7934,7 +8120,7 @@ var init_store = __esm({
7934
8120
  });
7935
8121
  const texts = batchDocs.map((d) => d.text);
7936
8122
  const vectors = await this.withEmbedderReconnect((embedder) => embedder.embedMany(texts));
7937
- assertValidEmbeddingVectors(vectors, batchDocs.length, this.options.embedder.getDimension?.());
8123
+ assertValidEmbeddingVectors(vectors, batchDocs.length, this.embedder.getDimension?.());
7938
8124
  const records = batchDocs.map((doc, idx) => ({
7939
8125
  vector: Array.from(vectors[idx]),
7940
8126
  url: doc.url,
@@ -8014,11 +8200,17 @@ var init_store = __esm({
8014
8200
  }
8015
8201
  async createTable(name = this.tableName) {
8016
8202
  if (!this.db) throw new Error("Database not connected");
8017
- const dim = this.options.embedder.getDimension();
8203
+ if (this.retrieval === "bm25") {
8204
+ return createBm25StoreTable(this.db, name);
8205
+ }
8206
+ const dim = this.embedder.getDimension();
8018
8207
  if (dim === null) throw new Error("[store] Cannot create table: embedder dimension unknown (not yet initialized)");
8019
8208
  return createStoreTable(this.db, name, dim, this.options.modelName);
8020
8209
  }
8021
8210
  async withEmbedderReconnect(fn) {
8211
+ if (this.retrieval === "bm25" || !this.options.embedder) {
8212
+ throw new Error("[store] withEmbedderReconnect called without an embedder (bm25 mode?) \u2014 internal invariant violated");
8213
+ }
8022
8214
  try {
8023
8215
  return await fn(this.options.embedder);
8024
8216
  } catch (err) {
@@ -8100,17 +8292,26 @@ var init_store = __esm({
8100
8292
  }
8101
8293
  let table = await this.getFreshTable();
8102
8294
  const workspace = this.getWorkspace();
8103
- const outcome = await this.withEmbedderReconnect(async (embedder) => {
8295
+ const runWriteAttempt = async () => {
8104
8296
  let retryCount = 0;
8105
8297
  const MAX_RETRIES = 5;
8106
8298
  const BASE_DELAY = 100;
8107
8299
  while (retryCount <= MAX_RETRIES) {
8108
8300
  try {
8109
8301
  const isGlobal = this.options.knowledgeMode === "global";
8302
+ if (this.retrieval === "bm25") {
8303
+ return await addDocumentsBm25ToStore(
8304
+ table,
8305
+ docs,
8306
+ () => this.isClosing,
8307
+ workspace,
8308
+ isGlobal
8309
+ );
8310
+ }
8110
8311
  return await addDocumentsToStore(
8111
8312
  table,
8112
8313
  docs,
8113
- embedder,
8314
+ this.embedder,
8114
8315
  () => this.isClosing,
8115
8316
  workspace,
8116
8317
  isGlobal
@@ -8129,7 +8330,8 @@ var init_store = __esm({
8129
8330
  }
8130
8331
  }
8131
8332
  throw new Error("[store] addDocuments retry loop exited without result");
8132
- });
8333
+ };
8334
+ const outcome = this.retrieval === "bm25" ? await runWriteAttempt() : await this.withEmbedderReconnect(runWriteAttempt);
8133
8335
  if (outcome !== "empty") {
8134
8336
  metrics.increment("knowledge_store_add_documents_total", 1, {
8135
8337
  status: outcome === "ignored_closing" ? "ignored_closing" : "success"
@@ -8154,6 +8356,9 @@ var init_store = __esm({
8154
8356
  return this.trackOperation("search", [], async () => {
8155
8357
  const table = await this.getFreshTable();
8156
8358
  const scopeFilter = this.getScopeFilter();
8359
+ if (this.retrieval === "bm25") {
8360
+ return this.circuitBreaker.execute(() => searchStoreBm25(table, query, options.limit ?? 5, scopeFilter));
8361
+ }
8157
8362
  return this.circuitBreaker.execute(
8158
8363
  () => this.withEmbedderReconnect(
8159
8364
  (embedder) => searchStore(table, embedder, query, this.getReranker.bind(this), options.limit ?? 5, scopeFilter)
@@ -8308,8 +8513,9 @@ var init_store = __esm({
8308
8513
  url: r.url,
8309
8514
  // The text is the summary/description
8310
8515
  text: r.text,
8311
- // The vector is the model-dimension embedding
8312
- v: Array.from(r.vector),
8516
+ // The vector is the model-dimension embedding — vector/hybrid mode only;
8517
+ // a bm25 store has no vector column, so the export omits it entirely.
8518
+ ...this.retrieval === "vector" ? { v: Array.from(r.vector) } : {},
8313
8519
  // Minimal metadata needed for the UI
8314
8520
  m: {
8315
8521
  d: metadata["description"] || "",
@@ -8390,6 +8596,9 @@ var init_store = __esm({
8390
8596
  return this.trackOperation("findRelevantUrls", [], async () => {
8391
8597
  const table = await this.getFreshTable();
8392
8598
  const scopeFilter = this.getScopeFilter();
8599
+ if (this.retrieval === "bm25") {
8600
+ return this.circuitBreaker.execute(() => findRelevantUrlsBm25(table, query, options.limit ?? 20, scopeFilter));
8601
+ }
8393
8602
  return this.circuitBreaker.execute(
8394
8603
  () => this.withEmbedderReconnect(
8395
8604
  (embedder) => findRelevantUrls(table, embedder, query, this.getReranker.bind(this), options.limit ?? 20, scopeFilter)
@@ -9078,7 +9287,7 @@ function getMigrationStrategy(config) {
9078
9287
  logger.warn(`[knowledge] Invalid migration strategy '${strategy}'. Valid options: drop, re-embed, backup. Falling back to default (backup).`);
9079
9288
  return void 0;
9080
9289
  }
9081
- async function createKnowledgeStoreComponents(embedderFactory, reconnectFactory, withLock, config = getConfig(), workspace = process.cwd(), availability = probeKnowledgeStoreAvailability()) {
9290
+ async function createKnowledgeStoreComponents(embedderFactory, reconnectFactory, withLock, config = getConfig(), workspace = process.cwd(), availability = probeKnowledgeStoreAvailability(config.KNOWLEDGE_STORE_RETRIEVAL)) {
9082
9291
  if (config.KNOWLEDGE_STORE_MODE === "none") {
9083
9292
  logger.debug("[knowledge] Knowledge store is disabled in configuration");
9084
9293
  return null;
@@ -9091,13 +9300,14 @@ async function createKnowledgeStoreComponents(embedderFactory, reconnectFactory,
9091
9300
  for (let attempt = 1; attempt <= MAX_INIT_RETRIES; attempt++) {
9092
9301
  let store;
9093
9302
  try {
9094
- logger.info(`[knowledge] Creating Knowledge Store components (attempt ${attempt}/${MAX_INIT_RETRIES})...`);
9095
- const embedder = await embedderFactory();
9303
+ logger.info(`[knowledge] Creating Knowledge Store components (attempt ${attempt}/${MAX_INIT_RETRIES}, retrieval=${config.KNOWLEDGE_STORE_RETRIEVAL})...`);
9304
+ const embedder = config.KNOWLEDGE_STORE_RETRIEVAL === "bm25" ? null : await embedderFactory();
9096
9305
  const migrationStrategy = getMigrationStrategy(config);
9097
9306
  store = new KnowledgeStore({
9098
9307
  dbDir: getDbDir(config, workspace),
9099
9308
  embedder,
9100
9309
  modelName: config.EMBEDDING_MODEL,
9310
+ retrieval: config.KNOWLEDGE_STORE_RETRIEVAL,
9101
9311
  migrationStrategy,
9102
9312
  reconnectFactory,
9103
9313
  withLock,
@@ -9139,7 +9349,9 @@ async function forceDeleteKnowledgeStore(config, workspace) {
9139
9349
  try {
9140
9350
  if (fs14.existsSync(manifestPath)) {
9141
9351
  const manifest = JSON.parse(fs14.readFileSync(manifestPath, "utf-8"));
9142
- if (manifest.activeTableName) tableName = manifest.activeTableName;
9352
+ const key = config?.KNOWLEDGE_STORE_RETRIEVAL === "bm25" ? "activeBm25TableName" : "activeTableName";
9353
+ const saved = key === "activeBm25TableName" ? manifest.activeBm25TableName : manifest.activeTableName;
9354
+ if (saved) tableName = saved;
9143
9355
  }
9144
9356
  } catch (err) {
9145
9357
  logger.warn(`[knowledge] Failed to read manifest before forced delete, defaulting to table '${tableName}':`, err);
@@ -9209,6 +9421,10 @@ var init_knowledge_store_service = __esm({
9209
9421
  // mode change (project↔global re-scope, or enabled→none) and re-initialize so a /research-config
9210
9422
  // change applies without a Pi restart — not just the DISABLED→enabled revival.
9211
9423
  _initializedMode = void 0;
9424
+ // The retrieval strategy the current build reflects ('vector' hybrid / 'bm25'
9425
+ // lexical) — drives the embedder-optional invariants below and the live
9426
+ // re-init detection on a /research-config retrieval change.
9427
+ _initializedRetrieval = void 0;
9212
9428
  // Initialization promise to prevent concurrent initialization
9213
9429
  _initializationPromise = null;
9214
9430
  // Disposal promise: lets a concurrent initialize() wait out an in-flight
@@ -9224,13 +9440,16 @@ var init_knowledge_store_service = __esm({
9224
9440
  }
9225
9441
  const newCwd = ctx?.cwd || this._cwd;
9226
9442
  if ((this.lifecycle === "initialized" /* INITIALIZED */ || this.lifecycle === "disabled" /* DISABLED */) && this._cwd === newCwd) {
9227
- const liveMode = (ctx?.config || getConfig(this._cwd)).KNOWLEDGE_STORE_MODE;
9443
+ const liveConfig = ctx?.config || getConfig(this._cwd);
9444
+ const liveMode = liveConfig.KNOWLEDGE_STORE_MODE;
9445
+ const liveRetrieval = liveConfig.KNOWLEDGE_STORE_RETRIEVAL;
9228
9446
  const reviveFromMode = this.lifecycle === "disabled" /* DISABLED */ && this._disabledReason === "mode" && liveMode !== "none";
9229
9447
  const modeChanged = this.lifecycle === "initialized" /* INITIALIZED */ && ctx?.config !== void 0 && this._initializedMode !== void 0 && this._initializedMode !== ctx.config.KNOWLEDGE_STORE_MODE;
9230
- if (!reviveFromMode && !modeChanged) {
9448
+ const retrievalChanged = this.lifecycle === "initialized" /* INITIALIZED */ && ctx?.config !== void 0 && this._initializedRetrieval !== void 0 && this._initializedRetrieval !== ctx.config.KNOWLEDGE_STORE_RETRIEVAL;
9449
+ if (!reviveFromMode && !modeChanged && !retrievalChanged) {
9231
9450
  return;
9232
9451
  }
9233
- logger.log(`[KnowledgeStoreService] Knowledge Mode changed (${this._initializedMode ?? this._disabledReason} \u2192 ${liveMode}); re-initializing store (no restart needed).`);
9452
+ logger.log(`[KnowledgeStoreService] Knowledge config changed (${this._initializedMode ?? this._disabledReason} \u2192 ${liveMode}, retrieval ${this._initializedRetrieval ?? "?"} \u2192 ${liveRetrieval}); re-initializing store (no restart needed).`);
9234
9453
  if (this.lifecycle === "initialized" /* INITIALIZED */) {
9235
9454
  await this.dispose();
9236
9455
  }
@@ -9265,6 +9484,7 @@ var init_knowledge_store_service = __esm({
9265
9484
  this._cwd = ctx?.cwd || this._cwd;
9266
9485
  const config = ctx?.config || getConfig(this._cwd);
9267
9486
  this._initializedMode = config.KNOWLEDGE_STORE_MODE;
9487
+ this._initializedRetrieval = config.KNOWLEDGE_STORE_RETRIEVAL;
9268
9488
  const embedderFactory = () => getEmbedder(config);
9269
9489
  const reconnectFactory = async () => {
9270
9490
  await clearEmbeddingInstance();
@@ -9332,7 +9552,7 @@ var init_knowledge_store_service = __esm({
9332
9552
  }
9333
9553
  }
9334
9554
  if (!components) {
9335
- const depsMissing = !probeKnowledgeStoreAvailability().available;
9555
+ const depsMissing = !probeKnowledgeStoreAvailability(config.KNOWLEDGE_STORE_RETRIEVAL).available;
9336
9556
  logger.debug(`[KnowledgeStoreService] Knowledge store is disabled (${depsMissing ? "dependencies missing" : "mode = none"}). Setting lifecycle to DISABLED.`);
9337
9557
  this.lifecycle = "disabled" /* DISABLED */;
9338
9558
  this._disabledReason = depsMissing ? "native" : "mode";
@@ -9430,6 +9650,9 @@ var init_knowledge_store_service = __esm({
9430
9650
  * Check if the knowledge store is ready
9431
9651
  */
9432
9652
  isReady() {
9653
+ if (this._initializedRetrieval === "bm25") {
9654
+ return this._store !== null && this._writerQueue !== null;
9655
+ }
9433
9656
  return this._embedder !== null && this._store !== null && this._writerQueue !== null;
9434
9657
  }
9435
9658
  /** The working directory this service initialized against (from ctx.cwd). */
@@ -9453,6 +9676,9 @@ var init_knowledge_store_service = __esm({
9453
9676
  if (this.lifecycle === "disabled" /* DISABLED */) {
9454
9677
  return null;
9455
9678
  }
9679
+ if (this._initializedRetrieval === "bm25") {
9680
+ return null;
9681
+ }
9456
9682
  if (!this._embedder) {
9457
9683
  throw new Error("[KnowledgeStoreService] Embedder not initialized");
9458
9684
  }
@@ -18127,7 +18353,21 @@ function registerHealthChecks(registry, container = getServiceContainer()) {
18127
18353
  }
18128
18354
  const store = await service.getStore();
18129
18355
  const counts = store ? await store.countScoped() : { local: 0, global: 0, projects: 0 };
18356
+ const retrieval = getConfig(service.getCwd()).KNOWLEDGE_STORE_RETRIEVAL;
18130
18357
  const embedder = await service.getEmbedder();
18358
+ if (retrieval === "bm25") {
18359
+ return {
18360
+ healthy: true,
18361
+ diagnostic: {
18362
+ status: "initialized",
18363
+ retrieval: "bm25",
18364
+ model: "none (lexical BM25)",
18365
+ localEntries: counts.local,
18366
+ globalEntries: counts.global,
18367
+ totalProjects: counts.projects
18368
+ }
18369
+ };
18370
+ }
18131
18371
  if (!embedder) {
18132
18372
  return { healthy: false, error: "Embedder service not available" };
18133
18373
  }
@@ -18158,6 +18398,7 @@ function registerHealthChecks(registry, container = getServiceContainer()) {
18158
18398
  healthy: true,
18159
18399
  diagnostic: {
18160
18400
  status: "initialized",
18401
+ retrieval,
18161
18402
  device,
18162
18403
  model: config2.EMBEDDING_MODEL,
18163
18404
  localEntries: counts.local,
@@ -27400,6 +27641,29 @@ knowledge store mode: ${cfg.KNOWLEDGE_STORE_MODE} (default depth: ${cfg.DEFAUL
27400
27641
  async function cmdKnowledgeConfig(kc) {
27401
27642
  const cwd = process.cwd();
27402
27643
  try {
27644
+ if (kc.action === "set-retrieval") {
27645
+ const before = getConfig(cwd, "cli").KNOWLEDGE_STORE_RETRIEVAL;
27646
+ const cfg = { ...getConfig(cwd, "cli"), KNOWLEDGE_STORE_RETRIEVAL: kc.retrieval };
27647
+ saveConfig(cfg, "local", cwd, ["PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL"]);
27648
+ resetConfig();
27649
+ const info2 = describeKnowledgeStoreMode(cwd, "cli");
27650
+ const overridden = info2.retrieval !== kc.retrieval;
27651
+ if (kc.json) {
27652
+ toStdout(pretty({ command: "knowledge-config", action: "set-retrieval", requested: kc.retrieval, previous: before, saved: true, effectiveOverriddenBy: overridden ? info2.retrievalOrigin : null, ...info2, cwd }));
27653
+ return EXIT.OK;
27654
+ }
27655
+ toStdout(
27656
+ `knowledge store retrieval for this directory: ${before} -> ${kc.retrieval} (saved per-directory)
27657
+ strategy: ${kc.retrieval === "bm25" ? "lexical BM25 \u2014 no embedding model needed" : "hybrid (embedding + BM25, fused) \u2014 needs the optional embedding dependency"}
27658
+ saved: per-directory (${cwd})
27659
+ db dir: ${info2.dbDir}
27660
+ ` + (info2.available ? "" : ` state: ${info2.unavailableReason}
27661
+ `) + (overridden ? `
27662
+ NOTE: the effective retrieval here is still '${info2.retrieval}' because a higher-precedence ${info2.retrievalOrigin} overrides it. Clear that source for this setting to take effect.
27663
+ ` : "")
27664
+ );
27665
+ return EXIT.OK;
27666
+ }
27403
27667
  if (kc.action === "set") {
27404
27668
  const before = getConfig(cwd, "cli").KNOWLEDGE_STORE_MODE;
27405
27669
  const cfg = { ...getConfig(cwd, "cli"), KNOWLEDGE_STORE_MODE: kc.mode };
@@ -27430,12 +27694,15 @@ NOTE: the effective mode here is still '${info2.mode}' because a higher-preceden
27430
27694
  }
27431
27695
  toStdout(
27432
27696
  `knowledge store mode: ${info.mode} (source: ${info.origin})
27433
- scope: ${info.mode === "none" ? "disabled here" : info.mode === "project" ? "this directory only" : "shared across all directories"}
27434
- db dir: ${info.dbDir}
27435
- ` + (info.available ? "" : ` state: ${info.unavailableReason}
27697
+ knowledge store retrieval: ${info.retrieval} (source: ${info.retrievalOrigin})
27698
+ scope: ${info.mode === "none" ? "disabled here" : info.mode === "project" ? "this directory only" : "shared across all directories"}
27699
+ strategy: ${info.retrieval === "bm25" ? "lexical BM25 \u2014 no embedding model needed" : "hybrid (embedding + BM25, fused)"}
27700
+ db dir: ${info.dbDir}
27701
+ ` + (info.available ? "" : ` state: ${info.unavailableReason}
27436
27702
  `) + `
27437
- change it for THIS directory: ${BINARY_NAME} knowledge-config set <none|project|global>
27438
- change the machine-wide default: set PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${resolvedConfigPaths().configEnv}
27703
+ change scope for THIS directory: ${BINARY_NAME} knowledge-config set <none|project|global>
27704
+ change retrieval for THIS directory: ${BINARY_NAME} knowledge-config set retrieval <vector|bm25>
27705
+ change the machine-wide defaults: set PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> and/or PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL=<vector|bm25> in ${resolvedConfigPaths().configEnv}
27439
27706
  `
27440
27707
  );
27441
27708
  return EXIT.OK;
@@ -27680,6 +27947,10 @@ COMMANDS
27680
27947
  --json Emit a JSON object.
27681
27948
  knowledge-config set <mode> Set the mode for THIS directory (none | project | global);
27682
27949
  persisted per-directory. Takes effect on the next run.
27950
+ knowledge-config set retrieval <vector|bm25>
27951
+ Set the retrieval strategy for THIS directory: 'vector'
27952
+ (hybrid embedding+BM25, default) or 'bm25' (pure lexical,
27953
+ no embedding model). Persisted per-directory.
27683
27954
 
27684
27955
  status Show detected config, model/key, and readiness.
27685
27956
  --config <path> Layer this config file on top (highest file precedence; base
@@ -27713,6 +27984,7 @@ CONFIGURE
27713
27984
 
27714
27985
  Knowledge store \u2014 ON by default in every directory (mode 'global', one shared store).
27715
27986
  per-directory: ${BINARY_NAME} knowledge-config set <none|project|global>
27987
+ ${BINARY_NAME} knowledge-config set retrieval <vector|bm25>
27716
27988
  machine-wide: PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${p.configEnv}
27717
27989
  'project' scopes the store to the current directory; 'none' disables it here.
27718
27990
 
@@ -27814,6 +28086,22 @@ function parseArgs(argv) {
27814
28086
  return out;
27815
28087
  }
27816
28088
  if (action === "set") {
28089
+ if (positional[1] === "retrieval") {
28090
+ const retrieval = positional[2];
28091
+ if (retrieval === void 0) {
28092
+ throw new UsageError("knowledge-config set retrieval requires a strategy: vector | bm25.");
28093
+ }
28094
+ if (retrieval !== "vector" && retrieval !== "bm25") {
28095
+ throw new UsageError(`invalid retrieval strategy "${retrieval}". Use one of: vector | bm25.`);
28096
+ }
28097
+ if (positional.length > 3) {
28098
+ throw new UsageError(`unexpected argument "${positional[3]}" after "knowledge-config set retrieval ${retrieval}".`);
28099
+ }
28100
+ out.command = "knowledge-config";
28101
+ out.knowledgeConfig = { action: "set-retrieval", retrieval, json };
28102
+ out.configPath = configPath;
28103
+ return out;
28104
+ }
27817
28105
  const mode = positional[1];
27818
28106
  if (mode === void 0) {
27819
28107
  throw new UsageError("knowledge-config set requires a mode: none | project | global.");
@@ -27829,7 +28117,7 @@ function parseArgs(argv) {
27829
28117
  out.configPath = configPath;
27830
28118
  return out;
27831
28119
  }
27832
- throw new UsageError(`unknown knowledge-config action "${action}". Use: show | set <none|project|global>.`);
28120
+ throw new UsageError(`unknown knowledge-config action "${action}". Use: show | set <none|project|global> | set retrieval <vector|bm25>.`);
27833
28121
  }
27834
28122
  if (cmd === "skill") {
27835
28123
  let json = false;