@lincoln504/pi-research 1.3.11 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5558 -4596
  3. package/dist/prompts/research-tool-usage.md +1 -1
  4. package/dist/prompts/researcher.md +1 -0
  5. package/dist/prompts/system-coordinator.md +1 -1
  6. package/dist/prompts/system-lead-router.md +122 -0
  7. package/dist/prompts/system-lead-synthesizer.md +46 -0
  8. package/dist/thread-worker.mjs +123 -30
  9. package/docs/ARCHITECTURE.md +69 -24
  10. package/docs/CHANGELOG.md +215 -0
  11. package/docs/CONFIGURATION.md +108 -8
  12. package/package.json +19 -11
  13. package/scripts/build.cjs +8 -1
  14. package/scripts/cleanup.cjs +26 -5
  15. package/scripts/setup.cjs +25 -1
  16. package/skills/pi-research/SKILL.md +1 -1
  17. package/src/cli.ts +41 -2
  18. package/src/config.ts +12 -8
  19. package/src/constants.ts +1 -1
  20. package/src/core/interfaces/observer-interfaces.ts +3 -3
  21. package/src/core/interfaces/orchestration-interfaces.ts +2 -0
  22. package/src/core/interfaces/planning-interfaces.ts +7 -1
  23. package/src/core/interfaces/scheduler-interfaces.ts +18 -2
  24. package/src/core/llm/agentic-repair.ts +7 -2
  25. package/src/core/llm/inject-date.ts +4 -1
  26. package/src/core/llm/llm-utils.ts +7 -0
  27. package/src/core/planning-service.ts +719 -91
  28. package/src/core/planning-utils.ts +21 -5
  29. package/src/core/service-initialization.ts +19 -3
  30. package/src/core/service-registry.ts +19 -3
  31. package/src/healthcheck/index.ts +11 -2
  32. package/src/index.ts +4 -3
  33. package/src/infrastructure/browser/browser-client.ts +68 -34
  34. package/src/infrastructure/browser/browser-task-scheduler.ts +220 -34
  35. package/src/infrastructure/browser/config.ts +77 -6
  36. package/src/infrastructure/browser/priority-task-queue.ts +55 -1
  37. package/src/infrastructure/browser/scheduler-factory.ts +12 -1
  38. package/src/infrastructure/browser/task-execution-service.ts +17 -7
  39. package/src/infrastructure/browser/thread-worker-browser.ts +66 -20
  40. package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
  41. package/src/infrastructure/browser/thread-worker.mjs +123 -30
  42. package/src/infrastructure/browser/thread-worker.ts +2 -2
  43. package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
  44. package/src/infrastructure/embedding/embedding-client.ts +14 -4
  45. package/src/infrastructure/embedding/embedding-factory.ts +83 -15
  46. package/src/infrastructure/file-lock-service.ts +214 -19
  47. package/src/infrastructure/gpu-resource-service.ts +15 -1
  48. package/src/infrastructure/knowledge-store-service.ts +14 -0
  49. package/src/infrastructure/research-run-semaphore.ts +1 -1
  50. package/src/infrastructure/service-initialization.ts +7 -0
  51. package/src/knowledge/embedder-init.ts +60 -18
  52. package/src/knowledge/store.ts +11 -5
  53. package/src/logger.ts +11 -0
  54. package/src/orchestration/deep-research-orchestrator.ts +141 -87
  55. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  56. package/src/orchestration/research-orchestration-service.ts +30 -1
  57. package/src/orchestration/research-synthesis-service.ts +53 -1
  58. package/src/orchestration/researcher-executor.ts +53 -2
  59. package/src/orchestration/researcher.ts +15 -2
  60. package/src/prompts/research-tool-usage.md +1 -1
  61. package/src/prompts/researcher.md +1 -0
  62. package/src/prompts/system-coordinator.md +1 -1
  63. package/src/prompts/system-lead-router.md +122 -0
  64. package/src/prompts/system-lead-synthesizer.md +46 -0
  65. package/src/sdk.ts +10 -1
  66. package/src/security/nvd-client.ts +55 -4
  67. package/src/security/osv-client.ts +13 -1
  68. package/src/tools/research-knowledge-search.ts +29 -4
  69. package/src/tools/scrape.ts +6 -0
  70. package/src/tools/search.ts +7 -0
  71. package/src/tools/security.ts +27 -9
  72. package/src/tools/stackexchange.ts +13 -7
  73. package/src/tools/youtube-transcript.ts +26 -12
  74. package/src/utils/cancellation.ts +36 -0
  75. package/src/utils/coverage-digest.ts +204 -0
  76. package/src/utils/llm-usage.ts +33 -6
  77. package/src/utils/log-utils.ts +16 -1
  78. package/src/utils/metrics-summary.ts +20 -4
  79. package/src/utils/metrics.ts +27 -1
  80. package/src/web-research/browser-search.ts +93 -20
  81. package/src/web-research/scraper-utils.ts +55 -14
  82. package/dist/prompts/system-lead-evaluator.md +0 -118
  83. package/src/prompts/system-lead-evaluator.md +0 -118
@@ -74,12 +74,12 @@ Research
74
74
  | `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
75
75
  | `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
76
76
  | `PI_RESEARCH_MAX_FAILED_RESEARCHERS` | `2` | 1–10 | Unique researcher failures that abort the whole run. Raise to let slower, still-in-flight researchers finish before giving up. |
77
- | `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. |
77
+ | `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. It sets how *fast* a search burst drains, not how large a burst may be — a burst larger than the pool waits its turn rather than being trimmed or timing out. |
78
78
  | `PI_RESEARCH_WORKER_CONCURRENCY` | `2` | 1–10 | Tasks per worker process. |
79
79
  | `PI_RESEARCH_MAX_CONCURRENT_RUNS` | `3` | ≥1 | Machine-wide cap on research runs executing at the same time, across **every** process (CLI, agent skill, pi extension, SDK). Runs beyond the cap queue rather than fail. All concurrent runs share one leader-elected browser/embedding pool, so oversubscribing it degrades every run at once. |
