@lincoln504/pi-research 1.4.3 → 1.5.0

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 (41) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +192 -45
  3. package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
  4. package/dist/prompts/research-knowledge-search-extractor.md +2 -0
  5. package/dist/prompts/researcher.md +2 -14
  6. package/dist/thread-worker.mjs +1 -1
  7. package/docs/AGENT-SKILL.md +4 -3
  8. package/docs/CHANGELOG.md +45 -1
  9. package/docs/CONFIGURATION.md +1 -1
  10. package/docs/SDK.md +1 -1
  11. package/package.json +1 -1
  12. package/skills/pi-research/SKILL.md +1 -1
  13. package/src/cli.ts +44 -3
  14. package/src/config.ts +26 -9
  15. package/src/constants.ts +23 -4
  16. package/src/core/llm/research-model-resolver.ts +38 -0
  17. package/src/core/planning-service.ts +12 -1
  18. package/src/index.ts +3 -1
  19. package/src/infrastructure/browser/thread-worker-messaging.ts +5 -1
  20. package/src/infrastructure/browser/thread-worker.mjs +1 -1
  21. package/src/infrastructure/embedding/embedding-server.ts +29 -9
  22. package/src/knowledge/store.ts +60 -4
  23. package/src/observers/research-observer-impl.ts +14 -6
  24. package/src/orchestration/quick-research-orchestrator.ts +29 -10
  25. package/src/orchestration/research-synthesis-service.ts +32 -0
  26. package/src/orchestration/researcher-executor.ts +31 -16
  27. package/src/orchestration/researcher.ts +2 -2
  28. package/src/orchestration/session-state.ts +19 -4
  29. package/src/prompts/research-knowledge-relevance-triage.md +2 -0
  30. package/src/prompts/research-knowledge-search-extractor.md +2 -0
  31. package/src/prompts/researcher.md +2 -14
  32. package/src/sdk.ts +6 -0
  33. package/src/tools/index.ts +2 -1
  34. package/src/tools/research-knowledge-search.ts +37 -4
  35. package/src/tools/research-tool-definition.ts +27 -16
  36. package/src/tools/scrape.ts +8 -1
  37. package/src/utils/log-utils.ts +5 -2
  38. package/src/utils/metrics-summary.ts +21 -1
  39. package/src/utils/tool-usage-tracker.ts +6 -3
  40. package/src/web-research/web-scraper.ts +9 -0
  41. package/src/youtube/index.ts +11 -1
