@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.
- package/README.md +1 -1
- package/dist/cli.mjs +192 -45
- package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
- package/dist/prompts/research-knowledge-search-extractor.md +2 -0
- package/dist/prompts/researcher.md +2 -14
- package/dist/thread-worker.mjs +1 -1
- package/docs/AGENT-SKILL.md +4 -3
- package/docs/CHANGELOG.md +45 -1
- package/docs/CONFIGURATION.md +1 -1
- package/docs/SDK.md +1 -1
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +44 -3
- package/src/config.ts +26 -9
- package/src/constants.ts +23 -4
- package/src/core/llm/research-model-resolver.ts +38 -0
- package/src/core/planning-service.ts +12 -1
- package/src/index.ts +3 -1
- package/src/infrastructure/browser/thread-worker-messaging.ts +5 -1
- package/src/infrastructure/browser/thread-worker.mjs +1 -1
- package/src/infrastructure/embedding/embedding-server.ts +29 -9
- package/src/knowledge/store.ts +60 -4
- package/src/observers/research-observer-impl.ts +14 -6
- package/src/orchestration/quick-research-orchestrator.ts +29 -10
- package/src/orchestration/research-synthesis-service.ts +32 -0
- package/src/orchestration/researcher-executor.ts +31 -16
- package/src/orchestration/researcher.ts +2 -2
- package/src/orchestration/session-state.ts +19 -4
- package/src/prompts/research-knowledge-relevance-triage.md +2 -0
- package/src/prompts/research-knowledge-search-extractor.md +2 -0
- package/src/prompts/researcher.md +2 -14
- package/src/sdk.ts +6 -0
- package/src/tools/index.ts +2 -1
- package/src/tools/research-knowledge-search.ts +37 -4
- package/src/tools/research-tool-definition.ts +27 -16
- package/src/tools/scrape.ts +8 -1
- package/src/utils/log-utils.ts +5 -2
- package/src/utils/metrics-summary.ts +21 -1
- package/src/utils/tool-usage-tracker.ts +6 -3
- package/src/web-research/web-scraper.ts +9 -0
- 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
|
-
## [
|
|
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
|
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
182
|
-
* to wait out a still-running
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
* leak-to-process-exit
|
|
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
|
-
|
|
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
|
|
package/src/knowledge/store.ts
CHANGED
|
@@ -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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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;
|