@lincoln504/pi-research 1.6.19 → 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.
@@ -2699,6 +2699,13 @@ var ConfigSchema = Type.Object({
2699
2699
  WORKER_CONCURRENCY: Type.Number({ minimum: 1, maximum: 10, default: 2 }),
2700
2700
  /** Knowledge store isolation mode (default: 'global' — one shared store across every directory) */
2701
2701
  KNOWLEDGE_STORE_MODE: Type.Union([Type.Literal("none"), Type.Literal("project"), Type.Literal("global")], { default: "global" }),
2702
+ /** Knowledge store retrieval strategy (default: 'vector' — current hybrid dense+sparse behavior).
2703
+ * 'vector' = hybrid search (embedding similarity + BM25/FTS fused via RRF; requires the
2704
+ * optional @huggingface/transformers dependency and a downloaded embedding model).
2705
+ * 'bm25' = pure lexical/BM25 retrieval over the same stored findings — NO embedding model:
2706
+ * never initialized, downloaded, or invoked. Uses a separate lexical table in the same
2707
+ * LanceDB database, so existing vector stores are untouched (no migration). */
2708
+ KNOWLEDGE_STORE_RETRIEVAL: Type.Union([Type.Literal("vector"), Type.Literal("bm25")], { default: "vector" }),
2702
2709
  /** Embedding model to use for the knowledge store */
2703
2710
  EMBEDDING_MODEL: Type.String({ default: "onnx-community/granite-embedding-small-english-r2-ONNX" }),
2704
2711
  /** Hardware backend for embeddings: 'auto' (probe WebGPU viability out-of-process, fall back to CPU), 'webgpu' (force), or 'cpu' (force).
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.6.20] - 2026-09-21
9
+
10
+ ### Added
11
+
12
+ - **BM25-only (lexical) knowledge-store retrieval mode: the store can now run entirely without an embedding model** (merged PR #14 by @akmalayari). A new project-scoped setting, `PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL` (`vector` — the default and unchanged hybrid behavior — or `bm25`), selects how stored findings are retrieved. In `bm25` mode, retrieval is pure BM25 over LanceDB's Tantivy full-text indexes (the same lexical half the hybrid path already used, now standing alone over the summary text and full page Markdown) — the `@huggingface/transformers` dependency is never resolved, loaded, downloaded, or invoked, so a bm25-only installation works with `--omit=optional` and no model download. Each strategy keeps its own table in the same database directory (`knowledge` vs `knowledge-bm25`), so switching requires no migration and existing vector stores are untouched. The mode is exposed everywhere the scoping mode is: `knowledge-config set retrieval <vector|bm25>` and `show` on the CLI, a Knowledge Retrieval entry in the `/research-config` menu (embedding model/device entries hide in bm25 mode), the healthcheck, and the availability probe — which in bm25 mode no longer demands the optional embedding package. New tests cover the lexical round-trip, BM25 ranking sanity, scope isolation, cross-mode coexistence, per-mode availability probing, config parsing, and a hard embedder-factory-never-called isolation assertion; docs (KNOWLEDGE-STORE.md, CONFIGURATION.md, `.env.example`) document the three-way scoping × retrieval story.
13
+
14
+ ### Changed
15
+
16
+ - **pi packages updated to the 0.87.0 line and the compatibility floor/tested-ceiling moved to match.** `@earendil-works/pi-ai`, `pi-coding-agent`, `pi-server` and `pi-tui` move `>=0.85.0 <1` → `>=0.87.0 <1` (all four verified at 0.87.0); `PI_MIN_VERSION` and `PI_TESTED_MAX_VERSION` move 0.85.0 → 0.87.0 with the floor's rationale updated for the 0.86 pi-ai TranscriptContext / JSON-value / fail-closed-user_bash changes and the 0.87 `SessionEntry` union (`ContextEditEntry`), SessionManager-canonical provider context, and `shouldStopAfterTurn` → `finishTurn` boundary. All four type-checks, lint, and the full unit suite (3,120 tests) pass against the 0.87.0 packages; `src/index.ts`'s runtime-check comment tracks the new floor.
17
+ - **`@lancedb/lancedb` moves 0.38.0 → 0.39.0** (same-semver native store; the full unit suite passes against it, and the `apache-arrow` hold below was re-verified against the new native reader — the 21.2.0 IPC failure reproduces identically under 0.39.0).
18
+
19
+ ### Fixed
20
+
21
+ - **CodeQL alert #267 (`js/unused-local-variable`, note): an unused `vi` import left in the PR #14 test file `store-bm25.test.ts` is removed.** Caught by the CodeQL run on the merge commit; no other open alerts remain.
22
+ - **Dependency holds re-verified and enforced again after an attempted refresh:** `apache-arrow` stays 21.1.0 — 21.2.0 breaks `@lancedb/lancedb` 0.39.0's IPC schema parsing (the RangeOutOfBounds footer error reproduces in the test runner; a create-then-read of the store schema fails at `createTable`), confirming the existing hold against the new native reader; `playwright-core` stays 1.60.0 (camoufox-js 0.12.0's peer range still caps `<1.61.0` — 1.63.0 fails `npm install` with ERESOLVE); `@huggingface/transformers` stays 4.2.0 (4.3.0 pulls `onnxruntime-node` 1.30.0 and reshuffles the optional native stack for no needed gain, per the 1.6.18 hold); `typebox` moves 1.3.7 → 1.3.27, realigning the lockstep rule with the new host line (pi 0.87.0's own packages pin 1.3.27 — 1.3.34 was briefly tried and reverted for breaking that rule). Dev-only in-range bumps: `eslint` 10.10.0 → 10.11.0, `@typescript-eslint/*` 8.70.0 → 8.70.1.
23
+
8
24
  ## [1.6.19] - 2026-09-18
9
25
 
10
26
  ### Added
@@ -27,6 +27,7 @@ variables still take effect.) A setting is written to one of two scopes:
27
27
  |---------|-------|--------|---------|
28
28
  | `/research` depth | project | normal · deep · ultra | `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` |
29
29
  | Knowledge Mode | project | none · project · global | `PI_RESEARCH_KNOWLEDGE_STORE_MODE` |
30
+ | Knowledge Retrieval | project | vector · bm25 | `PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL` |
30
31
  | Researcher Timeout | user | 3 · 5 · 10 · 15 · 20 · 30 (minutes) | `PI_RESEARCH_TIMEOUT_MS` |
31
32
  | Max Concurrency | user | 1 – 5 | `PI_RESEARCH_MAX_RESEARCHERS` |
32
33
  | Scrape Batches | user | unlimited · 1 · 2 · 3 · 5 · 10 · 15 | `PI_RESEARCH_MAX_SCRAPE_BATCHES` |
@@ -127,7 +128,8 @@ See the [knowledge store doc](KNOWLEDGE-STORE.md) for what each value does.
127
128
 
128
129
  | Variable | Default | Range | Description |
129
130
  |----------|---------|-------|-------------|
130
- | `PI_RESEARCH_KNOWLEDGE_STORE_MODE` (TUI) `[project]` | `global` | none · project · global | Store scope: one shared store across every directory (`global`), scoped to the current directory (`project`), or disabled (`none`). Independently of this setting, the store is cleanly OFF when its required packages are not installed (optional `@huggingface/transformers` skipped at install, broken `@lancedb/lancedb`): init fails fast instead of retry-storming, and `pi-research knowledge-config`, the `/research-config` menu, and the healthcheck all name the missing package and the repair. |
131
+ | `PI_RESEARCH_KNOWLEDGE_STORE_MODE` (TUI) `[project]` | `global` | none · project · global | Store scope: one shared store across every directory (`global`), scoped to the current directory (`project`), or disabled (`none`). Independently of this setting, the store is cleanly OFF when its required packages are not installed (optional `@huggingface/transformers` skipped at install, broken `@lancedb/lancedb`): init fails fast instead of retry-storming, and `pi-research knowledge-config`, the `/research-config` menu, and the healthcheck all name the missing package and the repair. In `bm25` retrieval mode the embedding package is not required at all. |
132
+ | `PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL` (TUI) `[project]` | `vector` | vector · bm25 | Retrieval strategy: `vector` = hybrid (embedding similarity + BM25 fused via RRF; needs the optional embedding model), `bm25` = pure lexical BM25 (no embedding model — never downloaded or initialized; works with `--omit=optional`). Each strategy keeps its own table in the same database directory, so switching requires no migration. |
131
133
  | `PI_RESEARCH_EMBEDDING_MODEL` (TUI) | `onnx-community/granite-embedding-small-english-r2-ONNX` | — | Embedding model. Changing it clears the store and starts fresh. |
132
134
  | `PI_RESEARCH_EMBEDDING_DEVICE` (TUI) | `auto` | auto · webgpu · cpu | Inference backend. `auto` probes WebGPU viability out-of-process and falls back to CPU; `cpu` forces CPU; `webgpu` forces the GPU path with no probe (advanced — can hard-crash on a software GPU). The TUI exposes only `auto` (as "GPU") and `cpu`. |
133
135
  | `PI_RESEARCH_CACHE_TTL_DAYS` (TUI) | `30` | 1–365 | How long cached findings are kept before eviction. |
@@ -58,6 +58,35 @@ The embedding model is lazy — it only downloads and initializes the first time
58
58
  the store is actually written or searched, so the `global` default adds no startup
59
59
  cost until a run caches its first page.
60
60
 
61
+ ### Retrieval strategies: vector (hybrid) and bm25 (lexical)
62
+
63
+ Independent of scope, the store's retrieval strategy is set by
64
+ `PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL` (a project-scoped setting, default
65
+ `vector`):
66
+
67
+ | Strategy | Behavior |
68
+ |----------|----------|
69
+ | `vector` (default) | Hybrid search: embedding similarity + BM25 keyword matching over the stored summaries and full page text, fused via reciprocal rank fusion (RRF). Richest matching, especially for paraphrases. Requires the optional `@huggingface/transformers` dependency and a downloaded embedding model. |
70
+ | `bm25` | Pure lexical search: BM25 ranking over the same stored summaries and full page text. **No embedding model** — never downloaded, initialized, or invoked — so a bm25-only installation works with `--omit=optional` and adds no model download. |
71
+
72
+ Change the strategy for the current directory with the `/research-config` TUI
73
+ (Knowledge Retrieval), or with `pi-research knowledge-config set retrieval
74
+ <vector|bm25>` on the standalone CLI. Like Knowledge Mode, it persists to the
75
+ per-directory project registry and applies without a restart.
76
+
77
+ The two strategies keep **separate tables** in the same database directory
78
+ (`knowledge` for vector, `knowledge-bm25` for bm25). An existing vector store is
79
+ never touched by bm25 mode and vice versa — switching strategies requires no
80
+ migration, and each mode keeps its own history while the other is active. In
81
+ `bm25` mode, `exportKnowledge()` omits vectors, and the embedding settings
82
+ (`PI_RESEARCH_EMBEDDING_MODEL`, `PI_RESEARCH_EMBEDDING_DEVICE`,
83
+ `PI_RESEARCH_EMBEDDING_MODEL_INIT_TIMEOUT_MS`) are ignored entirely.
84
+
85
+ BM25 ranking uses Tantivy (LanceDB's full-text engine) with its default Unicode
86
+ tokenization and lowercasing, over both the summary text and the full page
87
+ Markdown — so lexical mode matches the same fields the hybrid mode's keyword
88
+ half does.
89
+
61
90
  ### How a run uses the store
62
91
 
63
92
  The store is driven by the orchestrator, not called ad hoc by researcher agents,
@@ -76,6 +105,9 @@ synthesized answer with citations — or reports that live research is needed.
76
105
 
77
106
  ### Embeddings and the model
78
107
 
108
+ Applies to the `vector` (hybrid) retrieval strategy only — `bm25` mode runs with
109
+ no embedding model at all.
110
+
79
111
  Embeddings are computed locally with
80
112
  [`@huggingface/transformers`](https://github.com/huggingface/transformers.js) over
81
113
  ONNX. The default model is
@@ -173,7 +205,8 @@ keep history longer.
173
205
 
174
206
  ### Changing the model: migration
175
207
 
176
- When the configured embedding model differs from the one the stored vectors were
208
+ Applies to the `vector` retrieval strategy. When the configured embedding model
209
+ differs from the one the stored vectors were
177
210
  built with, the store is migrated according to `PI_RESEARCH_MIGRATION_STRATEGY`:
178
211
 
179
212
  | Strategy | What happens |
@@ -209,12 +242,13 @@ indices are pruned to keep it bounded. There is no manual maintenance command.
209
242
  | Setting | Variable | Default |
210
243
  |---------|----------|---------|
211
244
  | Knowledge Mode (project-scoped) | `PI_RESEARCH_KNOWLEDGE_STORE_MODE` | `global` |
212
- | Embedding model | `PI_RESEARCH_EMBEDDING_MODEL` | `onnx-community/granite-embedding-small-english-r2-ONNX` |
213
- | Embedding device | `PI_RESEARCH_EMBEDDING_DEVICE` | `auto` |
245
+ | Retrieval strategy (project-scoped) | `PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL` | `vector` |
246
+ | Embedding model (vector mode only) | `PI_RESEARCH_EMBEDDING_MODEL` | `onnx-community/granite-embedding-small-english-r2-ONNX` |
247
+ | Embedding device (vector mode only) | `PI_RESEARCH_EMBEDDING_DEVICE` | `auto` |
214
248
  | Cache retention (days) | `PI_RESEARCH_CACHE_TTL_DAYS` | `30` |
215
249
  | Migration strategy | `PI_RESEARCH_MIGRATION_STRATEGY` | `backup` |
216
250
  | Database directory | `PI_RESEARCH_KNOWLEDGE_DIR` | `~/.pi/research/knowledge_db` |
217
- | Model init timeout (ms) | `PI_RESEARCH_EMBEDDING_MODEL_INIT_TIMEOUT_MS` | `300000` |
251
+ | Model init timeout, vector mode only (ms) | `PI_RESEARCH_EMBEDDING_MODEL_INIT_TIMEOUT_MS` | `300000` |
218
252
  | Re-probe WebGPU | `PI_RESEARCH_WEBGPU_REPROBE` | _(unset)_ |
219
253
 
220
254
  See [CONFIGURATION.md](CONFIGURATION.md) for the full configuration model, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.6.19",
3
+ "version": "1.6.20",
4
4
  "description": "Web research for your agents with smart and safe tooling + knowledge store",
5
5
  "type": "module",
6
6
  "private": false,
@@ -115,12 +115,12 @@
115
115
  "image": "https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/hero.png"
116
116
  },
117
117
  "dependencies": {
118
- "@earendil-works/pi-ai": ">=0.85.0 <1",
119
- "@earendil-works/pi-coding-agent": ">=0.85.0 <1",
120
- "@earendil-works/pi-server": ">=0.85.0 <1",
121
- "@earendil-works/pi-tui": ">=0.85.0 <1",
118
+ "@earendil-works/pi-ai": ">=0.87.0 <1",
119
+ "@earendil-works/pi-coding-agent": ">=0.87.0 <1",
120
+ "@earendil-works/pi-server": ">=0.87.0 <1",
121
+ "@earendil-works/pi-tui": ">=0.87.0 <1",
122
122
  "@kreuzberg/html-to-markdown-node": "^3.7.2",
123
- "@lancedb/lancedb": "0.38.0",
123
+ "@lancedb/lancedb": "0.39.0",
124
124
  "apache-arrow": "21.1.0",
125
125
  "bgutils-js": "^4.0.3",
126
126
  "camoufox-js": "^0.12.0",
@@ -131,7 +131,7 @@
131
131
  "pdf-oxide-wasm": "^0.3.77",
132
132
  "playwright-core": "1.60.0",
133
133
  "poolifier": "^5.3.2",
134
- "typebox": "1.3.7",
134
+ "typebox": "1.3.27",
135
135
  "undici": "^8.10.2",
136
136
  "youtubei.js": "^18.0.0"
137
137
  },
@@ -157,12 +157,12 @@
157
157
  "devDependencies": {
158
158
  "@eslint/js": "^10.0.1",
159
159
  "@types/jsdom": "^30.0.0",
160
- "@typescript-eslint/eslint-plugin": "^8.70.0",
161
- "@typescript-eslint/parser": "^8.70.0",
160
+ "@typescript-eslint/eslint-plugin": "8.70.1",
161
+ "@typescript-eslint/parser": "8.70.1",
162
162
  "@vitest/coverage-v8": "^5.0.1",
163
163
  "dependency-cruiser": "^18.2.0",
164
164
  "depngn": "^0.6.0",
165
- "eslint": "^10.10.0",
165
+ "eslint": "10.11.0",
166
166
  "npm-check-updates": "^23.1.0",
167
167
  "typescript": ">=6.0.3 <6.1.0",
168
168
  "vite": "^8.2.2",
package/src/cli.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * pi-research research "<query>" [--depth N] [--model provider/id]
9
9
  * pi-research knowledge "<q>" ["<q2>" ...]
10
10
  * pi-research knowledge-config [set <none|project|global>]
11
+ * pi-research knowledge-config set retrieval <vector|bm25>
11
12
  * pi-research status [--json]
12
13
  * pi-research help
13
14
  *
@@ -895,6 +896,32 @@ export async function cmdKnowledgeConfig(kc: NonNullable<ParsedArgs['knowledgeCo
895
896
  // silently breaking the documented `--json: Emit a JSON object` contract for
896
897
  // this one subcommand.
897
898
  try {
899
+ if (kc.action === 'set-retrieval') {
900
+ const before = getConfig(cwd, 'cli').KNOWLEDGE_STORE_RETRIEVAL;
901
+ const cfg = { ...getConfig(cwd, 'cli'), KNOWLEDGE_STORE_RETRIEVAL: kc.retrieval! };
902
+ // Persist ONLY the retrieval strategy for this directory — mirrors the
903
+ // scoping-mode `set` branch above (changedKeys-scoped registry write).
904
+ saveConfig(cfg, 'local', cwd, ['PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL']);
905
+ resetConfig();
906
+ const info = describeKnowledgeStoreMode(cwd, 'cli');
907
+ const overridden = info.retrieval !== kc.retrieval;
908
+ if (kc.json) {
909
+ toStdout(pretty({ command: 'knowledge-config', action: 'set-retrieval', requested: kc.retrieval, previous: before, saved: true, effectiveOverriddenBy: overridden ? info.retrievalOrigin : null, ...info, cwd }));
910
+ return EXIT.OK;
911
+ }
912
+ toStdout(
913
+ `knowledge store retrieval for this directory: ${before} -> ${kc.retrieval} (saved per-directory)\n` +
914
+ ` strategy: ${kc.retrieval === 'bm25' ? 'lexical BM25 — no embedding model needed' : 'hybrid (embedding + BM25, fused) — needs the optional embedding dependency'}\n` +
915
+ ` saved: per-directory (${cwd})\n` +
916
+ ` db dir: ${info.dbDir}\n` +
917
+ (info.available ? '' : ` state: ${info.unavailableReason}\n`) +
918
+ (overridden
919
+ ? `\nNOTE: the effective retrieval here is still '${info.retrieval}' because a higher-precedence ${info.retrievalOrigin} overrides it. Clear that source for this setting to take effect.\n`
920
+ : ''),
921
+ );
922
+ return EXIT.OK;
923
+ }
924
+
898
925
  if (kc.action === 'set') {
899
926
  const before = getConfig(cwd, 'cli').KNOWLEDGE_STORE_MODE;
900
927
  const cfg = { ...getConfig(cwd, 'cli'), KNOWLEDGE_STORE_MODE: kc.mode! };
@@ -932,11 +959,14 @@ export async function cmdKnowledgeConfig(kc: NonNullable<ParsedArgs['knowledgeCo
932
959
  }
933
960
  toStdout(
934
961
  `knowledge store mode: ${info.mode} (source: ${info.origin})\n` +
935
- ` scope: ${info.mode === 'none' ? 'disabled here' : info.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
936
- ` db dir: ${info.dbDir}\n` +
937
- (info.available ? '' : ` state: ${info.unavailableReason}\n`) +
938
- `\nchange it for THIS directory: ${BINARY_NAME} knowledge-config set <none|project|global>\n` +
939
- `change the machine-wide default: set PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${resolvedConfigPaths().configEnv}\n`,
962
+ `knowledge store retrieval: ${info.retrieval} (source: ${info.retrievalOrigin})\n` +
963
+ ` scope: ${info.mode === 'none' ? 'disabled here' : info.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
964
+ ` strategy: ${info.retrieval === 'bm25' ? 'lexical BM25 — no embedding model needed' : 'hybrid (embedding + BM25, fused)'}\n` +
965
+ ` db dir: ${info.dbDir}\n` +
966
+ (info.available ? '' : ` state: ${info.unavailableReason}\n`) +
967
+ `\nchange scope for THIS directory: ${BINARY_NAME} knowledge-config set <none|project|global>\n` +
968
+ `change retrieval for THIS directory: ${BINARY_NAME} knowledge-config set retrieval <vector|bm25>\n` +
969
+ `change the machine-wide defaults: set PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> and/or PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL=<vector|bm25> in ${resolvedConfigPaths().configEnv}\n`,
940
970
  );
941
971
  return EXIT.OK;
942
972
  } catch (err) {
@@ -1235,7 +1265,7 @@ interface ParsedArgs {
1235
1265
  command?: string;
1236
1266
  research?: ResearchArgs;
1237
1267
  knowledge?: { queries: string[]; json?: boolean };
1238
- knowledgeConfig?: { action: 'show' | 'set'; mode?: 'none' | 'project' | 'global'; json?: boolean };
1268
+ knowledgeConfig?: { action: 'show' | 'set' | 'set-retrieval'; mode?: 'none' | 'project' | 'global'; retrieval?: 'vector' | 'bm25'; json?: boolean };
1239
1269
  status?: { json?: boolean };
1240
1270
  skill?: { action: 'install' | 'uninstall' | 'status'; json?: boolean; dryRun?: boolean; copy?: boolean };
1241
1271
  configPath?: string;
@@ -1270,6 +1300,10 @@ COMMANDS
1270
1300
  --json Emit a JSON object.
1271
1301
  knowledge-config set <mode> Set the mode for THIS directory (none | project | global);
1272
1302
  persisted per-directory. Takes effect on the next run.
1303
+ knowledge-config set retrieval <vector|bm25>
1304
+ Set the retrieval strategy for THIS directory: 'vector'
1305
+ (hybrid embedding+BM25, default) or 'bm25' (pure lexical,
1306
+ no embedding model). Persisted per-directory.
1273
1307
 
1274
1308
  status Show detected config, model/key, and readiness.
1275
1309
  --config <path> Layer this config file on top (highest file precedence; base
@@ -1303,6 +1337,7 @@ CONFIGURE
1303
1337
 
1304
1338
  Knowledge store — ON by default in every directory (mode 'global', one shared store).
1305
1339
  per-directory: ${BINARY_NAME} knowledge-config set <none|project|global>
1340
+ ${BINARY_NAME} knowledge-config set retrieval <vector|bm25>
1306
1341
  machine-wide: PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${p.configEnv}
1307
1342
  'project' scopes the store to the current directory; 'none' disables it here.
1308
1343
 
@@ -1421,6 +1456,24 @@ export function parseArgs(argv: string[]): ParsedArgs {
1421
1456
  return out;
1422
1457
  }
1423
1458
  if (action === 'set') {
1459
+ // Extended form: `knowledge-config set retrieval <vector|bm25>` — switches the
1460
+ // retrieval STRATEGY (the scoping mode uses the original single-arg form).
1461
+ if (positional[1] === 'retrieval') {
1462
+ const retrieval = positional[2];
1463
+ if (retrieval === undefined) {
1464
+ throw new UsageError('knowledge-config set retrieval requires a strategy: vector | bm25.');
1465
+ }
1466
+ if (retrieval !== 'vector' && retrieval !== 'bm25') {
1467
+ throw new UsageError(`invalid retrieval strategy "${retrieval}". Use one of: vector | bm25.`);
1468
+ }
1469
+ if (positional.length > 3) {
1470
+ throw new UsageError(`unexpected argument "${positional[3]}" after "knowledge-config set retrieval ${retrieval}".`);
1471
+ }
1472
+ out.command = 'knowledge-config';
1473
+ out.knowledgeConfig = { action: 'set-retrieval', retrieval, json };
1474
+ out.configPath = configPath;
1475
+ return out;
1476
+ }
1424
1477
  const mode = positional[1];
1425
1478
  if (mode === undefined) {
1426
1479
  throw new UsageError('knowledge-config set requires a mode: none | project | global.');
@@ -1436,7 +1489,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
1436
1489
  out.configPath = configPath;
1437
1490
  return out;
1438
1491
  }
1439
- throw new UsageError(`unknown knowledge-config action "${action}". Use: show | set <none|project|global>.`);
1492
+ throw new UsageError(`unknown knowledge-config action "${action}". Use: show | set <none|project|global> | set retrieval <vector|bm25>.`);
1440
1493
  }
1441
1494
 
1442
1495
  if (cmd === 'skill') {
package/src/config.ts CHANGED
@@ -50,6 +50,13 @@ export const ConfigSchema = Type.Object({
50
50
  WORKER_CONCURRENCY: Type.Number({ minimum: 1, maximum: 10, default: 2 }),
51
51
  /** Knowledge store isolation mode (default: 'global' — one shared store across every directory) */
52
52
  KNOWLEDGE_STORE_MODE: Type.Union([Type.Literal('none'), Type.Literal('project'), Type.Literal('global')], { default: 'global' }),
53
+ /** Knowledge store retrieval strategy (default: 'vector' — current hybrid dense+sparse behavior).
54
+ * 'vector' = hybrid search (embedding similarity + BM25/FTS fused via RRF; requires the
55
+ * optional @huggingface/transformers dependency and a downloaded embedding model).
56
+ * 'bm25' = pure lexical/BM25 retrieval over the same stored findings — NO embedding model:
57
+ * never initialized, downloaded, or invoked. Uses a separate lexical table in the same
58
+ * LanceDB database, so existing vector stores are untouched (no migration). */
59
+ KNOWLEDGE_STORE_RETRIEVAL: Type.Union([Type.Literal('vector'), Type.Literal('bm25')], { default: 'vector' }),
53
60
  /** Embedding model to use for the knowledge store */
54
61
  EMBEDDING_MODEL: Type.String({ default: 'onnx-community/granite-embedding-small-english-r2-ONNX' }),
55
62
  /** Hardware backend for embeddings: 'auto' (probe WebGPU viability out-of-process, fall back to CPU), 'webgpu' (force), or 'cpu' (force).
@@ -211,6 +218,7 @@ export const DEFAULTS: Config = Value.Create(ConfigSchema);
211
218
  const LOCAL_SCOPE_KEYS = new Set([
212
219
  'PI_RESEARCH_DEFAULT_RESEARCH_DEPTH',
213
220
  'PI_RESEARCH_KNOWLEDGE_STORE_MODE',
221
+ 'PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL',
214
222
  ]);
215
223
 
216
224
  /**
@@ -600,6 +608,10 @@ export interface KnowledgeStoreModeInfo {
600
608
  mode: 'none' | 'project' | 'global';
601
609
  /** Human-readable source of the EFFECTIVE value (highest-precedence source that set it). */
602
610
  origin: string;
611
+ /** Knowledge store retrieval strategy ('vector' hybrid — default, or 'bm25' lexical-only). */
612
+ retrieval: 'vector' | 'bm25';
613
+ /** Human-readable source of the EFFECTIVE retrieval value (same precedence walk as `origin`). */
614
+ retrievalOrigin: string;
603
615
  /** Physical LanceDB directory backing the store (shared across project/global modes). */
604
616
  dbDir: string;
605
617
  /**
@@ -627,34 +639,40 @@ export function describeKnowledgeStoreMode(
627
639
  iface?: ConfigInterface,
628
640
  ): KnowledgeStoreModeInfo {
629
641
  const cfg = getConfig(cwd, iface);
630
- const KEY = 'PI_RESEARCH_KNOWLEDGE_STORE_MODE';
631
- const fileHasKey = (p: string): boolean => {
642
+ const fileHasKey = (key: string, p: string): boolean => {
632
643
  try {
633
- return fs.existsSync(p) && parseDotEnv(fs.readFileSync(p, 'utf-8'))[KEY] !== undefined;
644
+ return fs.existsSync(p) && parseDotEnv(fs.readFileSync(p, 'utf-8'))[key] !== undefined;
634
645
  } catch {
635
646
  return false;
636
647
  }
637
648
  };
638
649
 
639
- let origin = 'built-in default';
640
- if (process.env[KEY] !== undefined) {
641
- origin = 'environment variable';
642
- } else {
650
+ /**
651
+ * Walk the same precedence createConfig applies, highest first, for one env key:
652
+ * env var > per-directory project registry > {iface}.env overlay > config.env > default.
653
+ */
654
+ const originFor = (KEY: string): string => {
655
+ if (process.env[KEY] !== undefined) {
656
+ return 'environment variable';
657
+ }
643
658
  let registryHasKey = false;
644
659
  try {
645
660
  const registry = loadProjectSettingsRegistry();
646
661
  registryHasKey = findRegistryEntry(registry, normalizeWorkspacePath(cwd))?.[KEY] !== undefined;
647
662
  } catch { /* unreadable registry → not the origin */ }
648
- if (registryHasKey) origin = 'this directory (project settings)';
649
- else if (iface && fileHasKey(getInterfaceEnvFilePath(iface))) origin = `${iface}.env overlay`;
650
- else if (fileHasKey(getGlobalEnvFilePath())) origin = 'config.env (machine-wide default)';
651
- }
663
+ if (registryHasKey) return 'this directory (project settings)';
664
+ if (iface && fileHasKey(KEY, getInterfaceEnvFilePath(iface))) return `${iface}.env overlay`;
665
+ if (fileHasKey(KEY, getGlobalEnvFilePath())) return 'config.env (machine-wide default)';
666
+ return 'built-in default';
667
+ };
652
668
 
653
669
  return {
654
670
  mode: cfg.KNOWLEDGE_STORE_MODE,
655
- origin,
671
+ origin: originFor('PI_RESEARCH_KNOWLEDGE_STORE_MODE'),
672
+ retrieval: cfg.KNOWLEDGE_STORE_RETRIEVAL,
673
+ retrievalOrigin: originFor('PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL'),
656
674
  dbDir: getDbDir(cfg, cwd),
657
- ...availabilityFields(),
675
+ ...availabilityFields(cfg),
658
676
  };
659
677
  }