package/docs/CHANGELOG.md CHANGED
@@ -5,7 +5,51 @@ 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
- ## [Unreleased]
8
+ ## [1.5.0] - 2026-08-20
9
+
10
+ Whole-project audit (release integrity, CI/test health, SSRF consistency,
11
+ docs-vs-code accuracy, and an adversarial re-check of every fix claimed in
12
+ the 1.4.1 changelog entry) that found several defects in code the 1.4.1
13
+ audit itself had just "fixed" — proof that a changelog claim needs
14
+ independent re-verification, not just a passing regression test for the
15
+ first failure mode it covers. Plus a prompt-caching workstream: a full verification of how
16
+ prompt caching actually behaves across every provider path pi supports,
17
+ restructuring of every LLM prompt in the codebase for cache-prefix
18
+ invariance, and a caching-aware scrape budget.
19
+
20
+ ### Added
21
+
22
+ - **Prompt-caching-aware scrape budget.** When prompt caching is known to be active for the run's resolved research model — the Anthropic API (caching is native and always on), or an `openai-completions` provider route explicitly configured with Anthropic-style `cacheControlFormat` — the effective per-researcher scrape-batch maximum is the configured `PI_RESEARCH_MAX_SCRAPE_BATCHES` plus one: the cached system-prompt/tool prefix makes the extra batch's context re-send cheap. The detection is deliberately conservative (`isPromptCachingActiveForModel`): providers whose caching is implicit/server-side (OpenAI Responses, Google, Mistral, Bedrock) report false, because a false positive would raise the budget without the discount that pays for it. One arithmetic source of truth (`getMaxScrapeBatches`) feeds tracker enforcement, the researcher prompt text, the scrape tool's own protocol text, and the TUI progress units, so they can never disagree; the increment is capped so a configured 99 (the documented max) never crosses into the >99 "unlimited" display band (`src/core/llm/research-model-resolver.ts`, `src/constants.ts`, `src/utils/tool-usage-tracker.ts`, `src/tools/scrape.ts`, `src/observers/research-observer-impl.ts`).
23
+ - **Prompt-cache metrics surfaced to users.** The per-run summary's token line now reads e.g. `**150,000** tokens (**90,000** from cache)` whenever the provider reported prompt-cache reads, and `ResearchStats` (SDK `getLastRunStats()`) carries `cacheReadTokens`/`cacheWriteTokens`. Previously the `llm_cache_read/write_tokens_total` counters existed but reached no user-facing surface, so there was no way to see whether caching was working (`src/utils/metrics-summary.ts`).
24
+ - **Weak-grounding notice on completed reports.** A run that completes but verifies fewer than 3 sources now appends an explicit `Grounding notice` after its CITED LINKS list telling the reader (human or calling agent) that unattributed claims are unverified. Driven by a real production run: a depth-1 report whose researchers retrieved almost nothing shipped as a fully sourced-looking document with one citation and fabricated most of its specifics, and the calling agent had to detect the degradation itself. The existing zero-source notes already covered the empty case; this covers the sourced-looking-but-barely-grounded one. The notice sits BEFORE the CITED LINKS header — anything after the last citation entry gets absorbed into that entry's description when quick mode re-parses the report for knowledge-store entries. The CLI additionally prints a stderr `WARNING:` line on completion when a report carries the weak-grounding or no-sources notice, so callers that read stderr (the agent skill does) can distinguish a degraded run from a healthy one without parsing the report (`src/orchestration/research-synthesis-service.ts`, `src/cli.ts`).
25
+
26
+ ### Changed
27
+
28
+ - **Every researcher's system prompt is now byte-identical across researchers, rounds, and retry attempts.** The per-researcher goal, knowledge-store results, initial evidence, sibling-coordination notes, and steering messages moved from the system prompt into the researcher's initial user message. A byte-identical system prompt + tool set is the cacheable prefix providers key on, so on caching providers every researcher after the first now gets prompt-cache reads on that prefix instead of a fresh cache write per researcher (previously each researcher's unique system prompt guaranteed zero cross-researcher reuse — with Anthropic's 25% cache-write premium, strictly worse than no caching). Quick mode got the same restructure, which also removed a long-standing redundancy where the query was sent twice (once templated into the system prompt, once as the user message). Regression tests pin the invariance directly (`src/prompts/researcher.md`, `src/orchestration/researcher-executor.ts`, `src/orchestration/quick-research-orchestrator.ts`).
29
+ - **The knowledge-search extractor and relevance-triage prompts split static rules from per-call data.** Both stateless background calls put the queries, conversation history, and reference documents/candidates inside the system prompt, so on caching providers every call was a distinct cache entry — pure cache-write premium with zero reads. The templates now carry a `USER_TURN` marker: rules above it stay the (cacheable, byte-identical) system prompt; the per-call sections below are delivered as the user message. A missing marker falls back to the legacy all-in-system shape rather than corrupting the prompt (`src/prompts/research-knowledge-search-extractor.md`, `src/prompts/research-knowledge-relevance-triage.md`, `src/tools/research-knowledge-search.ts`).
30
+ - **Removed a vestigial instruction from the researcher prompt.** "Do NOT try to search the store manually" told researchers not to use a tool they have never been given (`research_knowledge_search` is a top-level tool, not part of the researcher toolset); the surviving wording just explains where knowledge-store results come from and how to cite them.
31
+ - `docs/SDK.md` now opens by describing the package as a high-level research SDK.
32
+
33
+ ### Fixed
34
+
35
+ - **A markdown-fenced JSON synthesis envelope could ship verbatim as the final report.** `salvageReportText` suppresses broken JSON envelopes when parse and repair both fail, but its check was `startsWith('{')` — a ```` ```json ````-fenced envelope starts with backticks and sailed through (observed in production 2026-08-20: a fenced `{"action":"synthesize",…}` blob, internal protocol fields included, published as a user-facing report). The sniff now strips one leading/trailing code fence before the shape check; prose that merely contains a fenced block is still preserved (`src/core/planning-service.ts`).
36
+ - **The release workflow's credentialed `publish` job interpolated an untrusted job output into its shell.** `npm publish "${{ needs.test-install.outputs.tar_file }}"` splices text originating in the one job that runs untrusted dependency lifecycle scripts directly into the script of the only job holding `id-token: write` — a script-injection channel that re-opened the exact credential/untrusted-code separation the 1.4.2 publish restructure exists to enforce (GitHub's hardening guide lists `needs.*.outputs.*` as an injection source). The publish job now derives the tarball filename deterministically from the release tag (env-indirected), verifies the downloaded artifact contains exactly that file, and the `test-install` job no longer exports the output at all (`.github/workflows/release.yml`).
37
+ - **The browser scrape layer classified PDF-vs-HTML against the pre-redirect URL** — the same bug class 1.4.2 fixed in the fetch layer via `currentUrl`, left unfixed one layer down. A download endpoint without `.pdf` that redirects to the real `.pdf` file (with missing/mislabeled content-type) fell into the HTML branch, serializing Firefox's PDF-viewer chrome instead of the document, and the oversized-PDF early abort never armed for it. Both browser-layer checks now use the response's own post-redirect URL (`src/infrastructure/browser/thread-worker-messaging.ts`).
38
+ - **Unit tests polluted the real shared log file.** The test setup's `PI_RESEARCH_LOG_PATH` redirect ran in the setup module's body — but its own static imports (metrics → logger) are hoisted and evaluate first, so the logger singleton had already bound the user's real log path before the redirect executed. Every error-path unit test then appended real-looking WARN/ERROR entries (6 spurious ERRORs and hundreds of WARNs in one observed day) that materially slowed forensic diagnosis of real runs. The env assignments now live in an import-free module imported first (`test/setup/unit-env.ts`).
39
+ - **The missing-config advisory was the single most common line in the log.** `getConfig` warned "No configuration found for workspace" on every call from an unconfigured directory — 362 entries in one observed day. Now warned once per workspace per process (`src/config.ts`).
40
+ - Singular knowledge-search progress line rendered a bare trailing slash ("1 query/") — same rate-vs-count confusion the search-burst line was already fixed for (`src/cli.ts`).
41
+ - `isVerboseFromEnv`'s doc comment claimed config loads sync `config.DEBUG` into the env; only saves do. The comment now says exactly when a `DEBUG=true` line in config.env takes effect (`src/utils/log-utils.ts`).
42
+ - **Four 1.4.2 fixes shipped without the regression tests their release notes implied.** Backfilled and verified: `security_search` must not charge the shared gathering budget on unknown-database/invalid-severity rejects; `redactSecrets` must still mark the scan-cut tail as truncated when redaction shrinks the prefix under the cap; `uninstallSkill([])` must remove nothing (an explicit empty list is not `--all`); and redirect-aware PDF/HTML classification on the fetch path (both directions, verified failing against the pre-fix code).
43
+ - **Knowledge-store migration could still lose data if BOTH the canonical rename and its own revert failed.** The 1.4.1 fix for the manifest/disk desync bug added a revert-the-rename path for when the post-rename manifest save fails, but the revert's own failure fell through into a catch block written for a different case (the first rename never happening at all) — which pointed the manifest at a directory that no longer existed on disk. Now retries the manifest save against the name that actually matches disk, and logs loudly if that also fails, instead of silently stranding the data; a third-order failure (the recovery reopen itself failing) is also handled — and the manifest repair is deliberately not gated behind the reopen, since it needs only file writes, so a reopen-only fault still gets the manifest fixed for the next restart. Three regression tests cover the compound failures directly (`src/knowledge/store.ts`).
44
+ - **`SIGQUIT` was documented as gracefully handled and wasn't.** `docs/SDK.md` and `SKILL.md` list SIGQUIT alongside SIGINT/SIGTERM/SIGHUP as a signal the CLI catches and shuts down cleanly from — but no listener was ever registered for it, so a real SIGQUIT hit Node's default disposition (immediate kill, no abort, no drain) instead. Added the missing handler to both the CLI and SDK; a real-subprocess test now sends an actual SIGQUIT to the built binary and distinguishes "our handler ran" from "the OS killed it" (`src/cli.ts`, `src/sdk.ts`).
45
+ - **Embedding-server shutdown could dispose the pipeline while a second queued embed was starting.** `SerialQueue.waitForIdle()` only checked the currently-executing task; there's a real gap, verified with a controlled reproduction, between one task clearing that state and the next queued task claiming it, and a caller checking exactly in that gap saw "idle" while a second native call was about to run against the about-to-be-disposed pipeline — the same class of native-GPU-concurrency bug this queue exists to prevent, one layer deeper than the 1.4.1 fix covered. Now also tracks whether the queue is still mid-drain (`src/infrastructure/embedding/embedding-server.ts`).
46
+ - **Two sibling headless research runs finishing at nearly the same moment could both skip clearing shared steering messages.** `endResearchSession`'s "last run in the session" gate only checked TUI-only state (`order`, `panels`), so on every headless run it was trivially true — one headless run ending reset steering while a sibling headless run was still consuming it. The gate now also requires the abort registry (populated by TUI and headless runs alike) to be empty, and because this run's own entry is removed in the same synchronous function, the whichever-sibling-finishes-last decision is atomic; the separate, racy guard the research tool used to run before its cleanup was removed in favor of this one (`src/orchestration/session-state.ts`, `src/tools/research-tool-definition.ts`).
47
+ - **A single YouTube transcript could dominate a researcher's context window.** The `youtube_transcript` tool concatenated raw transcripts into its tool response with no per-video cap — a long video (or several in one batch call) could inject hundreds of thousands of characters. Each transcript is now truncated with the same per-document cap and marker the scrape tool uses (`src/youtube/index.ts`).
48
+ - **A legacy `.pi-research.env` file in the current directory could set user-scoped keys.** The tool runs in arbitrary (including untrusted) repositories; a checked-in legacy env file could override user-level configuration such as API endpoints for anyone who ran research in that directory. CWD-local legacy files are now restricted to the same local-scope key allowlist that already governed project-scoped registry storage; user-scoped keys in them are ignored (`src/config.ts`).
49
+ - **The scrape tool's own protocol text could disagree with the enforced limit.** `scrape.ts` computed its batch count from config directly instead of asking the tracker that actually enforces the limit; with the new caching-aware budget those could differ by one. The tool now reads the tracker's limit, falling back to config only when no tracker is wired (`src/tools/scrape.ts`).
50
+ - **Release workflow's credentialed `publish` job ran an unscoped `npm install`.** `npm install -g npm@11` (needed for the OIDC trusted-publishing exchange) executed in the one job holding `id-token: write`, without `--ignore-scripts` — npm itself ships no install-time lifecycle scripts today, but the job's whole design is to never run installable code there on trust alone. Added `--ignore-scripts`; verified the resulting npm binary still publishes correctly (`.github/workflows/release.yml`).
51
+ - **README overstated the knowledge store as always-on.** "Every finding is written to a local LanceDB knowledge store" implied unconditional, automatic behavior; it's actually conditional on the store being enabled. Reworded to "can be saved."
52
+ - **`AGENT-SKILL.md` presented the Codex skill-install path as equally confirmed as Claude/OpenClaw's.** The installer itself tags the Codex path `confidence: 'unverified'` ("path not confirmed by official docs"); the doc now says so.
9
53
 
10
54
  ## [1.4.3] - 2026-08-19
11
55
 
@@ -68,7 +68,7 @@ Research
68
68
  | `PI_RESEARCH_TIMEOUT_MS` (TUI) | `300000` | 180000–1800000 | Per-researcher timeout (3–30 min). |
69
69
  | `PI_RESEARCH_MAX_RESEARCHERS` (TUI) | `3` | 1–5 | Parallel researchers. |
70
70
  | `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` (TUI) `[project]` | `1` | 1–3 | Depth for `/research` and the CLI when `--depth` is omitted (1=normal, 2=deep, 3=ultra). |
71
- | `PI_RESEARCH_MAX_SCRAPE_BATCHES` (TUI) | `2` | 0–99 | Scrape batches per researcher (0 = unlimited). |
71
+ | `PI_RESEARCH_MAX_SCRAPE_BATCHES` (TUI) | `2` | 0–99 | Scrape batches per researcher (0 = unlimited). When prompt caching is known to be active for the resolved research model (Anthropic API models, or a provider route explicitly configured for Anthropic-style cache control), the effective limit is this value plus one — the cached prompt prefix makes the extra batch cheap. |
72
72
  | `PI_RESEARCH_MAX_GATHERING_CALLS` | `12` | 1–100 | Shared web-gathering calls per researcher (`search` + `security_search` + `stackexchange` + `youtube_transcript`). |
73
73
  | `PI_RESEARCH_MAX_CONCURRENT_SCRAPES` | `3` | 1–20 | Concurrent URLs fetched per scrape batch. |
74
74
  | `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