80
80
  | `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` | `600000` | ≥0 | How long a run queues for a free slot before failing with "maximum concurrent research runs reached" (CLI exit `75`). `0` = fail immediately instead of queueing. |
81
- | `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and evaluator keep using the session model. Accepts `provider/id` or a bare model id. |
82
- | `PI_RESEARCH_DISABLED_TOOLS` | _(none)_ | — | Comma-separated research tools to disable for a run (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `grep`). Removed from every researcher's toolset and named in the coordinator/evaluator prompt. Strictly additive — it can only remove capabilities, never grant one, and it stacks on top of the default exclusions rather than replacing them. |
81
+ | `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and research lead keep using the session model. Accepts `provider/id` or a bare model id. |
82
+ | `PI_RESEARCH_DISABLED_TOOLS` | _(none)_ | — | Comma-separated research tools to disable for a run (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `grep`, `read`). Removed from every researcher's toolset and named in the coordinator and research-lead prompts. Strictly additive — it can only remove capabilities, never grant one, and it stacks on top of the default exclusions rather than replacing them. An unrecognized name excludes nothing and is warned about rather than failing the run. |
83
83
  | `PI_RESEARCH_REPORT_EXPORT_ENABLED` (TUI) | `false` | — | Front-ends write a Markdown report to disk and surface its path. |
84
84
  | `PI_RESEARCH_REPORT_EXPORT_DIR` | _(smart cwd)_ | — | Pin exported reports to a fixed directory, bypassing the cwd-relative resolution. Useful for the agent skill, which runs from the host agent's arbitrary directory. |
85
85
  | `PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING` | `0.15` | 0.05–1.0 | Max fraction of the context window used for initial scrape context. |
@@ -99,10 +99,10 @@ Timeouts
99
99
 
100
100
  | Variable | Default | Range | Description |
101
101
  |----------|---------|-------|-------------|
102
- | `PI_RESEARCH_LLM_TIMEOUT_MS` | `300000` | 60000–1800000 | Coordinator / evaluator / repair / knowledge LLM call timeout. |
102
+ | `PI_RESEARCH_LLM_TIMEOUT_MS` | `300000` | 60000–1800000 | Coordinator / research-lead / repair / knowledge LLM call timeout. |
103
103
  | `PI_RESEARCH_SCRAPE_TIMEOUT_MS` | `15000` | 5000–120000 | Per-page scrape (page-load) timeout. |
104
104
  | `PI_RESEARCH_SEARCH_TIMEOUT_MS` | `45000` | 5000–120000 | Browser search page timeout. |
105
- | `PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS` | `10000` | 2000–120000 | Queue-wait / overhead margin added to each browser op's own timeout (a search task ceiling is `SEARCH_TIMEOUT_MS` + this; a scrape is `SCRAPE_TIMEOUT_MS` + this). |
105
+ | `PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS` | `10000` | 2000–120000 | Overhead margin added to each browser op's own timeout (a search task ceiling is `SEARCH_TIMEOUT_MS` + this; a scrape is `SCRAPE_TIMEOUT_MS` + this). These ceilings bound **execution**: a task's clock starts when a worker picks it up, so time spent queued behind other work is not charged against it and raising this to cover a busy pool is neither needed nor effective. |
106
106
  | `PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS` | `10000` | 2000–120000 | Pre-flight health-check timeout. |
107
107
 
108
108
  LLM output & reasoning
@@ -111,8 +111,8 @@ These are advanced, env-only knobs (not in the TUI).
111
111
 
112
112
  | Variable | Default | Range | Description |
113
113
  |----------|---------|-------|-------------|
114
- | `PI_RESEARCH_LLM_THINKING_LEVEL` | `off` | off · minimal · low · medium · high | Chain-of-thought level for all engine LLM work (coordinator, evaluator, synthesis, JSON-repair, knowledge extraction, and researcher sub-agents). Off by default — these calls emit structured JSON / cited reports, so a thinking block only consumes the output budget and can truncate the answer. Clamped per model by pi. |
115
- | `PI_RESEARCH_PLANNING_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens for the plan + mid-round evaluator decision. Clamped to the model's real ceiling. |
114
+ | `PI_RESEARCH_LLM_THINKING_LEVEL` | `off` | off · minimal · low · medium · high | Chain-of-thought level for all engine LLM work (coordinator, router, synthesizer, JSON-repair, knowledge extraction, and researcher sub-agents). Off by default — these calls emit structured JSON / cited reports, so a thinking block only consumes the output budget and can truncate the answer. Clamped per model by pi. |
115
+ | `PI_RESEARCH_PLANNING_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens for the coordinator's plan. The router's decision has its own, smaller ceiling; the final synthesis uses `PI_RESEARCH_SYNTHESIS_MAX_TOKENS`. Clamped to the model's real ceiling. |
116
116
  | `PI_RESEARCH_SYNTHESIS_MAX_TOKENS` | `32768` | 1024–131072 | Max output tokens for the final synthesized report. Clamped to the model's real ceiling. |
117
117
 
118
118
  Knowledge store
@@ -156,7 +156,8 @@ Diagnostics & platform
156
156
  | `PI_RESEARCH_SKILL_DIR` | _(auto)_ | Override the bundled research-skill source directory used by the skill installer. |
157
157
  | `PI_RESEARCH_PURGE_BROWSERS` | _(unset)_ | Read by the bundled `scripts/cleanup.cjs`: set `1` to also delete the shared camoufox browser cache (kept by default because other installs may use it). Note npm ≥7 does not run `preuninstall`, so that script does not fire on `npm uninstall` — see [AGENT-SKILL.md](AGENT-SKILL.md). |
158
158
  | `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` | _(unset)_ | Set `1` during `npm install` to skip the camoufox browser download (fetched lazily on first use instead; standard Playwright convention). |