660
678
 
@@ -665,8 +683,8 @@ export function describeKnowledgeStoreMode(
665
683
  * throws: the probe catches resolution errors internally and reports them as
666
684
  * `available: false`.
667
685
  */
668
- function availabilityFields(): { available: boolean; unavailableReason: string } {
669
- const availability = probeKnowledgeStoreAvailability();
686
+ function availabilityFields(cfg?: Config): { available: boolean; unavailableReason: string } {
687
+ const availability = probeKnowledgeStoreAvailability(cfg?.KNOWLEDGE_STORE_RETRIEVAL ?? getConfig().KNOWLEDGE_STORE_RETRIEVAL);
670
688
  return {
671
689
  available: availability.available,
672
690
  unavailableReason: describeKnowledgeStoreUnavailability(availability),
@@ -924,6 +942,7 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
924
942
  PI_RESEARCH_WORKER_THREADS: String(config.WORKER_THREADS),
925
943
  PI_RESEARCH_WORKER_CONCURRENCY: String(config.WORKER_CONCURRENCY),
926
944
  PI_RESEARCH_KNOWLEDGE_STORE_MODE: config.KNOWLEDGE_STORE_MODE,
945
+ PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL: config.KNOWLEDGE_STORE_RETRIEVAL,
927
946
  PI_RESEARCH_EMBEDDING_MODEL: config.EMBEDDING_MODEL,
928
947
  PI_RESEARCH_EMBEDDING_DEVICE: config.EMBEDDING_DEVICE,
929
948
  PI_RESEARCH_SCRAPE_TIMEOUT_MS: String(config.SCRAPE_TIMEOUT_MS),
@@ -1147,6 +1166,7 @@ export function createConfig(env: Record<string, string | undefined>, processEnv
1147
1166
  WORKER_THREADS: parseEnvNumber(e, 'PI_RESEARCH_WORKER_THREADS', DEFAULTS.WORKER_THREADS, 1, 10, true),
1148
1167
  WORKER_CONCURRENCY: parseEnvNumber(e, 'PI_RESEARCH_WORKER_CONCURRENCY', DEFAULTS.WORKER_CONCURRENCY, 1, 10, true),
1149
1168
  KNOWLEDGE_STORE_MODE: parseEnvEnum(e, 'PI_RESEARCH_KNOWLEDGE_STORE_MODE', ['none', 'project', 'global'] as const, 'global'),
1169
+ KNOWLEDGE_STORE_RETRIEVAL: parseEnvEnum(e, 'PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL', ['vector', 'bm25'] as const, 'vector'),
1150
1170
  EMBEDDING_MODEL: parseEnvString(e, 'PI_RESEARCH_EMBEDDING_MODEL', DEFAULTS.EMBEDDING_MODEL)!,
1151
1171
  EMBEDDING_DEVICE: parseEnvEnum(e, 'PI_RESEARCH_EMBEDDING_DEVICE', ['auto', 'webgpu', 'cpu'] as const, DEFAULTS.EMBEDDING_DEVICE),
1152
1172
  SCRAPE_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_SCRAPE_TIMEOUT_MS', DEFAULTS.SCRAPE_TIMEOUT_MS, 5000, 120000),
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * - As a pi EXTENSION, the host supplies `@earendil-works/*`. Our package.json
9
9
  * range never constrains it, so an in-process check is the only enforcement.
10
- * - As a standalone CLI/SDK, the range is `>=0.85.0 <1` and published tarballs
10
+ * - As a standalone CLI/SDK, the range is `>=0.87.0 <1` and published tarballs
11
11
  * carry no lockfile, so every fresh install resolves the newest 0.x at that
12
12
  * instant. pi is pre-1.0: a minor bump may break anything under semver, and
13
13
  * already has (0.83.0 extended the ResourceLoader contract).
@@ -74,8 +74,17 @@ export function compareVersions(a: SemverParts, b: SemverParts): number {
74
74
  * indefinite-hang-in-network-restricted-environments bug that explicit
75
75
  * option originally existed to prevent. The floor moved to close that gap,
76
76
  * not just because the type changed.
77
+ *
78
+ * 0.86.0 changed the pi-ai provider stream input from Context to normalized
79
+ * TranscriptContext values, restricted ToolCall.arguments / ToolResultMessage
80
+ * details to JSON-compatible values, and made user_bash fail closed; 0.87.0
81
+ * added ContextEditEntry to the SessionEntry union, made SessionManager the
82
+ * canonical provider-context source, and replaced shouldStopAfterTurn with the
83
+ * finishTurn boundary. The SDK paths (session service, model-registry factory)
84
+ * are written against that surface; an older host may resolve but is not a
85
+ * tested or supported target.
77
86
  */
78
- export const PI_MIN_VERSION: SemverParts = { major: 0, minor: 85, patch: 0 };
87
+ export const PI_MIN_VERSION: SemverParts = { major: 0, minor: 87, patch: 0 };
79
88
 
80
89
  /**
81
90
  * Newest host line this release was actually exercised against (CI + a real run).
@@ -83,7 +92,7 @@ export const PI_MIN_VERSION: SemverParts = { major: 0, minor: 85, patch: 0 };
83
92
  * Compared on MAJOR.MINOR only: a patch bump within a tested line is not a new
84
93
  * surface, and warning on it would be noise.
85
94
  */
86
- export const PI_TESTED_MAX_VERSION: SemverParts = { major: 0, minor: 85, patch: 0 };
95
+ export const PI_TESTED_MAX_VERSION: SemverParts = { major: 0, minor: 87, patch: 0 };
87
96
 
88
97
  export type PiCompatibilityLevel = 'ok' | 'unparseable' | 'too-old' | 'untested';
89
98
 
@@ -196,8 +196,25 @@ export function registerHealthChecks(registry: IHealthRegistryService, container
196
196
  }
197
197
  const store = await service.getStore();
198
198
  const counts = store ? await store.countScoped() : { local: 0, global: 0, projects: 0 };
199
+ const retrieval = getConfig(service.getCwd()).KNOWLEDGE_STORE_RETRIEVAL;
199
200
  const embedder = await service.getEmbedder();
200
201
 
202
+ if (retrieval === 'bm25') {
203
+ // Lexical mode: no embedding model exists — null is the healthy state, not
204
+ // a failure. Report BM25-specific diagnostics instead of the embedder's.
205
+ return {
206
+ healthy: true,
207
+ diagnostic: {
208
+ status: 'initialized',
209
+ retrieval: 'bm25',
210
+ model: 'none (lexical BM25)',
211
+ localEntries: counts.local,
212
+ globalEntries: counts.global,
213
+ totalProjects: counts.projects
214
+ }
215
+ };
216
+ }
217
+
201
218
  if (!embedder) {
202
219
  return { healthy: false, error: 'Embedder service not available' };
203
220
  }
@@ -240,6 +257,7 @@ export function registerHealthChecks(registry: IHealthRegistryService, container
240
257
  healthy: true,
241
258
  diagnostic: {
242
259
  status: 'initialized',
260
+ retrieval,
243
261
  device,
244
262
  model: config.EMBEDDING_MODEL,
245
263
  localEntries: counts.local,
package/src/index.ts CHANGED
@@ -91,10 +91,11 @@ function extractResultText(result: AgentToolResult<unknown>): string {
91
91
  * Pi Research Extension
92
92
  */
93
93
  export default async function (pi: ExtensionAPI) {
94
- // Runtime version check — must match the @earendil-works/* dependency minimum (>=0.85.0).
94
+ // Runtime version check — must match the @earendil-works/* dependency minimum (>=0.87.0).
95
95
  // The floor was raised to 0.84.0 because setRuntimeApiKey() lost its `allowNetwork` option
96
96
  // there and only 0.84.0+ hardcodes the equivalent guard internally (see pi-version.ts), then
97
- // to 0.85.0 to track the pi extension package line. The
97
+ // to 0.85.0 to track the pi extension package line, and to 0.87.0 for the 0.86
98
+ // TranscriptContext / 0.87 SessionEntry-union surface (see pi-version.ts). The
98
99
  // APIs it rests on arrived in 0.80.8, which introduced ModelRuntime and removed
99
100
  // AuthStorage/ModelRegistry.create(); buildModelRegistry (model-registry-factory.ts)
100
101
  // unconditionally calls ModelRuntime.create(), and createAgentSession() is invoked
@@ -2699,6 +2699,13 @@ var ConfigSchema = Type.Object({
2699
2699
  WORKER_CONCURRENCY: Type.Number({ minimum: 1, maximum: 10, default: 2 }),
2700
2700
  /** Knowledge store isolation mode (default: 'global' — one shared store across every directory) */
2701
2701
  KNOWLEDGE_STORE_MODE: Type.Union([Type.Literal("none"), Type.Literal("project"), Type.Literal("global")], { default: "global" }),
2702
+ /** Knowledge store retrieval strategy (default: 'vector' — current hybrid dense+sparse behavior).
2703
+ * 'vector' = hybrid search (embedding similarity + BM25/FTS fused via RRF; requires the
2704
+ * optional @huggingface/transformers dependency and a downloaded embedding model).
2705
+ * 'bm25' = pure lexical/BM25 retrieval over the same stored findings — NO embedding model:
2706
+ * never initialized, downloaded, or invoked. Uses a separate lexical table in the same
2707
+ * LanceDB database, so existing vector stores are untouched (no migration). */
2708
+ KNOWLEDGE_STORE_RETRIEVAL: Type.Union([Type.Literal("vector"), Type.Literal("bm25")], { default: "vector" }),
2702
2709
  /** Embedding model to use for the knowledge store */
2703
2710
  EMBEDDING_MODEL: Type.String({ default: "onnx-community/granite-embedding-small-english-r2-ONNX" }),
2704
2711
  /** Hardware backend for embeddings: 'auto' (probe WebGPU viability out-of-process, fall back to CPU), 'webgpu' (force), or 'cpu' (force).
@@ -50,6 +50,10 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
50
50
  // mode change (project↔global re-scope, or enabled→none) and re-initialize so a /research-config
51
51
  // change applies without a Pi restart — not just the DISABLED→enabled revival.
52
52
  private _initializedMode: string | undefined = undefined;
53
+ // The retrieval strategy the current build reflects ('vector' hybrid / 'bm25'
54
+ // lexical) — drives the embedder-optional invariants below and the live
55
+ // re-init detection on a /research-config retrieval change.
56
+ private _initializedRetrieval: 'vector' | 'bm25' | undefined = undefined;
53
57
 
54
58
  // Initialization promise to prevent concurrent initialization
55
59
  private _initializationPromise: Promise<void> | null = null;
@@ -88,7 +92,12 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
88
92
  // - INITIALIZED and mode != the built mode : project↔global re-scope, or enabled→'none'.
89
93
  // A native-unavailable DISABLED ('native') stays memoized — retrying is futile on that platform.
90
94
  if ((this.lifecycle === ServiceLifecycle.INITIALIZED || this.lifecycle === ServiceLifecycle.DISABLED) && this._cwd === newCwd) {
91
- const liveMode: string = (ctx?.config || getConfig(this._cwd)).KNOWLEDGE_STORE_MODE;
95
+ const liveConfig = ctx?.config || getConfig(this._cwd);
96
+ const liveMode: string = liveConfig.KNOWLEDGE_STORE_MODE;
97
+ // Retrieval strategy ('vector' hybrid / 'bm25' lexical) participates in the same
98
+ // live-change detection: a /research-config switch must re-scope the store onto
99
+ // the OTHER table (lexical vs vector schema) without a Pi restart.
100
+ const liveRetrieval: string = liveConfig.KNOWLEDGE_STORE_RETRIEVAL;
92
101
  // Reviving a DISABLED store creates handles (no live state to tear down), so it is safe even
93
102
  // from a lazy call and uses the live config. Re-scoping/disabling a LIVE (INITIALIZED) store,
94
103
  // however, disposes handles — restrict that to an EXPLICIT ctx.config (mirrors the ctx.cwd
@@ -101,10 +110,15 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
101
110
  ctx?.config !== undefined &&
102
111
  this._initializedMode !== undefined &&
103
112
  this._initializedMode !== ctx.config.KNOWLEDGE_STORE_MODE;
104
- if (!reviveFromMode && !modeChanged) {
113
+ const retrievalChanged =
114
+ this.lifecycle === ServiceLifecycle.INITIALIZED &&
115
+ ctx?.config !== undefined &&
116
+ this._initializedRetrieval !== undefined &&
117
+ this._initializedRetrieval !== ctx.config.KNOWLEDGE_STORE_RETRIEVAL;
118
+ if (!reviveFromMode && !modeChanged && !retrievalChanged) {
105
119
  return;
106
120
  }
107
- logger.log(`[KnowledgeStoreService] Knowledge Mode changed (${this._initializedMode ?? this._disabledReason} → ${liveMode}); re-initializing store (no restart needed).`);
121
+ logger.log(`[KnowledgeStoreService] Knowledge config changed (${this._initializedMode ?? this._disabledReason} → ${liveMode}, retrieval ${this._initializedRetrieval ?? '?'} → ${liveRetrieval}); re-initializing store (no restart needed).`);
108
122
  // An INITIALIZED store holds live handles (embedder/LanceDB/writer) — dispose before rebuilding.
109
123
  if (this.lifecycle === ServiceLifecycle.INITIALIZED) {
110
124
  await this.dispose();
@@ -170,6 +184,7 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
170
184
  const config = ctx?.config || getConfig(this._cwd);
171
185
  // Record the mode this build reflects so a later runtime mode change is detected above.
172
186
  this._initializedMode = config.KNOWLEDGE_STORE_MODE;
187
+ this._initializedRetrieval = config.KNOWLEDGE_STORE_RETRIEVAL;
173
188
  const embedderFactory = () => getEmbedder(config);
174
189
  const reconnectFactory = async () => {
175
190
  // MUST await: clearEmbeddingInstance() only nulls the cached instance AFTER its
@@ -260,7 +275,7 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
260
275
  // calls don't re-run init, and so the healthcheck/config surfaces
261
276
  // report the package-level reason rather than a user choice the user
262
277
  // never made.
263
- const depsMissing = !probeKnowledgeStoreAvailability().available;
278
+ const depsMissing = !probeKnowledgeStoreAvailability(config.KNOWLEDGE_STORE_RETRIEVAL).available;
264
279
  logger.debug(`[KnowledgeStoreService] Knowledge store is disabled (${depsMissing ? 'dependencies missing' : 'mode = none'}). Setting lifecycle to DISABLED.`);
265
280
  this.lifecycle = ServiceLifecycle.DISABLED;
266
281
  this._disabledReason = depsMissing ? 'native' : 'mode';
@@ -431,6 +446,11 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
431
446
  * Check if the knowledge store is ready
432
447
  */
433
448
  isReady(): boolean {
449
+ // bm25 mode has no embedder by design — the store+writer pair is the readiness
450
+ // contract there. Vector mode keeps the original three-way check.
451
+ if (this._initializedRetrieval === 'bm25') {
452
+ return this._store !== null && this._writerQueue !== null;
453
+ }
434
454
  return this._embedder !== null && this._store !== null && this._writerQueue !== null;
435
455
  }
436
456
 
@@ -458,6 +478,11 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
458
478
  if (this.lifecycle === ServiceLifecycle.DISABLED) {
459
479
  return null;
460
480
  }
481
+ if (this._initializedRetrieval === 'bm25') {
482
+ // Lexical mode has no embedder — null is the CORRECT answer here, not an
483
+ // error. Callers must treat it as 'no embedding model configured'.
484
+ return null;
485
+ }
461
486
  if (!this._embedder) {
462
487
  throw new Error('[KnowledgeStoreService] Embedder not initialized');
463
488
  }