package/docs/SDK.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ## SDK
2
2
 
3
- The programmatic SDK for scripts, CI, and custom tooling. For configuration (the
3
+ A high-level research SDK for scripts, CI, and custom tooling. For configuration (the
4
4
  layering model, the TUI settings, and every environment variable) see
5
5
  [CONFIGURATION.md](CONFIGURATION.md).
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "description": "Web research for your agents with smart and safe tooling + knowledge store",
5
5
  "type": "module",
6
6
  "private": false,
@@ -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.4.3", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.5.0", "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
@@ -150,7 +150,7 @@ function pretty(obj: unknown): string {
150
150
  * lifecycle event to stderr. Keeps a long research run visible without spamming
151
151
  * stdout (the report goes to stdout, untouched).
152
152
  */
153
- function makeProgressObserver(): HeadlessObserverOptions {
153
+ export function makeProgressObserver(): HeadlessObserverOptions {
154
154
  return {
155
155
  enableLogging: false,
156
156
  onProgress: (event: string, data?: any) => {
@@ -196,9 +196,24 @@ function makeProgressObserver(): HeadlessObserverOptions {
196
196
  }
197
197
  break;
198
198
  }
199
- case 'complete':
199
+ case 'complete': {
200
200
  toStderr(` • complete (${data?.result?.length ?? 0} chars)\n`);
201
+ // Degradation signal for callers that read stderr (the agent skill
202
+ // does): a run that completes but is weakly grounded or retrieved no
203
+ // sources at all looks identical to a healthy run on exit code and
204
+ // the "complete" line alone. Two real runs on 2026-08-20 shipped
205
+ // degraded reports (1 citation / 0 sources) with nothing on stderr to
206
+ // distinguish them. The markers are the synthesis service's own
207
+ // notices, appended by ensureCitedLinks.
208
+ const reportText = typeof data?.result === 'string' ? data.result : '';
209
+ if (reportText.includes('No web sources were successfully retrieved') ||
210
+ reportText.includes('None of the links above could be verified')) {
211
+ toStderr(' • WARNING: no web sources could be retrieved — the report is unverified\n');
212
+ } else if (reportText.includes('Grounding notice: only')) {
213
+ toStderr(' • WARNING: weakly grounded report (fewer than 3 verified sources) — treat unattributed claims as unverified\n');
214
+ }
201
215
  break;
216
+ }
202
217
  case 'error':
203
218
  toStderr(` • error: ${data?.message}\n`);
204
219
  break;
@@ -738,7 +753,9 @@ export async function cmdKnowledge(queries: string[], json?: boolean): Promise<n
738
753
  config: getConfig(process.cwd(), 'cli'),
739
754
  ignoreGlobalConfig: true,
740
755
  });
741
- toStderr(`[pi-research] searching knowledge store (${queries.length} query/${queries.length === 1 ? '' : 's'})…\n`);
756
+ // "N queries", not "N query/s" — same rationale as the search_start line above:
757
+ // the slash form reads as a rate and renders "1 query/" in the singular.
758
+ toStderr(`[pi-research] searching knowledge store (${queries.length} ${queries.length === 1 ? 'query' : 'queries'})…\n`);
742
759
  result = await searchKnowledge(queries, abortController.signal);
743
760
  } catch (err) {
744
761
  return reportError(err, 'knowledge search', json);
@@ -1660,10 +1677,34 @@ async function main(argv: string[]): Promise<number> {
1660
1677
  // left the SDK's handler to tear the container down underneath a live orchestrator
1661
1678
  // — exactly the race the CLI installs its own handlers to avoid.
1662
1679
  process.on('SIGHUP', () => onSignal('SIGHUP'));
1680
+ // SIGQUIT: documented (see exitCodeForSignal's comment and the exit-code
1681
+ // tables in docs/SDK.md and SKILL.md) as handled the same way as SIGHUP,
1682
+ // but no listener was ever registered for it — an unhandled SIGQUIT hits
1683
+ // Node's default disposition (immediate termination, no abort, no drain),
1684
+ // silently breaking that documented guarantee. Has no Windows equivalent;
1685
+ // registering the listener there is a documented no-op, so no guard needed.
1686
+ process.on('SIGQUIT', () => onSignal('SIGQUIT'));
1663
1687
  if (process.platform === 'win32') {
1664
1688
  process.on('SIGBREAK', () => onSignal('SIGBREAK'));
1665
1689
  }
1666
1690
 
1691
+ // Test-only seam: idle here, right after every handler above is registered,
1692
+ // instead of parsing argv/running a command. Lets a subprocess test deliver
1693
+ // a real OS signal and observe THIS handler run (process.exit with the
1694
+ // derived code) rather than Node's default disposition (process killed by
1695
+ // the signal, no code) — the exact distinction that would have caught the
1696
+ // missing SIGQUIT registration this seam was added for. Never set in
1697
+ // production; see the SIGQUIT subprocess test in test/unit/cli.test.ts.
1698
+ if (process.env['PI_RESEARCH_TEST_HANG_FOR_SIGNAL'] === '1') {
1699
+ process.stdout.write('PI_RESEARCH_TEST_READY\n');
1700
+ // A bare unresolved Promise does not keep the event loop alive by
1701
+ // itself (only real libuv handles do — an installed signal listener
1702
+ // isn't one either), so without a ref'd timer the process would exit 0
1703
+ // on its own the instant this synchronous section finishes, before any
1704
+ // signal could ever arrive.
1705
+ await new Promise<never>(() => setInterval(() => {}, 1000));
1706
+ }
1707
+
1667
1708
  let parsed: ParsedArgs;
1668
1709
  try {
1669
1710
  parsed = parseArgs(argv);
package/src/config.ts CHANGED
@@ -751,16 +751,23 @@ function loadEnvFiles(cwd: string, iface?: ConfigInterface): Record<string, stri
751
751
  try {
752
752
  logger.warn(`[config] WARNING: .pi-research.env files are deprecated. Settings will be auto-migrated to the centralized registry. Remove ${legacyPath} after migration.`);
753
753
  legacyEnv = parseDotEnv(fs.readFileSync(legacyPath, 'utf-8'));
754
- Object.assign(merged, legacyEnv);
755
-
756
- // Auto-migrate legacy settings to the central registry — but only LOCAL_SCOPE_KEYS. The
757
- // registry is project-scoped storage by design (saveConfig writes only these keys to it);
758
- // persisting user-scoped legacy keys would freeze them per-workspace and silently override
759
- // the user's global config.env forever. Non-local legacy keys still apply to THIS resolution
760
- // via the Object.assign(merged, legacyEnv) above — they just aren't persisted.
754
+ // Only LOCAL_SCOPE_KEYS from this run's resolution too — same boundary
755
+ // step 4 below already enforces for the registry this file migrates
756
+ // into ("the registry is project-scoped storage... never user-scoped
757
+ // keys"). This file lives in the CWD, which for this tool is routinely
758
+ // an arbitrary, potentially untrusted repository a coding agent was
759
+ // just pointed at — applying every key unconditionally let a dropped-in
760
+ // .pi-research.env silently control user-scoped settings (RESEARCH_MODEL,
761
+ // EMBEDDING_DEVICE, DISABLED_TOOLS, export destinations, ...) for that
762
+ // run with no confirmation, for any setting the user hadn't already set
763
+ // via a real environment variable (which still wins over all of this).
764
+ // Non-local keys are ignored here exactly as they already are for the
765
+ // registry — this predecessor mechanism should not be MORE permissive
766
+ // than the successor it's migrating into.
761
767
  const legacyLocal = Object.fromEntries(
762
768
  Object.entries(legacyEnv).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
763
769
  );
770
+ Object.assign(merged, legacyLocal);
764
771
  const existingLocal = Object.fromEntries(
765
772
  Object.entries(findRegistryEntry(registry, normalizedCwd) ?? {}).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
766
773
  );
@@ -799,8 +806,14 @@ function loadEnvFiles(cwd: string, iface?: ConfigInterface): Record<string, stri
799
806
  }
800
807
  }
801
808
  } else if (Object.keys(merged).length === 0 && !fs.existsSync(legacyPath) && !fs.existsSync(globalPath)) {
802
- // 5. Warning for missing config
803
- logger.warn(`[config] No configuration found for workspace: ${cwd}. Using code defaults. Run /research-config to configure.`);
809
+ // 5. Warning for missing config — once per workspace per process. getConfig
810
+ // is called on many hot paths (every tool invocation, health checks), and
811
+ // repeating this advisory each time made it the single most common line in
812
+ // the shared log (362 entries in one observed day), burying real warnings.
813
+ if (!warnedMissingConfigWorkspaces.has(normalizedCwd)) {
814
+ warnedMissingConfigWorkspaces.add(normalizedCwd);
815
+ logger.warn(`[config] No configuration found for workspace: ${cwd}. Using code defaults. Run /research-config to configure.`);
816
+ }
804
817
  }
805
818
 
806
819
  warnLegacyEnvVarsOnce(merged);
@@ -825,6 +838,10 @@ const LEGACY_ENV_VARS: Record<string, string> = {
825
838
 
826
839
  let warnedLegacyEnvVars = false;
827
840
 
841
+ // Workspaces already warned about having no configuration (see loadEnvFiles) —
842
+ // per-process dedup so the advisory appears once, not on every getConfig call.
843
+ const warnedMissingConfigWorkspaces = new Set<string>();
844
+
828
845
  /** Warn once per process about set-but-dead 0.1.x env vars (upgrade aid; never throws). */
829
846
  function warnLegacyEnvVarsOnce(merged: Record<string, string>): void {
830
847
  if (warnedLegacyEnvVars) return;
package/src/constants.ts CHANGED
@@ -46,11 +46,26 @@ export function getMaxGatheringCalls(config?: Config): number {
46
46
  /**
47
47
  * Get the maximum scrape batches from config.
48
48
  * This function should be used instead of MAX_SCRAPE_CALLS to support dynamic configuration.
49
+ *
50
+ * @param cachingActive - Pass `true` when prompt caching is active for this
51
+ * run's resolved model (see isPromptCachingActiveForModel in
52
+ * core/llm/research-model-resolver.ts). With caching active, the EFFECTIVE
53
+ * maximum is the configured value plus one: a cached run's system-prompt/
54
+ * tool-definition prefix is cheap to re-send, so the same token budget
55
+ * covers one more scrape batch per researcher. Every consumer of the batch
56
+ * count (tracker enforcement, prompt text, progress units) must go through
57
+ * this function with the same cachingActive value so they all agree on the
58
+ * effective number. The "unlimited" sentinel (0 or >99) is unchanged —
59
+ * adding one to unlimited is meaningless.
49
60
  */
50
- export function getMaxScrapeBatches(config?: Config): number {
61
+ export function getMaxScrapeBatches(config?: Config, cachingActive = false): number {
51
62
  try {
52
63
  const batches = (config || getConfig()).MAX_SCRAPE_BATCHES;
53
- return batches === 0 || batches > 99 ? 999999 : batches;
64
+ if (batches === 0 || batches > 99) return 999999;
65
+ // Cap at 99: a configured 99 (the documented max) plus one would land in the
66
+ // >99 "unlimited" band that every renderer checks — the prompt would say
67
+ // "unlimited batches" while enforcement actually blocked at 100.
68
+ return cachingActive ? Math.min(batches + 1, 99) : batches;
54
69
  } catch {
55
70
  return 2; // Fallback to default
56
71
  }
@@ -63,9 +78,13 @@ export const MAX_SCRAPE_URLS = 6;
63
78
  * Get the units per researcher for the progress bar.
64
79
  * Units = 1 (for start/search) + number of scrape batches.
65
80
  * When batches are unlimited (MAX_SCRAPE_BATCHES=0), caps at 4 for a sane progress estimate.
81
+ *
82
+ * Relative to the EFFECTIVE batch count: pass the run's cachingActive (from
83
+ * isPromptCachingActiveForModel over the resolved model) so progress units
84
+ * agree with the limit the tracker actually enforces.
66
85
  */
67
- export function getUnitsPerResearcher(config?: Config): number {
68
- const batches = getMaxScrapeBatches(config);
86
+ export function getUnitsPerResearcher(config?: Config, cachingActive = false): number {
87
+ const batches = getMaxScrapeBatches(config, cachingActive);
69
88
  return 1 + Math.min(batches, 4);
70
89
  }
71
90
 
@@ -89,3 +89,41 @@ export function resolveResearchModel(options: {
89
89
 
90
90
  throw new Error(buildNoModelAvailableMessage());
91
91
  }
92
+
93
+ /**
94
+ * Best-effort, CONSERVATIVE signal for "will pi-ai apply prompt caching for
95
+ * this resolved model." pi-ai owns all cache-mechanism logic internally (this
96
+ * codebase never constructs cache_control blocks itself) and exposes no
97
+ * public API to ask in advance for most providers, so this can only answer
98
+ * for the two client-visible cases:
99
+ *
100
+ * - `api === 'anthropic-messages'`: caching is native and on by default (no
101
+ * capability gate at all — see pi-ai's anthropic-messages provider).
102
+ * - `api === 'openai-completions'` with `compat.cacheControlFormat ===
103
+ * 'anthropic'`: an explicit opt-in to Anthropic-style cache_control
104
+ * emulation (e.g. an OpenRouter/gateway route to an Anthropic model
105
+ * configured with this override).
106
+ *
107
+ * Every other case returns false even though caching may actually be
108
+ * active: pi's generated model catalog sets `cacheControlFormat` on zero
109
+ * models, so plain `openai-completions` traffic (this project's custom
110
+ * providers — glm-coding, cerebras, zai, OpenRouter, ...) has its cache
111
+ * compat auto-detected by pi-ai's own private, unexported heuristic INSIDE
112
+ * the live request — not knowable client-side, before or after model
113
+ * resolution. Providers with fully implicit/automatic caching
114
+ * (openai-responses family, google-generative-ai, google-vertex,
115
+ * mistral-conversations, bedrock-converse-stream) have no "will it be
116
+ * active" signal at all — it depends on provider-side runtime behavior
117
+ * (minimum prompt length, server cache state). Treat this as "caching is
118
+ * DEFINITELY active" — a false negative is expected and fine for providers
119
+ * outside these two cases, but a false positive would be a real bug.
120
+ */
121
+ export function isPromptCachingActiveForModel(model: Model<any> | undefined): boolean {
122
+ if (!model) return false;
123
+ if (model.api === 'anthropic-messages') return true;
124
+ if (model.api === 'openai-completions') {
125
+ const compat = model.compat as { cacheControlFormat?: string } | undefined;
126
+ return compat?.cacheControlFormat === 'anthropic';
127
+ }
128
+ return false;
129
+ }
@@ -220,8 +220,19 @@ function isDegradableLlmError(error: unknown, signal?: AbortSignal): boolean {
220
220
  export function salvageReportText(raw: string): string {
221
221
  const trimmed = raw.trim();
222
222
  if (trimmed.length <= 50) return '';
223
+ // Envelope sniff. Models routinely wrap the JSON envelope in a markdown code
224
+ // fence (```json … ```); a fenced envelope starts with backticks, not '{', so a
225
+ // bare startsWith('{') check shipped one verbatim as a user-facing report
226
+ // (observed in production 2026-08-20: a ```json-fenced {"action":"synthesize",…}
227
+ // blob, internal protocol fields included, published as the final report).
228
+ // Strip one leading fence line and a trailing fence before the shape check.
229
+ let sniff = trimmed;
230
+ const fence = sniff.match(/^```[a-zA-Z0-9_-]*[ \t]*\r?\n/);
231
+ if (fence) {
232
+ sniff = sniff.slice(fence[0].length).replace(/\r?\n```\s*$/, '').trim();
233
+ }
223
234
  // Looks like a (broken) JSON envelope rather than a report — suppress it.
224
- if (trimmed.startsWith('{') && /"(?:action|content|researchers)"\s*:/.test(trimmed)) {
235
+ if (sniff.startsWith('{') && /"(?:action|content|researchers)"\s*:/.test(sniff)) {
225
236
  return '';
226
237
  }
227
238
  return raw;
package/src/index.ts CHANGED
@@ -556,7 +556,9 @@ export default async function (pi: ExtensionAPI) {
556
556
 
557
557
  // Researcher sub-agent sessions already carry the authoritative steering copy:
558
558
  // the executor (deep) and quick orchestrator inject "ADDITIONAL USER GUIDANCE"/
559
- // "ADDITIONAL CONSIDERATIONS" into the researcher system prompt themselves.
559
+ // "ADDITIONAL CONSIDERATIONS" into the researcher's initial user message themselves
560
+ // (kept OUT of the system prompt so its cacheable prefix stays byte-identical
561
+ // across researchers — see researcher-executor.ts).
560
562
  // Appending the same messages again here delivered every steering message
561
563
  // TWICE to every researcher — return the prompt untouched. (The coordinator and
562
564
  // the research lead never pass through this hook at all: they run via
@@ -542,7 +542,11 @@ export async function executeScrapeTask(
542
542
  // Matches the PDF branch below AND the fetch layer's own detection
543
543
  // (web-scraper.ts): a server can mislabel or omit content-type, so a
544
544
  // `.pdf` URL extension is an equally-trusted signal, not just the header.
545
- const looksLikePdf = ct?.includes('application/pdf') || url.toLowerCase().endsWith('.pdf');
545
+ // resp.url() — THIS response's URL, not the original task URL: goto()
546
+ // follows redirects, and a download endpoint without `.pdf` that
547
+ // redirects to the real `.pdf` file must still arm the early abort
548
+ // (same redirect-aware fix the fetch layer got via currentUrl).
549
+ const looksLikePdf = ct?.includes('application/pdf') || String(resp.url()).toLowerCase().endsWith('.pdf');
546
550
  if (!looksLikePdf || oversizedPdfError || poisonedError) return;
547
551
  const declared = parseInt((await resp.headerValue('content-length').catch(() => null)) || '', 10);
548
552
  if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
@@ -2332,7 +2332,7 @@ async function executeScrapeTask(_context, url, signal) {
2332
2332
  if (resp.request().resourceType() === "document" && resp.frame() === page.mainFrame()) {
2333
2333
  finalMainFrameAddr = resp.serverAddr().catch(() => null);
2334
2334
  const check2 = Promise.resolve(resp.headerValue("content-type")).then(async (ct) => {
2335
- const looksLikePdf = ct?.includes("application/pdf") || url.toLowerCase().endsWith(".pdf");
2335
+ const looksLikePdf = ct?.includes("application/pdf") || String(resp.url()).toLowerCase().endsWith(".pdf");
2336
2336
  if (!looksLikePdf || oversizedPdfError || poisonedError) return;
2337
2337
  const declared = parseInt(await resp.headerValue("content-length").catch(() => null) || "", 10);
2338
2338
  if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
@@ -178,19 +178,39 @@ class SerialQueue {
178
178
  }
179
179
 
180
180
  /**
181
- * Resolve once no native call is in flight. Used by EmbeddingServer.shutdown()
182
- * to wait out a still-running inference before disposing the pipeline —
183
- * without this, disposal could run concurrently with a legitimately slow (not
184
- * hung) embed/embedMany call on the same re-entrant-locked GPU context, the
185
- * exact native-segfault class this queue exists to prevent. No timeout: a
186
- * promise that truly never settles is already an accepted, pre-existing
187
- * leak-to-process-exit scenario elsewhere in this class (see the poison path's
181
+ * Resolve once no native call is in flight AND no queued task is still due
182
+ * to run. Used by EmbeddingServer.shutdown() to wait out a still-running
183
+ * inference before disposing the pipeline — without this, disposal could
184
+ * run concurrently with a legitimately slow (not hung) embed/embedMany call
185
+ * on the same re-entrant-locked GPU context, the exact native-segfault
186
+ * class this queue exists to prevent. No timeout: a promise that truly
187
+ * never settles is already an accepted, pre-existing leak-to-process-exit
188
+ * scenario elsewhere in this class (see the poison path's
188
189
  * disposeEmbedder=false handling) — better to leak than risk concurrent
189
190
  * native access.
191
+ *
192
+ * Checking only `currentWork` is NOT enough with a second task already
193
+ * queued: a task's `run()` clears `currentWork` to null in its `finally`
194
+ * (once its native call has settled), and `pump()` only claims it again for
195
+ * the next task on a LATER microtask tick, once `run()` for the current
196
+ * task has fully unwound and `await entry.run()` resumes. If a caller's own
197
+ * check happens to land in that gap, `currentWork` reads as null even
198
+ * though a second native call is about to start (or a queue in the process
199
+ * of unwinding to its next task hasn't reached it yet) — exactly the window
200
+ * disposal must not run in. `running` stays true for the queue's entire
201
+ * drain, across every task in `tasks`, not just the current one, so it has
202
+ * no equivalent gap and closes this one.
190
203
  */
191
204
  async waitForIdle(): Promise<void> {
192
- while (this.currentWork) {
193
- await this.currentWork.catch(() => {});
205
+ while (this.currentWork || this.running) {
206
+ if (this.currentWork) {
207
+ await this.currentWork.catch(() => {});
208
+ } else {
209
+ // Nothing executing right now, but pump() is still mid-drain (about
210
+ // to claim the next queued task, or unwinding) — yield a microtask
211
+ // so it can advance instead of reporting idle into this exact gap.
212
+ await Promise.resolve();
213
+ }
194
214
  }
195
215
  }
196
216
 
@@ -527,10 +527,66 @@ export class KnowledgeStore implements IKnowledgeStore {
527
527
  // out of the way and start a fresh empty table over one manifest
528
528
  // write hiccup.
529
529
  logger.error('[store] Failed to persist canonical table name after rename — reverting to keep manifest and disk in sync.');
530
- await fsPromises.rename(canonicalDir, tempDir);
531
- this.tableName = tempTableName;
532
- this.table = await this.db.openTable(this.tableName);
533
- logger.info(`[store] Migrated data remains safe and accessible as ${tempTableName}; canonical rename deferred to a future successful manifest save.`);
530
+ try {
531
+ await fsPromises.rename(canonicalDir, tempDir);
532
+ this.tableName = tempTableName;
533
+ this.table = await this.db.openTable(this.tableName);
534
+ logger.info(`[store] Migrated data remains safe and accessible as ${tempTableName}; canonical rename deferred to a future successful manifest save.`);
535
+ } catch (revertErr) {
536
+ // The revert itself failed too. fs.rename is atomic on a single
537
+ // filesystem — a failed rename never leaves data half-moved — so
538
+ // the data is still fully at canonicalDir, not tempDir. The outer
539
+ // catch below was written for a DIFFERENT case (the first rename
540
+ // never happening at all) and would wrongly point the manifest at
541
+ // tempTableName, whose directory no longer exists on disk. Instead,
542
+ // try to persist a manifest against the name that actually matches
543
+ // disk right now, so the mismatch this whole branch exists to avoid
544
+ // doesn't reappear one level deeper.
545
+ logger.error(`[store] Revert rename also failed; data remains on disk at ${canonicalDir}: ${revertErr}`);
546
+ // Everything from here on is wrapped in its OWN try/catch and never
547
+ // rethrows: a third compounding failure (e.g. openTable() itself
548
+ // throwing, on the same underlying disk fault that already broke
549
+ // two renames) must not escape to the outer catch (renameErr)
550
+ // either — that one assumes data is still at tempDir, which by
551
+ // this point is doubly false.
552
+ // The manifest repair is deliberately NOT gated behind the table
553
+ // reopen: saveManifest depends only on writeFile/rename, not on an
554
+ // open table, so a reopen-only failure (a LanceDB-level fault on an
555
+ // otherwise-writable filesystem) must not skip a repair the code can
556
+ // still make — a skipped repair leaves the persisted manifest
557
+ // pointing at the nonexistent temp name, which is the next-restart
558
+ // empty-table hazard this whole branch exists to prevent.
559
+ this.tableName = canonicalName;
560
+ let reopened = false;
561
+ try {
562
+ this.table = await this.db.openTable(this.tableName);
563
+ reopened = true;
564
+ } catch (openErr) {
565
+ // A third compounding failure: even reopening the table at its
566
+ // actual on-disk location failed. This process has no working
567
+ // table handle for it, but the data itself should still be
568
+ // physically intact at canonicalDir (only the rename and its
569
+ // revert have run, and neither drops data — they only move a
570
+ // directory or fail outright). The manifest repair below still
571
+ // proceeds, so a future restart can find the data even though
572
+ // this process cannot serve it.
573
+ logger.error(`[store] CRITICAL: could not reopen the table at '${canonicalName}' after a failed rename revert (${openErr}). Data should still be intact on disk at ${canonicalDir}, but this process has no working table handle for it. Attempting the manifest repair anyway so a restart can find the data; verify ${canonicalDir} exists if problems persist.`);
574
+ }
575
+ try {
576
+ if (await this.saveManifest()) {
577
+ logger.warn(`[store] Recovered: manifest now points at '${canonicalName}', matching the data's actual on-disk location${reopened ? '' : ' (this process still has no working table handle — restart to serve it)'}.`);
578
+ } else {
579
+ // Both the revert and this recovery manifest save failed. The
580
+ // on-disk manifest (last persisted pointing at tempTableName,
581
+ // before the old table was dropped) now disagrees with disk.
582
+ // Fail loud rather than let a future restart silently create
583
+ // an empty table under the stale name.
584
+ logger.error(`[store] CRITICAL: could not persist a manifest matching disk after a failed rename revert. Data is intact on disk at ${canonicalDir} (table '${canonicalName}'), but the persisted manifest is stale and points at '${tempTableName}', which no longer exists. If this process exits before a future successful manifest save, the next start will not find the data automatically — manually repair the manifest to point at '${canonicalName}' before restarting.`);
585
+ }
586
+ } catch (manifestErr) {
587
+ logger.error(`[store] CRITICAL: manifest repair threw after a failed rename revert (${manifestErr}). Data is intact on disk at ${canonicalDir} (table '${canonicalName}'), but the persisted manifest is stale and points at '${tempTableName}'. Manually repair the manifest to point at '${canonicalName}' before restarting.`);
588
+ }
589
+ }
534
590
  }
535
591
  } catch (renameErr) {
536
592
  // If rename fails (cross-device, permissions, etc.) we keep the temp name
@@ -43,6 +43,14 @@ export interface ObserverContext {
43
43
  */
44
44
  renderImmediate?: () => void;
45
45
  researchComplexity: number;
46
+ /**
47
+ * Whether prompt caching is active for the run's resolved model (see
48
+ * isPromptCachingActiveForModel). With caching active the effective
49
+ * per-researcher scrape-batch maximum is one higher, and progress units
50
+ * must be computed relative to that same effective count the tracker
51
+ * enforces — not the base configured value.
52
+ */
53
+ cachingActive?: boolean;
46
54
  }
47
55
 
48
56
  export interface ObserverState {
@@ -94,7 +102,7 @@ export function createResearchObserver(
94
102
  activateSlice(panelState, state.quickSliceLabel);
95
103
  updateSliceStatus(panelState, state.quickSliceLabel, 'starting...', debouncedRefresh);
96
104
 
97
- const units = getUnitsPerResearcher();
105
+ const units = getUnitsPerResearcher(undefined, ctx.cachingActive);
98
106
  panelState.progress = { expected: units, made: 0 };
99
107
  }
100
108
  // Quick research: researcher is running → steering is acceptable
@@ -152,7 +160,7 @@ export function createResearchObserver(
152
160
  onPlanningSuccess: (plan) => {
153
161
  updateSliceStatus(panelState, 'coord', 'ready', debouncedRefresh);
154
162
  completeSlice(panelState, 'coord');
155
- const unitsPerResearcher = getUnitsPerResearcher();
163
+ const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
156
164
  const count = plan.researchers?.length || 0;
157
165
  const units = (count * unitsPerResearcher) + LEAD_EVAL_UNITS;
158
166
  panelState.progress = { expected: units, made: 0 };
@@ -319,7 +327,7 @@ export function createResearchObserver(
319
327
 
320
328
  onResearcherProgress: (id, status, tokens, cost) => {
321
329
  const sliceId = id === 'quick' ? state.quickSliceLabel : id;
322
- const unitsPerResearcher = getUnitsPerResearcher();
330
+ const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
323
331
 
324
332
  if (status !== undefined) {
325
333
  if (status.startsWith('done:')) {
@@ -350,7 +358,7 @@ export function createResearchObserver(
350
358
  onResearcherComplete: (id, _report) => {
351
359
  const sliceId = id === 'quick' ? state.quickSliceLabel : id;
352
360
  if (panelState.progress) {
353
- const unitsPerResearcher = getUnitsPerResearcher();
361
+ const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
354
362
  const current = progressCredits.get(id) ?? 0;
355
363
  const remaining = unitsPerResearcher - current;
356
364
  if (remaining > 0) {
@@ -365,7 +373,7 @@ export function createResearchObserver(
365
373
  onResearcherFailure: (id, _error) => {
366
374
  const sliceId = id === 'quick' ? state.quickSliceLabel : id;
367
375
  if (panelState.progress) {
368
- const unitsPerResearcher = getUnitsPerResearcher();
376
+ const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
369
377
  const current = progressCredits.get(id) ?? 0;
370
378
  const remaining = unitsPerResearcher - current;
371
379
  if (remaining > 0) {
@@ -430,7 +438,7 @@ export function createResearchObserver(
430
438
  } else {
431
439
  // Delegation: prepare for new round's search → researchers → steering acceptable
432
440
  if (plan?.researchers && plan.researchers.length > 0 && panelState.progress) {
433
- const unitsPerResearcher = getUnitsPerResearcher();
441
+ const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
434
442
  panelState.progress.expected += (plan.researchers.length * unitsPerResearcher) + LEAD_EVAL_UNITS;
435
443
  }
436
444
  panelState.steeringAcceptable = true;