159
- | `PLAYWRIGHT_BROWSERS_PATH` | _(user cache)_ | Standard Playwright variable, honoured here too: overrides where the camoufox binary is stored and looked up, and is passed through to browser workers. |
159
+ | `CAMOUFOX_INSTALL_DIR` | _(user cache)_ | camoufox-js's own variable, and the only one that can relocate the browser. Sets where pi-research looks the binary up, and is exported to the postinstall fetch and to browser workers. **Not effective on the currently pinned camoufox-js (0.10.x), which hardcodes its cache directory and honours no variable** — so relocating the browser is unsupported until that pin can advance (see ARCHITECTURE.md). |
160
+ | `PLAYWRIGHT_BROWSERS_PATH` | _(user cache)_ | Alias for the above, accepted for compatibility and exported onward as `CAMOUFOX_INSTALL_DIR`. Same caveat: on the pinned camoufox-js it moves only where pi-research looks, not where the download lands, so setting it reports the browser missing from the custom path after a successful install. |
160
161
  | `XDG_CACHE_HOME` | `~/.cache` | Standard XDG variable. When set, every `~/.cache/pi-research/...` path below is rooted at `$XDG_CACHE_HOME/pi-research/...` instead. |
161
162
  | `PI_RESEARCH_BIN` (alias `PI_RESEARCH_PATH`) | _(auto)_ | Agent-skill launcher only: explicit path to the pi-research engine binary when auto-resolution (PATH → local install → npx) should be bypassed. See [skills/pi-research/references/configuration.md](../skills/pi-research/references/configuration.md). |
162
163
  | `PLAYWRIGHT_INSTALL_DEPS` | _(unset)_ | Linux only. Set `true` during `npm install` to also install system libraries via `npx playwright install-deps` (same as `npm run install:system-deps`). |
@@ -238,6 +239,105 @@ just export the variable for that process.
238
239
  > `/research-config` TUI (which writes it), export variables in your shell, or use a
239
240
  > loader such as direnv. `.env.example` is a reference, not an active config file.
240
241
 
242
+ ### Prompt caching
243
+
244
+ A research run re-sends a large, mostly-unchanged prompt many times: a researcher
245
+ re-sends its whole conversation (including every scraped page) on each tool turn, and
246
+ the research lead used to re-send the accumulated findings on every round. Every provider will
247
+ serve that repetition from a prompt cache at a fraction of the input price — but only
248
+ for an **exact prefix** of the request, and only if nothing that varies sits in front
249
+ of it.
250
+
251
+ What pi-research does about it. The research lead is split into two roles so that the
252
+ repetition is removed rather than merely discounted. The **router** decides each round
253
+ whether to continue, reading each report in full on the one round it arrives and only its
254
+ short coverage digest thereafter, instead of re-reading every report collected so far. The **synthesizer** runs once, at the end, and is the only
255
+ call that reads the reports in full. Before the split, one call did both jobs and re-sent
256
+ the whole corpus every round, so its input grew with the square of the round count. The
257
+ synthesizer's corpus is also budgeted against the model's context window: over budget, the
258
+ reports are reduced in partial passes and merged rather than truncated or rejected.
259
+
260
+ How often the router runs depends on the round budget. Depth 2 and 3 route on every round
261
+ but the last. Depth 1's base budget is two rounds and the last one is skipped, so a plain
262
+ depth-1 run never routes — but steering raises the budget (up to two extra rounds), and a
263
+ steered depth-1 run does route. That is also the depth-1 case carrying the most findings,
264
+ so it is where routing on digests rather than on the full corpus matters most.
265
+
266
+ On top of that, both lead prompts are laid out stable-part-first: they interpolate only
267
+ values fixed for the whole run (complexity, team size, query budget, disabled tools), and
268
+ everything that changes between rounds — root query, round number, agenda, executed
269
+ queries, steering, round-phase guidance — is appended afterwards as a `RUN CONTEXT`
270
+ block. Unit tests hold the layout in place; if you edit
271
+ `src/prompts/system-lead-router.md` or `src/prompts/system-lead-synthesizer.md`, keep
272
+ run-varying text out of them.
273
+
274
+ What your provider does about it depends on which API pi talks to (the provider's `api`
275
+ field in `~/.pi/agent/models.json`) and, on OpenAI-compatible endpoints, on one `compat`
276
+ key:
277
+
278
+ | Provider API | Behaviour |
279
+ |--------------|-----------|
280
+ | `anthropic-messages` | pi inserts `cache_control` breakpoints automatically — on the system prompt, the last tool definition, and the last user/assistant/tool-result block. Nothing to configure. |
281
+ | `openai-completions` | pi inserts the **same** Anthropic-style breakpoints into the OpenAI-shaped payload whenever `compat.cacheControlFormat` is `"anthropic"`. Auto-detection sets that key for exactly one case — OpenRouter routing to an `anthropic/*` model — so by default no markers are sent and the provider's own implicit prefix caching is relied on. That default is correct for OpenAI, DeepSeek, Gemini and GLM, which all cache implicitly. |
282
+ | `openai-responses` | No markers at any setting; implicit caching only. |
283
+
284
+ Note that the marker behaviour is gated on `cacheControlFormat`, **not** on the `api`
285
+ field — setting the key makes an `openai-completions` provider emit explicit breakpoints
286
+ exactly like an `anthropic-messages` one. That matters when reading the counters below:
287
+ on a provider you have configured this way, a zero cache read is not evidence that
288
+ markers are missing.
289
+
290
+ The gap is providers that need explicit markers but are reached over an OpenAI-compatible
291
+ endpoint and are not the one auto-detected case. Anything in that category (Qwen through
292
+ OpenRouter, or any gateway fronting Claude) caches **nothing at all**, silently and with
293
+ no error. Fix it per provider with a `compat` block:
294
+
295
+ ```json
296
+ {
297
+ "providers": {
298
+ "openrouter": {
299
+ "api": "openai-completions",
300
+ "compat": {
301
+ "cacheControlFormat": "anthropic",
302
+ "sessionAffinityFormat": "openrouter",
303
+ "sendSessionAffinityHeaders": true
304
+ }
305
+ }
306
+ }
307
+ }
308
+ ```
309
+
310
+ `cacheControlFormat: "anthropic"` forces the markers on. It is safe to set for a whole
311
+ provider: models that do not use markers ignore the extra field. The two session-affinity
312
+ keys ask OpenRouter to keep a conversation on the replica that warmed its cache; without
313
+ them OpenRouter falls back to hashing the first messages, which an agentic loop mutates.
314
+
315
+ `PI_CACHE_RETENTION=long` (a pi variable, not a pi-research one) requests 1-hour
316
+ retention where the provider supports it. It raises the cache-write multiplier from
317
+ 1.25x to 2x, so it only pays off when a run's gaps between calls exceed the provider's
318
+ default window — nominally 5 minutes.
319
+
320
+ Measure before enabling it. On a direct probe against GLM over `anthropic-messages`, an
321
+ entry was still read back unchanged after **seven minutes at the default retention**, and
322
+ `long` produced an identical number — so on that route the doubled write multiplier buys
323
+ nothing. The nominal 5-minute figure is a floor providers are free to exceed, not a
324
+ deadline you can plan around.
325
+
326
+ Verifying it works. Set `PI_RESEARCH_DEBUG=true` and read the run log: every LLM call
327
+ records `llm_cache_read_tokens_total` and `llm_cache_write_tokens_total` alongside
328
+ `llm_tokens_total`, labelled by component (`coordinator` / `router` / `synthesizer` /
329
+ `researcher`).
330
+ Cache reads that stay at zero on a multi-turn researcher mean the prefix is not being
331
+ matched — usually a provider with a minimum cacheable prefix (commonly 1024–4096 tokens)
332
+ that short prompts never reach, or an explicit-marker provider missing the `compat` block
333
+ above. A counter that is absent rather than zero means the provider does not report
334
+ caching at all.
335
+
336
+ Cache **writes** are the weaker signal of the two. Several providers report reads and
337
+ omit writes entirely — OpenRouter and Z.ai's Anthropic-compatible endpoint both return
338
+ non-zero `cache_read` alongside a flat zero `cache_write` — so a zero there is normal and
339
+ says nothing about whether caching is working. Judge it by the read counter.
340
+
241
341
  ### Where files live
242
342
 
243
343
  All pi-research state lives under its own namespace, `~/.pi/research/`:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.3.11",
4
- "description": "Free unlimited web search and deep research for the Pi coding agent. Local knowledge store. Also works from Claude Code, Codex, and OpenClaw.",
3
+ "version": "1.4.1",
4
+ "description": "Web research for your agents with smart and safe tooling + knowledge store",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "main": "./src/index.ts",
@@ -53,19 +53,27 @@
53
53
  "pi-package",
54
54
  "pi-extension",
55
55
  "pi-coding-agent",
56
+ "claude-code",
56
57
  "openclaw",
58
+ "codex",
59
+ "agent-skills",
57
60
  "agent-skill",
58
61
  "skills",
59
- "research",
62
+ "web-search",
60
63
  "web-research",
61
64
  "deep-research",
62
- "agent",
63
- "web-search",
65
+ "ai-research",
66
+ "research",
67
+ "web-scraping",
64
68
  "scraping",
65
- "knowledge-store",
66
- "duckduckgo",
69
+ "browser-automation",
67
70
  "stealth-browser",
68
- "camoufox"
71
+ "camoufox",
72
+ "duckduckgo",
73
+ "knowledge-store",
74
+ "llm-tools",
75
+ "web-access",
76
+ "agent"
69
77
  ],
70
78
  "author": "Lincoln Deen",
71
79
  "contributors": [
@@ -120,7 +128,7 @@
120
128
  "@earendil-works/pi-tui": ">=0.84.0 <1",
121
129
  "@huggingface/transformers": "4.2.0",
122
130
  "@kreuzberg/html-to-markdown-node": "^3.5.5",
123
- "@lancedb/lancedb": "0.29.0",
131
+ "@lancedb/lancedb": "0.37.1",
124
132
  "apache-arrow": "21.1.0",
125
133
  "bgutils-js": "^3.2.0",
126
134
  "camoufox-js": "^0.10.2",
@@ -133,7 +141,7 @@
133
141
  "poolifier": "^5.3.2",
134
142
  "typebox": "1.3.7",
135
143
  "undici": "^8.9.0",
136
- "youtubei.js": "^17.2.0"
144
+ "youtubei.js": "^18.0.0"
137
145
  },
138
146
  "optionalDependencies": {
139
147
  "webgpu": "^0.4.0"
@@ -154,7 +162,7 @@
154
162
  },
155
163
  "devDependencies": {
156
164
  "@eslint/js": "^10.0.1",
157
- "@types/jsdom": "^28.0.3",
165
+ "@types/jsdom": "^30.0.0",
158
166
  "@typescript-eslint/eslint-plugin": "^8.65.0",
159
167
  "@typescript-eslint/parser": "^8.65.0",
160
168
  "@vitest/coverage-v8": "^4.1.10",
package/scripts/build.cjs CHANGED
@@ -87,9 +87,16 @@ function copyThreadWorker() {
87
87
  // 'prompts')), so the templates must sit next to the bundle.
88
88
  function copyPrompts() {
89
89
  fs.mkdirSync(p('dist', 'prompts'), { recursive: true });
90
- for (const f of fs.readdirSync(p('src', 'prompts'))) {
90
+ const sources = fs.readdirSync(p('src', 'prompts'));
91
+ for (const f of sources) {
91
92
  fs.copyFileSync(p('src', 'prompts', f), p('dist', 'prompts', f));
92
93
  }
94
+ // Prune prompts that no longer exist in src. Copying alone leaves a deleted or renamed
95
+ // prompt behind in an incremental dist forever; verify-package rejects the orphan at
96
+ // publish time, which surfaces as a confusing failure long after the rename.
97
+ for (const f of fs.readdirSync(p('dist', 'prompts'))) {
98
+ if (!sources.includes(f)) fs.rmSync(p('dist', 'prompts', f));
99
+ }
93
100
  }
94
101
 
95
102
  const TARGETS = {
@@ -197,13 +197,34 @@ const dirName = rawDirName && !rawDirName.includes('/') && !rawDirName.includes(
197
197
  * several tools' state by name); a substring match would treat that shared
198
198
  * folder as exclusively ours and wholesale-delete it. Only the exact default
199
199
  * layout is something WE constructed and can therefore prove is ours.
200
+ *
201
+ * Segment-adjacency is NOT that proof either, and used to be what this did:
202
+ * it tested whether `<dirName>/research` appeared ANYWHERE in the path, with no
203
+ * constraint on what followed. That also matched:
204
+ * - the research ROOT itself (`~/.pi/research`) — which holds config.env and
205
+ * knowledge_db/, the two things this script's header promises to preserve;
206
+ * - any unrelated directory beneath it (`/mnt/backup/.pi/research/mystate`);
207
+ * - traversal forms (`~/.pi/research/../../..`) that resolve far outside our
208
+ * tree entirely.
209
+ *
210
+ * The real proof is the full default *shape*: the runtime only ever builds
211
+ * `<root>/<dirName>/research/state`. Requiring those three trailing segments on
212
+ * the RESOLVED path keeps a relocated-but-default layout reclaimable while
213
+ * refusing the research root, sibling directories, and traversal escapes.
200
214
  */
215
+ const DEFAULT_STATE_TAIL = [dirName, 'research', 'state'];
216
+
201
217
  function isPiResearchNamespaced(p) {
202
- const segs = p.split(/[\\/]+/).filter(Boolean);
203
- for (let i = 0; i < segs.length - 1; i++) {
204
- if (segs[i] === dirName && segs[i + 1] === 'research') return true;
205
- }
206
- return false;
218
+ // Resolve first so `..` cannot smuggle the tail past this check.
219
+ const resolved = path.resolve(p);
220
+ // Windows and macOS default to case-insensitive filesystems, so a path
221
+ // differing only in case still names the directory we built.
222
+ const fold = (s) =>
223
+ process.platform === 'win32' || process.platform === 'darwin' ? s.toLowerCase() : s;
224
+ const segs = resolved.split(/[\\/]+/).filter(Boolean);
225
+ if (segs.length < DEFAULT_STATE_TAIL.length) return false;
226
+ const tail = segs.slice(-DEFAULT_STATE_TAIL.length);
227
+ return DEFAULT_STATE_TAIL.every((want, i) => fold(tail[i]) === fold(want));
207
228
  }
208
229
 
209
230
  /**
package/scripts/setup.cjs CHANGED
@@ -47,8 +47,25 @@ function resolveCamoufoxBin() {
47
47
  return null;
48
48
  }
49
49
 
50
+ /**
51
+ * The custom camoufox location, if the user set one. Mirrors
52
+ * getCustomCamoufoxDir() in src/infrastructure/browser/config.ts — the two must
53
+ * agree or install and lookup land in different directories.
54
+ *
55
+ * CAMOUFOX_INSTALL_DIR first: it is camoufox-js's own variable and the only one
56
+ * that can relocate the install (camoufox-js <0.12.0 hardcodes
57
+ * userCacheDir("camoufox") and honours nothing). PLAYWRIGHT_BROWSERS_PATH is
58
+ * kept as the documented alias and is exported BELOW as CAMOUFOX_INSTALL_DIR.
59
+ * NOTE: inert on the pinned camoufox-js 0.10.x, which reads neither — this is
60
+ * forward-compatible plumbing, not a live fix. See src/infrastructure/browser/
61
+ * config.ts and thread-worker-browser.ts.
62
+ */
63
+ function customCamoufoxDir() {
64
+ return process.env.CAMOUFOX_INSTALL_DIR || process.env.PLAYWRIGHT_BROWSERS_PATH || null;
65
+ }
66
+
50
67
  function camoufoxCachePath() {
51
- const customPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
68
+ const customPath = customCamoufoxDir();
52
69
  if (customPath) return customPath;
53
70
 
54
71
  if (isWindows) {
@@ -68,6 +85,13 @@ if (process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === '1') {
68
85
  console.log('pi-research: skipping browser download (PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1)');
69
86
  } else {
70
87
  const env = { ...process.env };
88
+ // Make the fetch honour the custom location. Without this the download always
89
+ // went to camoufox's default cache while camoufoxCachePath() reported the
90
+ // custom one: the "already installed" probe never matched, every install
91
+ // re-fetched, and at runtime the browser was reported missing despite having
92
+ // downloaded successfully.
93
+ const customDir = customCamoufoxDir();
94
+ if (customDir) env.CAMOUFOX_INSTALL_DIR = customDir;
71
95
 
72
96
  const installDeps = process.argv.includes('--system-deps') || process.env.PLAYWRIGHT_INSTALL_DEPS === 'true';
73
97
  if (installDeps && isLinux) {
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.3.11", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.4.1", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/src/cli.ts CHANGED
@@ -45,6 +45,7 @@ import { getConfig, getGlobalConfigDir, getGlobalEnvFilePath, getInterfaceEnvFil
45
45
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
46
46
  import { buildModelRegistry, safeGetAvailable } from './core/llm/model-registry-factory.ts';
47
47
  import { RESEARCH_TOOL_NAMES } from './constants.ts';
48
+ import { isBrowserAvailable } from './infrastructure/browser/config.ts';
48
49
 
49
50
  // ---------------------------------------------------------------------------
50
51
  // Constants
@@ -167,7 +168,11 @@ function makeProgressObserver(): HeadlessObserverOptions {
167
168
  toStderr(` • round ${data?.round}\n`);
168
169
  break;
169
170
  case 'search_start':
170
- toStderr(` • search burst: ${data?.queries?.length ?? 0} query/${(data?.queries?.length ?? 0) === 1 ? '' : 's'}\n`);
171
+ // "N queries", not "N query/s" — the old `query/${n===1?'':'s'}` form rendered a
172
+ // COUNT as what reads unmistakably as a RATE ("45 query/s"), so a burst that was
173
+ // in fact 45 queries looked like 45 per second and then appeared frozen. It also
174
+ // emitted a bare trailing slash ("1 query/") in the singular.
175
+ toStderr(` • search burst: ${data?.queries?.length ?? 0} ${(data?.queries?.length ?? 0) === 1 ? 'query' : 'queries'}\n`);
171
176
  break;
172
177
  case 'researcher_start':
173
178
  toStderr(` • researcher ${data?.id}: ${data?.name}\n`);
@@ -770,6 +775,18 @@ async function cmdStatus(json?: boolean): Promise<number> {
770
775
  const det = await detectCredentials();
771
776
  const paths = resolvedConfigPaths();
772
777
  const cfg = getConfig(process.cwd(), 'cli');
778
+ // The stealth browser is installed by this package's `postinstall`
779
+ // (scripts/setup.cjs), which npm 11.19+ BLOCKS BY DEFAULT — it now warns
780
+ // "packages have install scripts not yet covered by allowScripts" and carries
781
+ // on, so the browser is simply absent on an otherwise successful install.
782
+ // Every other native artifact survives that (onnxruntime, sharp, esbuild and
783
+ // webgpu all ship prebuilt platform packages), which is exactly what makes
784
+ // this one worth reporting: nothing else fails, and without this the first
785
+ // symptom is a scrape falling back mid-run. A missing browser degrades
786
+ // capability rather than breaking the tool — the fetch path still serves most
787
+ // pages — so it is reported alongside `ready` rather than folded into it.
788
+ // isBrowserAvailable() is a resolve + existsSync; it never launches anything.
789
+ const browserInstalled = isBrowserAvailable();
773
790
  const summary = {
774
791
  package: '@lincoln504/pi-research',
775
792
  version: PKG_VERSION,
@@ -783,6 +800,14 @@ async function cmdStatus(json?: boolean): Promise<number> {
783
800
  modelFrom: det.modelFrom,
784
801
  problem: det.problem ?? null,
785
802
  },
803
+ browser: {
804
+ installed: browserInstalled,
805
+ // Same remedy the health check prints, so both surfaces name one command.
806
+ fix: browserInstalled ? null : 'npx camoufox-js fetch',
807
+ note: browserInstalled
808
+ ? null
809
+ : 'Stealth browser not installed — scraping falls back to plain fetch, which some sites block. npm 11.19+ blocks install scripts by default, which skips this package\'s postinstall.',
810
+ },
786
811
  knowledgeStoreMode: cfg.KNOWLEDGE_STORE_MODE,
787
812
  defaultDepth: cfg.DEFAULT_RESEARCH_DEPTH,
788
813
  paths,
@@ -796,6 +821,10 @@ async function cmdStatus(json?: boolean): Promise<number> {
796
821
  toStdout(`pi-research ${PKG_VERSION}\n`);
797
822
  toStdout(`ready: ${summary.ready ? 'yes' : 'no'}\n\n`);
798
823
  toStdout(configBlock(det, summary.ready ? undefined : '— fix the problem above, then re-run.') + '\n');
824
+ if (!summary.browser.installed) {
825
+ toStdout(`\nstealth browser: NOT INSTALLED — run: ${summary.browser.fix}\n`);
826
+ toStdout(` ${summary.browser.note}\n`);
827
+ }
799
828
  toStdout(`\nknowledge store mode: ${cfg.KNOWLEDGE_STORE_MODE} (default depth: ${cfg.DEFAULT_RESEARCH_DEPTH})\n`);
800
829
  return EXIT.OK;
801
830
  }
@@ -1064,7 +1093,17 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
1064
1093
  // --json: emit a structured error on stdout so a machine consumer never has to
1065
1094
  // special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
1066
1095
  if (json) {
1067
- const payload: Record<string, unknown> = { ok: false, error: msg, exitCode };
1096
+ // A cancellation reports itself as a cancellation, not as whatever component
1097
+ // happened to notice the abort first. The internal message is genuinely
1098
+ // misleading here — a Ctrl-C unwinding through the planner surfaces as
1099
+ // "Coordinator failed: Request was aborted", so a consumer relaying `error`
1100
+ // verbatim (which SKILL.md instructs agents to do) tells the user a component
1101
+ // broke when they simply stopped the run. The plain-text branch below already
1102
+ // refuses to say "failed" for a cancel; JSON said it anyway. The original
1103
+ // wording is preserved under `cause` so nothing diagnostic is lost.
1104
+ const payload: Record<string, unknown> = isCancellation
1105
+ ? { ok: false, error: `pi-research ${what} cancelled.`, cause: msg, exitCode }
1106
+ : { ok: false, error: msg, exitCode };
1068
1107
  // Machine-readable "this will succeed later" flag, so a consumer can back off
1069
1108
  // and retry without string-matching the human message. Gated on cancellation:
1070
1109
  // a Ctrl-C landing in the same window a queue-wait expires must never emit
package/src/config.ts CHANGED
@@ -88,7 +88,7 @@ export const ConfigSchema = Type.Object({
88
88
  * never killed before it has used its full nav budget plus time waiting in the queue.
89
89
  * (default: 10000) */
90
90
  BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2000, maximum: 120000, default: 10000 }),
91
- /** Timeout for coordinator/evaluator/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
91
+ /** Timeout for coordinator/research-lead/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
92
92
  * Not exposed in TUI — controlled via PI_RESEARCH_LLM_TIMEOUT_MS env var.
93
93
  * Ceiling raised to 30min (matching RESEARCHER_TIMEOUT_MS): a slow model on a
94
94
  * large-context synthesis/plan call can legitimately run >10min, and the old
@@ -96,7 +96,7 @@ export const ConfigSchema = Type.Object({
96
96
  * (e.g. 900000), causing those calls to time out mid-generation. */
97
97
  LLM_TIMEOUT_MS: Type.Number({ minimum: 60000, maximum: 1800000, default: 300000 }),
98
98
  /** Chain-of-thought "thinking" level for the engine's own LLM calls (coordinator,
99
- * evaluator, synthesis, JSON-repair, knowledge extraction) AND the researcher
99
+ * router, synthesizer, JSON-repair, knowledge extraction) AND the researcher
100
100
  * sub-agents. Default 'off': these calls emit structured JSON / cited reports, not
101
101
  * open-ended reasoning, so thinking only burns the output-token budget (often
102
102
  * truncating before the JSON/report block) and adds latency for at most a marginal
@@ -111,22 +111,26 @@ export const ConfigSchema = Type.Object({
111
111
  Type.Literal('medium'),
112
112
  Type.Literal('high'),
113
113
  ], { default: 'off' }),
114
- /** Max output tokens for the coordinator plan + mid-round evaluator decision (and,
115
- * when the evaluator synthesizes mid-round, the report). Default 16384. The blanket
114
+ /** Max output tokens for the coordinator's round-1 plan. Default 16384. The routing
115
+ * decision has its OWN, much smaller ceiling (it emits a decision, never a report,
116
+ * and cannot synthesize mid-round under the split protocol); the final report uses
117
+ * SYNTHESIS_MAX_TOKENS below. The blanket
116
118
  * 4096 cap previously throttled every call and could be exhausted by a thinking block
117
119
  * before any text was emitted. Capped at the model's real ceiling at call time.
118
120
  * Not exposed in TUI — controlled via PI_RESEARCH_PLANNING_MAX_TOKENS env var. */
119
121
  PLANNING_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
120
- /** Max output tokens for the final synthesized research report (the forced-synthesis
121
- * evaluator call). Default 32768 so a full cited report is never truncated. Capped at
122
+ /** Max output tokens for the final synthesized research report (the synthesizer call,
123
+ * and each pass of a reduce when the corpus does not fit in one). Default 32768 so a
124
+ * full cited report is never truncated. It is also the reserve subtracted from the
125
+ * model's context window when budgeting that corpus. Capped at
122
126
  * the model's real maxTokens at call time.
123
127
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
124
128
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
125
129
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
126
130
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
127
131
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
128
- * and the knowledge synthesis background LLM. The coordinator and evaluator always
129
- * use the caller's model (ctx.model).
132
+ * and the knowledge synthesis background LLM. The coordinator and the research lead
133
+ * always use the caller's model (ctx.model).
130
134
  */
131
135
  RESEARCH_MODEL: Type.Optional(Type.String()),
132
136
  /** Explicit directory for the knowledge store database (overrides default) */
package/src/constants.ts CHANGED
@@ -69,7 +69,7 @@ export function getUnitsPerResearcher(config?: Config): number {
69
69
  return 1 + Math.min(batches, 4);
70
70
  }
71
71
 
72
- /** Progress bar weight per lead evaluator round */
72
+ /** Progress bar weight per research-lead round */
73
73
  export const LEAD_EVAL_UNITS = 2;
74
74
 
75
75
 
@@ -58,9 +58,9 @@ export interface ResearchObserver {
58
58
  // Synthesis/Completion
59
59
  /**
60
60
  * Fired when the run enters FINAL synthesis / stops consuming steering:
61
- * - deep mode: right before the forced (mustSynthesize) evaluator call, and
62
- * on the evaluator-chose-synthesize path (idempotent there — the flag is
63
- * already off via onEvaluationDecision('synthesize')).
61
+ * - deep mode: right before the synthesizer call, and on the path where the
62
+ * router chose to finish early (idempotent there — the flag is already off
63
+ * via onEvaluationDecision('synthesize')).
64
64
  * - quick mode: as soon as the researcher session settles, i.e. the point
65
65
  * the 500ms steering-consume poller stops running.
66
66
  * TUI consequence: steeringAcceptable flips false, so a late steer takes the
@@ -59,6 +59,8 @@ export interface RunResearchersOptions {
59
59
  export interface IResearchSynthesisService extends IService {
60
60
  storeReport(researchId: string, researcherId: string, report: string): void;
61
61
  getAllReports(researchId: string): Map<string, string>;
62
+ /** Coverage digest per stored report, derived when a researcher emitted none. */
63
+ getAllDigests(researchId: string): Map<string, string>;
62
64
  getReport(researchId: string, id: string): string | undefined;
63
65
  getReportsForRound(researchId: string, round: number): Map<string, string>;
64
66
  getReportCount(researchId: string): number;
@@ -56,6 +56,12 @@ export interface GenerateQueriesOptions {
56
56
  export interface UpdatePlanOptions {
57
57
  sessionId: string;
58
58
  reports: Map<string, string>;
59
+ /** Coverage digest per report id. The router reads the FRESH round's reports in full
60
+ * (evidence-based routing) and falls back to these digests only for PRIOR rounds — it
61
+ * does not read digests instead of the current round's reports. Optional: when omitted,
62
+ * digests are derived from the report bodies so a caller that doesn't track them still
63
+ * routes on coverage rather than on nothing. */
64
+ digests?: Map<string, string>;
59
65
  round: number;
60
66
  query: string;
61
67
  complexity: 1 | 2 | 3;
@@ -98,7 +104,7 @@ export interface IPlanningService extends IService {
98
104
  getTeamSize(complexity: 1 | 2 | 3): number;
99
105
  getQueryBudget(complexity: 1 | 2 | 3): number;
100
106
  getComplexityGuidance(complexity: 1 | 2 | 3, maxTeamSize: number, queryBudget: number): string;
101
- getEvaluatorComplexityGuidance(complexity: 1 | 2 | 3): string;
107
+ getEvaluatorComplexityGuidance(complexity: 1 | 2 | 3, researchRounds: number): string;
102
108
  getRoundPhaseGuidance(currentRound: number, maxRounds: number, complexity: 1 | 2 | 3, maxTeamSize: number): string;
103
109
  capResearcherQueries(plan: ResearchPlan, complexity: 1 | 2 | 3, serviceName: string): ResearchPlan;
104
110
  parseJsonPlan(text: string): ResearchPlan;
@@ -70,9 +70,25 @@ export interface ScrapeResult {
70
70
  /**
71
71
  * Base scheduler interface
72
72
  */
73
+ /**
74
+ * Reports when a task's EXECUTION starts and stops.
75
+ *
76
+ * Callers that keep their own guard over a scheduler call arm it on 'dispatched'
77
+ * rather than at call time, so queue wait never counts against the work's budget,
78
+ * and disarm it on 'settled' so the guard is not left running over a period in
79
+ * which nothing is executing — between a failed attempt and its retry, that
80
+ * period includes a backoff sleep, a re-enqueue and a fresh queue wait, and a
81
+ * timer left armed across it kills the retry for the predecessor's overrun.
82
+ *
83
+ * An implementation that cannot observe dispatch (BrowserClient, which only sees
84
+ * the leader's final reply) never calls it at all, and the caller's guard stays
85
+ * disarmed — that implementation bounds the call itself.
86
+ */
87
+ export type TaskDispatchListener = (event: 'dispatched' | 'settled') => void;
88
+
73
89
  export interface IScheduler {
74
- runSearch(query: string, config?: Config, signal?: AbortSignal): Promise<SearchResult[]>;
75
- runScrape(url: string, config?: Config, signal?: AbortSignal): Promise<ScrapeResult>;
90
+ runSearch(query: string, config?: Config, signal?: AbortSignal, onTaskEvent?: TaskDispatchListener): Promise<SearchResult[]>;
91
+ runScrape(url: string, config?: Config, signal?: AbortSignal, onTaskEvent?: TaskDispatchListener): Promise<ScrapeResult>;
76
92
  runHealthCheck(config?: Config, signal?: AbortSignal): Promise<{ success: boolean }>;
77
93
  shutdown(): Promise<void>;
78
94
  resetIdleTimerOnActivity?(): void;
@@ -34,6 +34,10 @@ export interface JsonRepairOptions {
34
34
  maxTokens?: number;
35
35
  /** Thinking level for the repair pass (default 'off' — repair emits JSON, not reasoning). */
36
36
  thinkingLevel?: ModelThinkingLevel;
37
+ /** Research session id, forwarded to the provider as its prompt-cache / session-affinity
38
+ * key (see llm-utils.buildSafeOptions). Keeps the repair pass on the same upstream
39
+ * replica as the call it is repairing. */
40
+ sessionId?: string;
37
41
  /** Usage sink invoked once per billed LLM attempt with the raw usage object from
38
42
  * the response. Lets callers attribute the repair pass's tokens/cost to the run
39
43
  * (metrics + observer) exactly as they do for the primary call. Every attempt that
@@ -70,7 +74,7 @@ export async function repairJsonWithLlm<T = any>(
70
74
  auth: { apiKey: string; headers?: Record<string, string | null> },
71
75
  options: JsonRepairOptions
72
76
  ): Promise<T | null> {
73
- const { model, context, schema, serviceName = 'RepairService', signal, maxTokens = 16384, thinkingLevel = 'off' } = options;
77
+ const { model, context, schema, serviceName = 'RepairService', signal, maxTokens = 16384, thinkingLevel = 'off', sessionId } = options;
74
78
 
75
79
  logger.warn(`[${serviceName}] JSON parse failed; attempting agentic salvage`);
76
80
 
@@ -118,7 +122,8 @@ Return ONLY the valid JSON object. No prose before or after.`;
118
122
  ],
119
123
  }, buildSafeOptions(model, {
120
124
  ...auth,
121
- signal
125
+ signal,
126
+ ...(sessionId ? { sessionId } : {}),
122
127
  }, maxTokens, thinkingLevel)),
123
128
  llmTimeout, `agentic-repair-${serviceName}`,
124
129
  );
@@ -35,7 +35,10 @@ function getCurrentDateString(): string {
35
35
  * training-cutoff defaults). The full date follows for precision. Applies to
36
36
  * coordinator, researcher, and evaluator across all modes.
37
37
  */
38
- export function injectCurrentDate(prompt: string, _agentType: 'coordinator' | 'researcher' | 'evaluator'): string {
38
+ export function injectCurrentDate(
39
+ prompt: string,
40
+ _agentType: 'coordinator' | 'researcher' | 'router' | 'synthesizer',
41
+ ): string {
39
42
  const monthYear = getCurrentMonthYearString();
40
43
  const fullDate = getCurrentDateString();
41
44
  // Derive the stale-year examples from the current year so the guidance stays correct in any
@@ -22,6 +22,13 @@ import { logger } from '../../logger.ts';
22
22
  * thinking block only consumes the output-token budget (often truncating before the
23
23
  * text block is emitted) for at most a marginal quality gain that does not justify the cost.
24
24
  *
25
+ * Callers should also pass the research `sessionId` in `options`. pi-ai forwards it as the
26
+ * provider's prompt-cache / session-affinity key — OpenAI's `prompt_cache_key`, OpenRouter's
27
+ * `x-session-id` — which keeps a run's calls on the replica that warmed their cache. Without
28
+ * it OpenRouter falls back to hashing the first system and user messages, which every round
29
+ * of a research run mutates, so it re-routes and re-warms. Providers that do not implement
30
+ * session affinity ignore the field, so passing it is unconditionally safe.
31
+ *
25
32
  * @param model - The model being called
26
33
  * @param options - Caller-provided options (an explicit `reasoning` here wins)
27
34
  * @param defaultCap - Default maxTokens cap (clamped to model.maxTokens)