@lincoln504/pi-research 1.3.12 → 1.4.3
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 +2 -2
- package/dist/cli.mjs +5670 -4705
- package/dist/prompts/research-tool-usage.md +1 -1
- package/dist/prompts/researcher.md +1 -0
- package/dist/prompts/system-lead-router.md +122 -0
- package/dist/prompts/system-lead-synthesizer.md +46 -0
- package/dist/thread-worker.mjs +169 -62
- package/docs/ARCHITECTURE.md +18 -10
- package/docs/CHANGELOG.md +225 -0
- package/docs/CONFIGURATION.md +106 -7
- package/package.json +2 -2
- package/scripts/build.cjs +8 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +5 -1
- package/src/config.ts +22 -12
- package/src/constants.ts +1 -1
- package/src/core/interfaces/observer-interfaces.ts +3 -3
- package/src/core/interfaces/orchestration-interfaces.ts +2 -0
- package/src/core/interfaces/planning-interfaces.ts +7 -1
- package/src/core/interfaces/scheduler-interfaces.ts +18 -2
- package/src/core/llm/agentic-repair.ts +7 -2
- package/src/core/llm/inject-date.ts +4 -1
- package/src/core/llm/llm-utils.ts +7 -0
- package/src/core/planning-service.ts +766 -91
- package/src/core/planning-utils.ts +21 -5
- package/src/core/service-initialization.ts +19 -3
- package/src/healthcheck/index.ts +11 -2
- package/src/index.ts +4 -3
- package/src/infrastructure/browser/browser-client.ts +68 -34
- package/src/infrastructure/browser/browser-task-scheduler.ts +236 -43
- package/src/infrastructure/browser/config.ts +79 -1
- package/src/infrastructure/browser/priority-task-queue.ts +55 -1
- package/src/infrastructure/browser/scheduler-factory.ts +12 -1
- package/src/infrastructure/browser/task-execution-service.ts +5 -5
- package/src/infrastructure/browser/thread-worker-browser.ts +87 -36
- package/src/infrastructure/browser/thread-worker-messaging.ts +99 -10
- package/src/infrastructure/browser/thread-worker.mjs +169 -62
- package/src/infrastructure/browser/thread-worker.ts +13 -5
- package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
- package/src/infrastructure/embedding/embedding-client.ts +14 -4
- package/src/infrastructure/embedding/embedding-factory.ts +83 -15
- package/src/infrastructure/embedding/embedding-server.ts +34 -0
- package/src/infrastructure/file-lock-service.ts +214 -19
- package/src/infrastructure/knowledge-store-service.ts +14 -0
- package/src/infrastructure/research-run-semaphore.ts +1 -1
- package/src/infrastructure/service-initialization.ts +7 -0
- package/src/knowledge/embedder-init.ts +46 -19
- package/src/knowledge/embedder.ts +11 -3
- package/src/knowledge/index.ts +36 -10
- package/src/knowledge/store.ts +29 -5
- package/src/logger.ts +11 -0
- package/src/orchestration/deep-research-orchestrator.ts +143 -87
- package/src/orchestration/quick-research-orchestrator.ts +11 -2
- package/src/orchestration/research-orchestration-service.ts +8 -0
- package/src/orchestration/research-synthesis-service.ts +53 -1
- package/src/orchestration/researcher-executor.ts +53 -2
- package/src/orchestration/researcher.ts +15 -2
- package/src/orchestration/session-state.ts +16 -0
- package/src/prompts/research-tool-usage.md +1 -1
- package/src/prompts/researcher.md +1 -0
- package/src/prompts/system-lead-router.md +122 -0
- package/src/prompts/system-lead-synthesizer.md +46 -0
- package/src/sdk.ts +10 -0
- package/src/skill-install/skill-installer.ts +5 -1
- package/src/stackexchange/index.ts +3 -0
- package/src/stackexchange/rest-client.ts +7 -12
- package/src/tools/research-knowledge-search.ts +11 -3
- package/src/tools/research-tool-definition.ts +38 -7
- package/src/tools/scrape.ts +6 -0
- package/src/tools/search.ts +7 -0
- package/src/tools/security.ts +31 -10
- package/src/tools/stackexchange.ts +13 -7
- package/src/tools/youtube-transcript.ts +26 -12
- package/src/utils/cancellation.ts +36 -0
- package/src/utils/coverage-digest.ts +204 -0
- package/src/utils/host-config.ts +12 -1
- package/src/utils/llm-usage.ts +33 -6
- package/src/utils/log-utils.ts +10 -0
- package/src/utils/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +102 -22
- package/src/web-research/web-scraper.ts +27 -16
- package/src/youtube/potoken.ts +8 -1
- package/src/youtube/transcript-client.ts +56 -5
- package/dist/prompts/system-lead-evaluator.md +0 -118
- package/src/prompts/system-lead-evaluator.md +0 -118
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,231 @@ 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]
|
|
9
|
+
|
|
10
|
+
## [1.4.3] - 2026-08-19
|
|
11
|
+
|
|
12
|
+
v1.4.2 was tagged and pushed but never published: the release workflow's
|
|
13
|
+
`publish` job restructuring below (removing `actions/checkout` entirely, to
|
|
14
|
+
close the OIDC-exposure gap) also removed `.nvmrc`, which `actions/setup-node`
|
|
15
|
+
needs on disk — the job failed at the very first step, before ever reaching
|
|
16
|
+
`npm publish`. No package was published under 1.4.2; this is that fix,
|
|
17
|
+
re-tagged as 1.4.3.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **The `publish` job failed immediately: `actions/setup-node`'s `node-version-file: '.nvmrc'` requires that file on disk, and the job no longer checked out the repository at all.** Restored via a minimal sparse checkout of just `.nvmrc` — checkout itself executes no dependency code, so the actual guarantee the OIDC-exposure fix established (no `npm ci`/`install`, hence no untrusted lifecycle scripts, in the credential-holding job) is unaffected.
|
|
22
|
+
|
|
23
|
+
## [1.4.2] - 2026-08-19 (tagged, never published — see 1.4.3)
|
|
24
|
+
|
|
25
|
+
Five further fresh-eyes audit rounds on top of 1.4.1, covering subsystems the
|
|
26
|
+
1.4.1 audit hadn't reached: scripts/config/CLI, knowledge-store/embedding, and
|
|
27
|
+
browser/network first; then orchestration router/synthesizer, locks/scheduler,
|
|
28
|
+
tools, repo hygiene; then knowledge/embedding, CLI/SDK, security/YouTube
|
|
29
|
+
internals, web-scraper/PDF, and CI/release scripts again from a second pass;
|
|
30
|
+
then a re-audit of the first three rounds' own diffs plus CI/release workflow
|
|
31
|
+
robustness and state/lock lifecycle; then a fifth round covering release/CI
|
|
32
|
+
credential handling end to end, web-scraper redirect classification, tool
|
|
33
|
+
budget/validation/steering-concurrency gaps, embedding-server shutdown vs. its
|
|
34
|
+
own GPU serialization queue, and knowledge-store migration manifest
|
|
35
|
+
persistence.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **Every browser task dispatched to the same worker process shared one Playwright `BrowserContext`**, letting cookies/storage bleed across concurrent, unrelated research sessions (`WORKER_CONCURRENCY` defaults to 2). Never a deliberate isolation trade-off — just an unexamined resource-sharing default, inconsistent with the rest of the codebase's per-session scoping (circuit breakers, scrape caches). Each task now gets its own fresh context via `acquireTaskContext()`, closed in its own `finally`; the browser process itself stays shared.
|
|
40
|
+
- **Knowledge-store corruption auto-recovery wiped the entire shared `knowledge_db` directory** — every project's data, every global entry, and every `_backup_*` safety net — the instant any single workspace hit a corrupted LanceDB manifest. Scoped the recovery to delete only the active table's own directory.
|
|
41
|
+
- **A WASM PDF document leaked native memory whenever both `toMarkdownAll()` and the per-page fallback failed** — the routine case for a malformed/encrypted PDF, in a browser-server process that stays warm across many sessions. `doc.free()` now runs unconditionally in a `finally`.
|
|
42
|
+
- **`PI_RESEARCH_CONFIG_DIR_NAME` (feeds `config.env`/state/`knowledge_db` paths, all under `HOME`) had no path-traversal guard**, unlike `cleanup.cjs`'s own copy of the same resolution — a value like `../../tmp/evil` could silently relocate where API keys get written. Applied the guard both places now share.
|
|
43
|
+
- CPU-fallback re-warmup after a WebGPU failure hardcoded `'mean'` pooling instead of the model's actual `poolingMode` (wrong for bge-m3/Qwen3-Embedding models) — inconsequential for the dummy warmup inference, but inconsistent with every other `warmupPipeline` call site in the embedder. Fixed to use the real pooling mode.
|
|
44
|
+
- Three CodeQL alerts: a predictable `Date.now()`/`Math.random()` temp-dir name in a test (`js/insecure-temporary-file`) replaced with `mkdtemp`; a dead assignment to `lastAnnouncedRound` with no later read; a dead `?? ''` on an already-non-nullable string parameter.
|
|
45
|
+
- **Browser task deadlines never budgeted for a worker's first real Camoufox launch.** Search/scrape/healthcheck timeouts assumed a warm worker, but eager warmup is deliberately disabled, so a normal cold start (up to 120s combined launch + context creation) on a fresh or just-reset worker could be misreported as "worker likely died mid-task", cascading toward unnecessary pool teardown. Added `COLD_START_ALLOWANCE_MS` as a single source of truth, folded into the scheduler's task ceilings, the healthcheck budget, and `browser-search.ts`'s mirrored per-query timeout.
|
|
46
|
+
- **The synthesizer's and router's overflow warnings only checked the reduced corpus against its own budget.** Below `MIN_SYNTHESIS_CORPUS_CHARS` that budget is a fixed floor, not a value derived from the model's context window, so a large global source list or run context could still overflow the real window with no warning. Both now also check the total against the real window directly.
|
|
47
|
+
- **`stackexchange_quota_low_total`/`_exhausted_total` were incremented as a side effect of `getQuotaInfo()`**, a method called up to three times per tool invocation for display — recording one real event as 2-3x the count. Moved the increments to the single gate check that actually observes each event.
|
|
48
|
+
- **The release workflow granted `id-token: write` (the npm OIDC trusted-publishing credential) to the whole workflow**, not just the job that publishes. Every job runs `npm ci` with full lifecycle scripts before its own lint/audit/test steps execute, so a malicious postinstall/prepare script in any dependency could have requested and exchanged that credential from a job that never legitimately needed it. Scoped to only the `publish` job.
|
|
49
|
+
- **A direct browser navigation to an oversized PDF downloaded the whole body before the size cap ever ran.** Playwright's `page.goto()` does not resolve until the full response finishes downloading, so a multi-hundred-MB PDF was pulled entirely into the worker process — risking an OOM kill that strands sibling tasks — before the existing post-goto size check could reject it. `executeScrapeTask` now inspects the main-frame response's Content-Length as soon as headers arrive and closes the page immediately when it already declares an oversized PDF, cutting the download off in flight rather than letting it finish first. A chunked or dishonestly-labelled response still falls through to the existing post-download byte check. The browser scrape layer also now falls back to a `.pdf` URL extension when content-type is missing or mislabelled (e.g. `application/octet-stream`), matching the fetch layer — previously such a response was serialized as HTML, silently losing the document.
|
|
50
|
+
- **The YouTube timedtext fetch validated only the first-hop URL against its trusted-host allowlist**, then followed redirects with default (`follow`) semantics and no revalidation — a 3xx `Location` header could have carried the PoToken-bearing request to an untrusted host. Now follows redirects manually, re-validating each hop against the same allowlist, mirroring `web-scraper.ts`'s existing SSRF redirect pattern.
|
|
51
|
+
- **The YouTube BotGuard attestation-server response was read with a bare `.json()` call** — no `.ok` check and no size cap on an externally-controlled body. Now checks the response status and reads through the codebase's existing `readJsonCapped`.
|
|
52
|
+
- **The embedder's input-truncation guard could split a UTF-16 surrogate pair at the cut boundary**, emitting an invalid lone high surrogate into the tokenizer for text containing an astral character (emoji, rare CJK) near the cap. Backs off one unit, matching the equivalent guard already in `truncateWithMarker`.
|
|
53
|
+
- **`initResearchSDK()` called while a shutdown was still in flight silently no-op'd, believing the SDK was ready.** `isInitialized` only flips to `false` near the end of `_doShutdown()` (after container disposal), so a caller landing in that window hit the "already initialized" branch and returned immediately while the container was still mid-teardown. `initResearchSDK()` now waits out any in-flight shutdown first, mirroring `shutdownResearchSDK()`'s existing symmetric wait on an in-flight init.
|
|
54
|
+
- **A CI job could hang silently for its full timeout window with no diagnosable cause.** `integration-test (ubuntu-latest)` burned its whole 60-minute budget when `npx playwright install-deps firefox` (apt-based) stalled, with no step-level bound to fail it fast. Added `timeout-minutes` to every bare apt-get/brew/choco/npx-network step across `ci.yml` and `release.yml`. A follow-up pass found the same exposure one level up: 6 jobs (including release.yml's OIDC-privileged `publish` job) had no job-level timeout at all, defaulting to GitHub's 6-hour ceiling — bounded all of them.
|
|
55
|
+
- **`validate` (the 3-OS pack/install/execute job) had no `needs:` gate on `lint`/`type-check`**, unlike its sibling jobs `integration-test` and `dep-graph`, which explicitly gate for the documented reason of not burning multi-OS runner minutes on an already-red build. Added the same gate.
|
|
56
|
+
- **The YouTube timedtext/BotGuard fetches (above) used the plain global `fetch`, with no connect-time SSRF pin** — the host allowlist constrains which hostname a redirect can name, but not what that hostname resolves to at the moment the socket opens, and this codebase already treats a resolver answering differently between validation and connect as a real threat elsewhere (the DNS-rebinding backstop in `thread-worker-messaging.ts`, which applies even to already-trusted hosts). Wired in the same `getSsrfSafeFetcher()` dispatcher-paired fetch already used by `web-scraper.ts`.
|
|
57
|
+
- **The release workflow's `publish` job held the npm OIDC trusted-publishing credential (`id-token: write`) for its entire duration, including its own `npm ci`** — which runs full dependency lifecycle scripts *before* any of that same job's lint/audit-gate/type-check/test steps execute. A compromised transitive dependency's postinstall script could have requested and exchanged that credential before a single check had a chance to fail the build — the exact risk `test-install`'s own comment already reasoned about for *other* jobs, just not the one that actually holds the credential. Restructured: `test-install` now also runs the full check suite (lint/audit-gate/type-check/deps:check/test:unit) and uploads the built, verified tarball as an artifact; `publish` (the only job with `id-token: write`) no longer runs `npm ci`, `npm install`, or any other step that executes a dependency's lifecycle scripts — it only downloads that pre-vetted tarball and runs `npm publish` on the file directly, which triggers no lifecycle scripts at all.
|
|
58
|
+
- **`ci.yml`'s `dep-graph` job (`contents: write`) checked out with default `persist-credentials: true`**, writing the write-scoped `GITHUB_TOKEN` into `.git/config` on disk *before* its own `npm ci` (on any cache miss, on every branch push) ran untrusted lifecycle scripts — a compromised dependency's postinstall could `git push` an arbitrary commit straight to the branch with no review, no OIDC exchange needed since the credential was already materialized on disk. Set `persist-credentials: false` on checkout; the token is now only ever pulled into memory in the final commit-and-push step, via an explicit authenticated remote URL.
|
|
59
|
+
- **`ci.yml`'s `validate` job passed a live `GITHUB_TOKEN` to `npm ci` alongside `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`** — but `setup.cjs` checks that skip flag first and returns before ever reaching the GitHub-releases fetch the token exists to authenticate, so every other dependency's postinstall/prepare script in the tree got a live token for no functional reason. Removed.
|
|
60
|
+
- **The fetch-layer scraper classified a response as PDF-vs-HTML using the pre-redirect URL, not the followed one.** `scrapeWithFetch`'s manual redirect loop tracks the real destination in `currentUrl`, but both the pre-download size-cap check and the actual extraction dispatch checked `url.toLowerCase().endsWith('.pdf')` against the stale original parameter. A legitimate large PDF served from a URL without a `.pdf` suffix (common for document-management/CDN download endpoints) that redirects to its real file could be rejected under the 25MB HTML cap instead of the 100MB PDF cap; the reverse case let a non-PDF landing page bypass the smaller HTML cap. Both checks now use `currentUrl`.
|
|
61
|
+
- **`security_search` spent one of the researcher's shared `MAX_GATHERING_CALLS` slots on a request that did zero work** — the same "malformed call forfeits a real call" bug class 1.4.1 fixed for `youtube_transcript`, left in a later gate within this tool. `options.tracker.recordCall()` ran before the semantic checks that reject unknown database names or an unrecognized severity, both of which return without ever querying a database. Moved the budget-charging call after every semantic-validation reject.
|
|
62
|
+
- **A finishing headless (non-TUI) `research` call could wipe a concurrent sibling headless run's queued/active steering messages.** `teardownUi()`'s own steering-clear guard (`getPiActivePanels(...).length <= 1`) is blind to headless runs — `getPiActivePanels` is populated only by the TUI's `registerSessionPanel`, so for headless sessions it always read 0, making the guard a no-op — and the headless `cleanup()` closure then cleared steering a second time, completely unconditionally. Added `getActiveResearchRunCount()` (backed by the `aborts` map, populated by both the TUI and headless branches) as a mode-agnostic concurrency signal, and removed the unconditional clear.
|
|
63
|
+
- **The top-level `research` tool was the one tool in this codebase that never validated its own parameters against its declared schema** — every sibling tool (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `research_knowledge_search`) explicitly re-checks with `Value.Check` before trusting `params`, since the host does not enforce this. `research-tool-definition.ts` cast `params as ResearchParams` with no runtime check, relying entirely on incidental downstream validation. Added the same gate, using a structural-only variant of the schema for the runtime check so the existing, better-messaged `validateInitialLinks()` bounds errors ("at most 20 URLs...", "at most 2048 characters...") still surface instead of being masked by a generic rejection.
|
|
64
|
+
- **`uninstallSkill([])` (an explicit empty tool-id list) was indistinguishable from `uninstallSkill(undefined)` and silently uninstalled everything**, because the gate was `toolIds && toolIds.length && !toolIds.includes(tool)` — `[].length` is falsy, so the whole condition short-circuited to "keep nothing filtered out." No production call site currently passes `[]`, but it was a live trap for any future caller and inconsistent with this file's other list params, where empty means none. Now `toolIds !== undefined` gates the filter, so `undefined` still means "every entry" and `[]` correctly means "none."
|
|
65
|
+
- **`redactSecrets()` could silently drop the tail of an oversized log message with no truncation marker.** The `REDACT_SCAN_LENGTH` (40,000 char) cut discards everything past that point *before* redaction runs, but the final "…[truncated N chars]" marker was only appended when the *post-redaction* length still exceeded `MAX_LOG_MESSAGE_LENGTH` (10,000). A message whose scanned prefix was dominated by content the redaction patterns collapse heavily (many long hex/JWT runs, each replaced by the much shorter `[REDACTED]`) could end up under that threshold even though real content beyond the scan cut was discarded — reading as complete while silently hiding the tail of a large scraped page or provider error body. Now also appends the marker when the scan cut alone truncated the message, even if redaction's own output stays under the length cap.
|
|
66
|
+
- **`EmbeddingServer.shutdown()` could dispose the embedder pipeline while a legitimately slow (not hung) inference was still running on it.** The `SerialQueue` exists specifically to prevent concurrent native GPU calls (a prior segfault class) and deliberately holds its slot past its own 120s soft timeout for exactly this reason — but `shutdown()` runs on ordinary leadership loss too, not just the poisoned-queue path, and its only guard against an in-flight call was `Embedder.dispose()`'s own 5-second drain, an order of magnitude under what the queue itself considers still-healthy work. A leader serving a large `embedMany` batch that legitimately took 20-30 seconds, racing a leadership-check tick, could have its pipeline disposed out from under the still-running call — the exact concurrent-native-access hazard the queue exists to prevent, just triggered by disposal instead of a second call. Added `SerialQueue.waitForIdle()`, awaited before any `disposeEmbedder` shutdown disposes the pipeline.
|
|
67
|
+
- **A re-embed migration could silently orphan its own freshly-migrated data.** After successfully renaming the temp migration directory to the canonical `knowledge.lance` name, `migrationReEmbed` called `saveManifest()` to persist that new name but discarded its (documented, non-throwing) `false`-on-failure return value — unlike the pre-drop save nineteen lines earlier, which does check it. If that save failed (disk pressure, a killed process), the manifest was left pointing at the now-renamed-away temp name; the next process start would find no such directory, silently create a brand-new EMPTY table under it, and strand every re-embedded document in the unreferenced canonical directory with no recovery path. Now checked: on failure, the rename is reverted (restoring the manifest/disk agreement the earlier successful save already established) rather than thrown as a migration failure — throwing here would have tripped the caller's re-embed-failure fallback to the `backup` strategy, which would have needlessly relocated this perfectly good, fully re-embedded data to a `_backup_*` directory over what is fundamentally a single failed file write.
|
|
68
|
+
|
|
69
|
+
### Deferred
|
|
70
|
+
|
|
71
|
+
- A CLI SIGINT-during-init race — already closed in practice by the `initResearchSDK()` fix above, since `shutdownResearchSDK()` already awaits an in-flight init before tearing down.
|
|
72
|
+
- `scripts/cleanup.cjs`'s hardcoded `.pi` config-dir fallback (vs. the runtime's `getConfigDirName()`, which additionally consults the host `pi` package) — latent, since it only diverges if pi itself ever renames its config directory, and requiring the host package from an uninstall-time script that must never fail was judged not worth the risk for a currently-inert gap.
|
|
73
|
+
- A knowledge-store migration-vs-concurrent-write race (`addDocuments()` not covered by the same cross-process lock `open()` uses) — architecturally complex, needs a dedicated pass.
|
|
74
|
+
- A low-confidence, unreachable-at-current-call-sites race in `clearEmbeddingInstance()`, flagged by CodeQL but not backed by a concrete interleaving — reviewed and deliberately left alone.
|
|
75
|
+
|
|
76
|
+
### Verified
|
|
77
|
+
|
|
78
|
+
- Every fix carries a regression test proven to fail against the pre-fix code and pass after (stash-and-rerun discipline).
|
|
79
|
+
- Full suites green across all five rounds: unit tests (2726-2747 depending on round), 57 + 103 integration (real browsers, including a live YouTube network fetch), lint, both type-checks, `deps:check`, the production audit gate, the build, and a live end-to-end research run against the built/published CLI.
|
|
80
|
+
- The fourth round's own changes re-verified after the fact: full unit suite (2740), lint, type-check, and `deps:check` all green; both workflow YAML files parse; the transcript-client test suite (which stubs global `fetch`) needed an accompanying `getSsrfSafeFetcher` mock to keep exercising the stub once the fetch path switched to the dispatcher-paired fetch — added, matching the same pattern already used by `web-research`'s own tests.
|
|
81
|
+
- The fifth round's own changes re-verified: full unit suite (2747, up from 2740 — six new regression tests plus a schema-validation test file), lint, both type-checks, `deps:check`, the production build, and the tarball manifest check all green; both workflow YAML files parse. Also fixed in the same round, discovered by the new schema-validation gate itself: the runtime check initially used the full public parameter schema (bounds included), which caught `initialLinks` count/length violations before the existing, better-messaged `validateInitialLinks()` check could — regressing two of its error-message assertions. Split into a structural-only runtime-check schema so those specific messages still win.
|
|
82
|
+
- A test-quality-only fix alongside the production fixes above: `browser-server.test.ts`'s headers-sent-guard test asserted "no unhandledRejection surfaced" after a fixed 25ms wait, which fails OPEN (silently loses coverage, not a flaky red build) on a slow/contended CI runner. Now flushes several real event-loop turns before a longer (100ms) backstop.
|
|
83
|
+
|
|
84
|
+
## [1.4.1] - 2026-08-17
|
|
85
|
+
|
|
86
|
+
v1.4.0 was tagged and pushed but never published: CI failed on every commit from the
|
|
87
|
+
moment `search-burst-end-to-end.test.ts` was added through the release commit itself —
|
|
88
|
+
10 of 10 runs, all three platforms, over twelve hours — so the `require-ci-green` release
|
|
89
|
+
gate never opened and `npm publish` never ran. An independent re-audit of the whole
|
|
90
|
+
1.3.12..1.4.0 diff, distrusting the prior review, found the test failure's actual cause
|
|
91
|
+
plus eight more defects.
|
|
92
|
+
|
|
93
|
+
### Fixed
|
|
94
|
+
|
|
95
|
+
- **The CI-only test failure was deterministic, not flaky.** The three failing tests in `search-burst-end-to-end.test.ts` fire a 120ms real-timer budget immediately after a hook/import boundary with zero settle time; ordinary CI-runner scheduling jitter at that boundary can consume the whole budget before a single query dispatches. The one test in the file that always passed happens to have an incidental 50ms warm-up delay before its own timing-critical section, for an unrelated reason (letting scrapes take the pool first) — added to the other three, since that is the actual difference between 0/10 and reliable passes. Never reproduced locally, including under heavy artificial CPU contention: the gap is specific to CI's virtualized/shared compute, not to load in general.
|
|
96
|
+
- **A hardcoded 150s healthcheck timeout silently overrode this release's own dynamic budget.** `registerHealthChecks` wrapped the `BrowserRuntime` check in `Math.max(healthTimeoutMs, 150000)`, never consulting `getHealthCheckBudgetMs()` — the function 1.4.0 added specifically to out-wait the longest a task can legitimately hold a worker slot. At raised timeouts the derived budget legitimately exceeds 150s, so the registry-level race aborted the check ~95s before a merely-busy (not wedged) pool could ever answer, reintroducing 1.4.0's headline bug one call-frame further out. Now takes the max of both.
|
|
97
|
+
- **The synthesizer's fixed overhead reserve didn't cover two unbounded inputs it claimed to.** `SYNTHESIS_OVERHEAD_TOKENS` documented itself as covering the system prompt, the global source list, and the run context, but only the first is actually fixed-size — the source list grows with every citation the run collected and the run context carries the entire cross-session query history, both uncapped, both riding along on every partial and merge pass. A large enough citation list or query history could reproduce the exact context-window overflow the 1.4.0 router/synthesizer split was built to eliminate, just via a different input. `synthesisCorpusBudgetChars` now takes their actual character length and subtracts it directly.
|
|
98
|
+
- **A busy-but-alive embedding leader could stall for up to ten minutes.** The poll loop's "~25s before declaring a leader's registration stale" accounting assumed a 2s health-check timeout; `EmbeddingClient`'s actual default is 120s. A leader whose event loop was merely busy (not dead) could take the full 120s to answer a single poll, blowing the outer 120s wait long before the unreachable-poll counter ever incremented — turning "wait a poll, try again" into "give up and re-run the whole election," repeatedly, against a leader that was never actually down. `fetchHealth()` now takes an explicit timeout; the poll loop passes 2s.
|
|
99
|
+
- **A live-but-CPU-busy lock holder could be falsely reported as wedged.** The knowledge-store init lock's `lockTimeout` (20s, class default) is the *waiting* side's own give-up bound — separate from, and far shorter than, `liveOwnerStaleThreshold` (120s), the bound that actually governs when a live holder's lock may be reclaimed. This lock protects a post-run FTS rebuild with no fixed duration; a single large synchronous call (e.g. better-sqlite3, which blocks the event loop for its whole duration) longer than 20s would starve the holder's heartbeat and make a waiting caller give up on a holder that was never wedged. Raised to match `liveOwnerStaleThreshold`'s own default.
|
|
100
|
+
- **`tool_security_search_calls_total` was reported at exactly 2x the real count.** One unconditional increment at the top of `execute()` plus a second, labeled increment on every exit path — the same double-counting pattern this release fixed for `search_queries_total`, left uncaught in its sibling tool.
|
|
101
|
+
- **A bolded coverage-digest header leaked its own markdown into the digest text.** The terminator side already consumed its whole delimiter line so a trailing `**` couldn't dangle into the report body; the header side had no equivalent handling, so `**COVERAGE DIGEST**` (bolded, as models routinely write section headers) left a stray `**` at the head of the digest.
|
|
102
|
+
- **The prompt-cache/session-affinity passthrough this release added wasn't wired into `research_knowledge_search`'s LLM calls**, unlike the planning-service calls it was built for. No correctness impact — just an inconsistent rollout of a same-release feature.
|
|
103
|
+
- **Stale doc comments describing the pre-restore, digest-only router protocol** survived in `planning-interfaces.ts` and two `deep-research-orchestrator.test.ts` comments — the exact staleness class 1.4.0's own release-diff audit had already hunted down and fixed elsewhere in this same diff.
|
|
104
|
+
|
|
105
|
+
### Verified
|
|
106
|
+
|
|
107
|
+
- Full suites green: 2710 unit tests, 57 + 103 integration (real browsers), typecheck.
|
|
108
|
+
- Each production-code fix carries a regression test that fails against the pre-fix code.
|
|
109
|
+
|
|
110
|
+
## [1.4.0] - 2026-08-17
|
|
111
|
+
|
|
112
|
+
Minor, because the research lead is now two roles rather than one call doing both jobs:
|
|
113
|
+
a **router** that decides each round whether to continue, and a **synthesizer** that
|
|
114
|
+
writes the report once, at the end, under a budget derived from the model's context
|
|
115
|
+
window. That started as an investigation into prompt caching — how pi and each provider
|
|
116
|
+
implement it, whether pi-research's own prompts are shaped so it can work, and what it
|
|
117
|
+
was actually doing — which was instrumented first and then fixed against the
|
|
118
|
+
measurements, and which is what showed that the evaluator's cost was repetition rather
|
|
119
|
+
than a caching misconfiguration.
|
|
120
|
+
|
|
121
|
+
The rest is the largest correctness batch in the 1.3 line: search bursts no longer
|
|
122
|
+
discard their own tail, browser task deadlines and durations stop charging queue wait to
|
|
123
|
+
the work, and a run's round accounting, health verdicts and lock waits were each wrong in
|
|
124
|
+
a way the logs had been recording for months.
|
|
125
|
+
|
|
126
|
+
### Added
|
|
127
|
+
|
|
128
|
+
- **Prompt-cache accounting.** `llm_cache_read_tokens_total` and `llm_cache_write_tokens_total` are now recorded next to `llm_tokens_total`, per component, wherever a billed call is booked. `llm_tokens_total` folds cached and uncached input into one figure, so a run whose cache never hit was indistinguishable from one that hit on every call — the cost moved, but nothing said why, and no prompt change could be evaluated. Zero is still emitted when the provider reported the field, so "caching is off" and "provider does not report caching" stay distinguishable. Measured on a live depth-1 run through OpenRouter: researchers drew 12,960 cache-read tokens of 26,900 total, while the coordinator drew 96 — which is what exposed the next item.
|
|
129
|
+
- **A prompt-caching section in [CONFIGURATION.md](CONFIGURATION.md)**, covering when pi marks a request up with explicit `cache_control` (on `anthropic-messages` always, and on `openai-completions` whenever the provider's `compat.cacheControlFormat` is `"anthropic"` — the gate is that key, not the `api` field), the case auto-detection misses, the `compat` block that fixes it, and how to verify the result from the counters above.
|
|
130
|
+
- **Coverage digests.** Each researcher in a delegated run now opens its report with a short `COVERAGE DIGEST` block — the goal as assigned, what it established, what it could not substantiate, what remains, and its source count. The block is split off the report at the single point every report enters the system, so the stored body, the synthesis corpus, the citation normalization, the fallback synthesis and the SDK's `researcher_complete` event all see the same digest-free report they saw before. A researcher that emits none gets one derived mechanically from its body, which says so in as many words: a research lead shown nothing for a researcher concludes it produced nothing and re-delegates work already done. Quick research is not asked for one — it has no lead and no next round, and its report goes straight to the user.
|
|
131
|
+
|
|
132
|
+
### Changed
|
|
133
|
+
|
|
134
|
+
- **The research lead is now two roles instead of one call doing both jobs.** A **router** decides each round whether to continue; a **synthesizer** writes the report once, at the end, reading every report in full. The router reads each report in full on the ONE round it arrives, and thereafter only its coverage digest — so the decision always rests on real prose, while a report is never re-sent to a call that has already read it. Previously one call did both and therefore read the entire corpus on every round, which produced three problems at once: its input grew with the square of the round count (round *n* re-sent rounds 1..*n*); the final call could exceed the model's context window on a deep, steering-extended run — projected at 265k tokens against a 262,144-token window — where the failure is a provider rejection after the whole run has been paid for, degrading to a mechanical concatenation of raw reports; and the request shape could not be cached at all, for the reason described above. Splitting the roles removes the repetition rather than discounting it, so it needs no caching, no retention tuning, and behaves identically on every provider. Routing input is now bounded by TEAM SIZE rather than round count: it carries one round of reports plus a digest per earlier researcher, so an extra round of history costs a digest rather than a re-send of everything before it — measured at two orders of magnitude less marginal cost per round of history. `CITED LINKS` is stripped from the reports the router sees, since routing uses the findings and not the bibliography, and the digest already carries the source count.
|
|
135
|
+
|
|
136
|
+
Reading each report exactly once is what keeps the fidelity of the old evaluator without its growth. An earlier iteration routed on digests alone; that was cheaper still, but it made the decision hostage to what each researcher claimed about its own work — a report whose digest said `Gaps: none` ended the run whether or not its prose supported that. The router is now given each fresh report **with** the digest its researcher wrote for it, and told to treat the digest as a claim to check against the prose underneath.
|
|
137
|
+
|
|
138
|
+
The router cannot produce a report, and two independent guards enforce that: unparseable router text is never salvaged into the report body (the synthesizer's is, since it has read every report), and a router that returns report content anyway has it discarded in favour of the real synthesis. The coordinator has no round-one direct answer to be affected: it was never reachable, and it is now removed rather than left as a trap (see below).
|
|
139
|
+
- **The final synthesis is bounded by the model's context window.** The corpus is budgeted against the window less the output ceiling and request overhead; when it does not fit, the reports are partitioned in order and reduced in partial passes before a merge pass writes the single report, rather than being truncated or sent to be rejected. A single report larger than the whole budget — where there is no partitioning left to do — is truncated with an explicit marker and a warning, so the model knows its tail is missing instead of reading the cut as the end of the findings.
|
|
140
|
+
|
|
141
|
+
- **The evaluator's request is now laid out so a prompt cache can hold it.** Providers cache on an exact prefix, and the evaluator was arranging its request so that essentially none of it qualified: the system prompt opened with the root query, round number, agenda, executed-query history and round-phase guidance, so a single round-varying byte sat near the front of every request and invalidated everything behind it. The global source list made it worse: it was prepended to the findings and grew by a line per new URL, moving every byte after it. The system prompt now interpolates only run-fixed values (complexity, team size, query budget, disabled tools) and is byte-identical across rounds; the run-varying context moves to a `RUN CONTEXT` block after the findings, and the source list sits between them. On Anthropic-style caching the old layout was worse than a miss: pi-ai places one breakpoint at the end of the system prompt, so a system prompt that changed each round paid the 1.25x write multiplier for an entry that could never be read, and because invalidation runs tools → system → messages, the message-level breakpoint could not hit either. Mutation-verified tests hold the layout in place.
|
|
142
|
+
|
|
143
|
+
**This is a prerequisite, not a complete fix, and the measurements say so.** The evaluator still reads zero cached tokens on both routes measured — Gemma via OpenRouter and GLM over `anthropic-messages` — across rounds of 32k and 82k tokens. The reason is that the evaluator is stateless: each round rebuilds ONE user message containing every report so far. Anthropic-style caching is block-granular — the breakpoint lands at the end of that single block, and round 2's block is not round 1's, so nothing before it can be reused no matter how the text inside is ordered. Three competing explanations were tested and eliminated: prompt size (a direct sweep shows this provider caching prefixes down to 704 tokens), session affinity (adding the session id moved nothing), and cache expiry between rounds (a direct probe reads an entry back unchanged after seven minutes at default retention, and `long` retention changes the number not at all). The remaining fix is structural — the router/synthesizer split described in the first entry above.
|
|
144
|
+
|
|
145
|
+
For contrast, the same runs show the researcher path — which *is* an accumulating conversation — serving 33% (Gemma) to 53% (GLM) of its prompt tokens from cache, up to 67% on its largest single turn. That is where the repetition actually lives: 1.28M researcher tokens against 114k evaluator tokens on the GLM run.
|
|
146
|
+
|
|
147
|
+
**Measured again across nine instrumented runs, including two entirely on `anthropic-messages`: the researcher path reads 51.9% of its tokens from cache (127 calls, 2.53M of 4.88M), and router, synthesizer, coordinator and both knowledge components read exactly zero.** The zero is not a configuration fault and the router/synthesizer split did not cause it — those components are invoked **once or twice per run**, so there is no prior identical prefix within a cache entry's lifetime for a second call to hit. Round-invariance was a real improvement and a necessary condition; it was never the binding one. Two further ceilings sit under it: the synthesizer's system prompt is ~930 tokens, below Anthropic's 1,024-token minimum cacheable prefix, and every agent system prompt is fronted by a day-granular temporal anchor (`injectCurrentDate`), so no prefix is shareable across days. The anchor stays where it is — it exists to stop a model answering "latest" questions against its training cutoff, and moving it would buy nothing while these components are called once per run. `test/unit/core/llm/inject-date-prefix.test.ts` pins that reasoning, and tests the real function: the planning-service suite mocks it to identity, so its byte-identical system-prompt assertions do not cover the prompt that ships.
|
|
148
|
+
|
|
149
|
+
One accounting caveat worth stating plainly: `llm_cache_write_tokens_total` is structurally always zero on both routes, and the intended distinction between "the provider reported zero" and "the provider does not report caching" is not reachable in practice — both adapters in use always emit a number, so the presence check that was meant to separate them can never be false.
|
|
150
|
+
- **The coordinator, evaluator and their JSON-repair passes now send the research session id** as the provider's prompt-cache / session-affinity key. pi-ai forwards it as OpenAI's `prompt_cache_key` and OpenRouter's `x-session-id`; without it OpenRouter falls back to hashing the first system and user messages, which every round of a research run mutates, so it re-routes between rounds and re-warms from cold. Providers that do not implement session affinity ignore the field.
|
|
151
|
+
|
|
152
|
+
### Fixed
|
|
153
|
+
|
|
154
|
+
- **A search burst larger than the worker pool destroyed most of itself.** `performSearch` handed every query to `Promise.all` at once while only `WORKER_THREADS` could run, and each query's deadline started at **enqueue** rather than when a worker picked it up. The number of queries that could possibly succeed was therefore `workers x (budget / latency)`, not the number asked for. Measured on a level-3 run: 100 queries dispatched into 6 workers went 94 deep in the shared queue, 78 aborted at the 90s mark having never run, 22 completed with a median of 61s, and the burst returned 49 results. Each abort was then reported to its researcher as a timeout whose message says "retrying the same query later is reasonable", so the next round re-queued them and the following burst reached 179. Queries are now dispatched through a bounded set of lanes: a lane takes its next query only when the previous one finishes, so the shared queue never builds a backlog and every query runs. Bursts take longer in wall-clock terms; they no longer discard most of themselves to get there. Lanes are not what makes a deadline honest — the dispatch-armed timers described below do that, and they hold at any queue depth — but bounding depth is worth having on its own, since the pool is shared with scrapes, healthchecks and other concurrent runs.
|
|
155
|
+
- **A queued browser task's deadline counted the time it spent waiting.** `BrowserTaskScheduler` armed each task's timer before enqueuing — the message even said "including queue wait" — so a task behind a saturated queue burned its whole budget waiting and then aborted having done no work. The timer is now armed when the queue dispatches the task, so waiting is bounded by the caller's signal and the run-level timeouts while a task that runs always gets its full budget. This is what lets the queue absorb an arbitrarily large burst: it makes work wait rather than discarding whatever it could not reach in time. A task that is genuinely slow *once running* still fails, so a hung worker cannot pin a slot.
|
|
156
|
+
- **Two more layers were charging queue wait to the work, and one of them was the same bug the layer below had just been fixed for.** Moving the scheduler's deadline to dispatch time is only correct if nothing above it keeps a shorter clock running from call time, and two things did.
|
|
157
|
+
|
|
158
|
+
`performSearch` kept its own per-query guard, armed before the query reached the browser queue. That is the layer the failure was actually reported through: on the run that exposed this, all 100 queries entered the queue at 05:46:14.906 and 78 rejected in unison at 05:47:44.90 — 90.000s later, to the millisecond — while 22 had completed. The scheduler now reports dispatch through an `onDispatch` callback and this guard is armed from it, so waiting is free and running is still bounded. Each retry attempt re-arms rather than inheriting what its predecessor spent. Against a follower scheduler, which sees only the leader's final reply and so cannot observe dispatch, the callback is never invoked and the guard stays disarmed — the follower bounds the call itself rather than guessing.
|
|
159
|
+
|
|
160
|
+
The follower's own request timeout was derived as leader budget plus a fixed margin, under a comment asserting the follower is always at least as patient as the leader. That invariant no longer held the moment the leader's budget stopped covering queue wait: leader turnaround is now wait plus budget, which no value derived from the budget alone can out-wait. Search and scrape requests therefore use the absolute cap instead, which is what that cap was always documented to be — a detector for a leader that is listening but no longer answering. The healthcheck stays budget-derived, because its leader side does bound the whole call.
|
|
161
|
+
|
|
162
|
+
Logs going back to the earliest retained file show this is not a new failure: 44% of queries starved on 2026-07-17 and 27% on 08-03, against 22% on the night it was finally diagnosed, with zero on the thirty days in between. The condition is arithmetic rather than intermittent — starvation whenever burst size exceeds `workers x (budget / median latency)` — and it hid partly because the two layers report it through different messages, so grepping for either one shows clean gaps.
|
|
163
|
+
- **The worker's task deadline never reached a search or a health check.** `runTask` armed an AbortController from the scheduler's budget and then handed the signal to the scrape path only; on the other two it fired into nothing, and the sole use of it was a check *after* the work had already returned, which relabels a result but ends nothing. Playwright's per-verb timeouts bound each action but not their sum, so the work could outlast the whole task budget — and poolifier counts a node as executing until its worker replies, so an overrunning task kept its worker marked busy long after the caller had given up. Search and the health probe now bind the signal to their page, which is what actually ends the work: any in-flight call rejects with 'Target closed', which the catch already maps back to the timeout. That mechanism was already in the scrape path a few lines away.
|
|
164
|
+
- **An abandoned browser launch leaked a Firefox until the pool shut down.** The 90s launch race attached only a rejection handler, so a launch that completed *after* the race was lost had no owner — the catch block promising to "close any partially-launched browser" found null, and the orphan sweep spares it because its parent worker is alive. Camoufox's own launch bound is 180s against our 90s, so the window is real. The late result is now adopted and closed, as `createPageSafe` already does for an orphaned page; the same is applied to context creation.
|
|
165
|
+
- **A retry that had to queue was killed by the previous attempt's clock.** Arming the per-query guard on dispatch was not sufficient on its own: between an attempt failing and its retry being dispatched there is a backoff sleep, a re-enqueue and a fresh queue wait, and the predecessor's timer ran across all of it. It would fire mid-wait and abort a query that had executed for two seconds, reporting it as pool saturation — the same charge-the-wait-to-the-work mistake, one attempt removed. The scheduler now reports when a task stops executing as well as when it starts, and the guard runs only between those two edges.
|
|
166
|
+
- **The follower's wedge detector could never fire before the caller that owned the request was killed.** It was a flat 300s cap, while `RESEARCHER_TIMEOUT_MS` defaults to 300s and bottoms out at 180s — so a wedged-but-listening leader surfaced as a bare researcher timeout with nothing said about the browser, where previously the user at least got a request timeout they could classify and retry. It is now derived from the owning caller's budget, so it always fires first. Where the leader's own task budget is configured so high that no value can both out-wait it and fire in time, firing first wins: a detector that never fires detects nothing.
|
|
167
|
+
- **One degraded browser worker took a share of every burst down with it.** The pool chose workers by round robin — a bare modular cursor whose only filter is a one-time `ready` flag. poolifier consults a node's capacity only *after* the strategy has picked it, and a task handed to a busy node is queued **on that node** and never re-routed. On 2026-08-15 a worker whose browser launch hung was still handed roughly one turn in six, and the run lost exactly the three queries that landed on it — while five of six workers idled and the shared queue stood empty, which is why the failure read as "starved waiting for a free browser worker" and took a night to find. The pool now chooses by `LEAST_USED`, minimising executing plus queued.
|
|
168
|
+
|
|
169
|
+
The guarantee that buys is structural rather than statistical: the priority queue above caps in-flight work at exactly `WORKER_THREADS x WORKER_CONCURRENCY`, which is the pool's total capacity, so at every dispatch there is a node with a free slot and an empty queue, and a strategy that reads load always finds it. That rests on the two capacities agreeing — so the pool is now also rebuilt when `WORKER_CONCURRENCY` changes, not only `WORKER_THREADS`. Per-worker concurrency is baked into poolifier's queue options at construction while the priority queue recomputes its own capacity every task, so gating the rebuild on worker count alone let the two layers disagree about how many tasks a worker may run.
|
|
170
|
+
|
|
171
|
+
It removes a second, stranger failure with it. Because nothing is ever queued on a node, poolifier's task stealing never fires — and stealing is what triggers an accounting defect in poolifier itself: a stolen task's completion decrements the *original* node's executing count rather than the node that ran it, freeing a slot on a worker that is still working, which then legitimately accepts a second task. That produced **1,425 genuinely overlapping task pairs across 37 days at a configured concurrency of 1**, silently doubling load on individual workers.
|
|
172
|
+
|
|
173
|
+
This failure mode was invisible to every metric and log line. poolifier computed `queuedTasks`, `stolenTasks` and `idleWorkerNodes` on `pool.info` the whole time and nothing read them. `browser_pool_parked_tasks` — tasks queued on a node while another node is idle — is now sampled on the leadership tick, and should read zero.
|
|
174
|
+
|
|
175
|
+
One correction to the strength of that claim, from a later review: parking is made rare, not impossible. A load-reading strategy never parks a task *while a free node exists*, and the priority queue's cap normally guarantees one does — but that cap is released early when a caller aborts a task still running on a worker, and a respawning worker is not yet `ready`. In those windows every node is genuinely full, ties resolve to the lowest-index node, and a task parks. The stolen-task defect above is therefore rarer, not unreachable.
|
|
176
|
+
- **Five runs returned no report because a permanent provider error was retried like a transient one.** The researcher retry loop had no permanent-error branch at all: its only skip condition was run teardown. HTTP 402 "requires more credits, or fewer max_tokens" was therefore retried the full attempt budget — 30 retried attempts across 2026-08-14/15, every one belonging to an episode that went on to exhaust, **0 of 10 recovered** — after which the run threw with no output, having already paid for a search burst that collected 70 results and a synthesis call billed over an empty corpus. Insufficient credits is now classified as unretriable.
|
|
177
|
+
|
|
178
|
+
The classifier is deliberately narrow, and the log record is why. Almost everything else reaching this loop does recover: "produced no text output" 26 of 26; a provider rejecting `reasoning_effort: 'none'` about 18 of 19, because OpenRouter routes the same model to different upstream providers per request and only some reject it; token-limit truncation about 16 of 17. Treating any of those as permanent would break runs that currently succeed. A researcher timeout also stays retriable at roughly 88% recovery, even though three attempts at a 15-minute budget cost 45 minutes of wall clock — that cost is real, but the fix for it is a bound on total retry time, not refusing a retry that usually works.
|
|
179
|
+
- **Teardown could stall for five minutes on a lock that guards a sub-second write.** The new stall-based lock waiting introduced a five-minute ceiling sized for the knowledge-store init lock, which covers an FTS rebuild of no fixed duration. The shared *state* lock guards a temp-file write plus fsync plus rename, and SDK shutdown awaits one — so it inherited a ceiling fifteen times its previous bound. Given its own ceiling of 30s. A ceiling should match what its lock protects.
|
|
180
|
+
- **The one state clear issued on behalf of an instance its caller does not hold.** Teardown clears the embedding-server registration pid-scoped, before `disposeAllServices` — so when this process owns the live server, it deletes the registration of a server that is still listening, and that server's leadership check reads a missing entry as benign and never steps down. The unconditional form of this clear produced 80 orphan windows totalling 147 hours before it was pid-scoped (the worst ran four days, with a second embedding model resident alongside the invisible one); pid-scoping fixed the cross-process case and left the same-process one. The clear is now skipped when this process owns the live server, whose own dispose deregisters it moments later under a serverId compare-and-set — the owner-scoped teardown the browser server already uses.
|
|
181
|
+
- **A browser task's reported duration counted the time it spent queued.** `Search completed ... in Nms` and `browser_search_duration_ms` were both measured from before the task was enqueued — the same variable the deadline used, and only the deadline was moved. That number is what every latency investigation reads, and during a saturated burst it reported a **51s median against a true 5.8s execution median**, inventing an eightfold latency regression that had not happened and costing an evening explaining it. Execution and queue wait are now separate: duration measures the work, and `browser_search_queue_wait_ms` / `browser_scrape_queue_wait_ms` record the wait, with both printed on the completion line. A task that never reached a worker still reports its whole elapsed span rather than zero, so a timeout is not recorded as instantaneous. Applied to search, scrape and healthcheck.
|
|
182
|
+
|
|
183
|
+
The queue-wait figure was itself recorded only on the success path, which censored the tail it exists to expose: the tasks that wait longest are precisely the ones that do not succeed. It is now recorded on every outcome, labelled by whether the task was ever dispatched — an abandoned task's wait was still running when the caller gave up, so it is a lower bound rather than a completed measurement, and folding it in as a zero would have pulled the distribution downward in exactly the bursts where it should spike.
|
|
184
|
+
- **`[PriorityQueue] Concurrency updated to 6` was logged 709 times in a day, always to the value it already had.** It is called on a per-task path and the value almost never changes. Only real changes are logged now, with the old value alongside the new. A line that fires constantly and says nothing is one a reader learns to skip, including on the day it matters.
|
|
185
|
+
- **Two research runs failed because housekeeping held a lock longer than the waiter allowed.** The knowledge-store init lock also covers the post-run FTS rebuild and optimize — work with no fixed duration — while a waiter gave up after 20s of elapsed time. A run starting while another's housekeeping was still going therefore failed outright rather than waiting its turn: 2026-08-14 at 23:48:08 and 23:49:09, both surfaced to the user as run errors. `lockTimeout` now bounds a **stall** rather than a wait. Each retry looks for evidence the lock is moving — its holder's heartbeat refreshing the mtime, or the lock changing hands — and only a lock that has been motionless for the full budget times out; the message says which of the two bounds was hit, since "timeout after 20000ms" against a holder that was working the whole time sent readers looking for a deadlock that was never there. An absolute five-minute ceiling keeps "wait while progress happens" from meaning "wait forever" under sustained contention, and a try-take (the run semaphore's non-blocking slot probe) still fails immediately. Raising the constant was the available alternative and would not have worked, because housekeeping has no fixed duration.
|
|
186
|
+
- **A lock whose owner could not be identified was reclaimed on the crash-cleanup schedule.** `_isOwnerAlive(null)` returns true — unreadable content proves nothing about the holder — but `_shouldReclaim` then applied the threshold meant for a *proven dead* owner. The two halves openly disagreed, and the log printed the disagreement: `Cleaning up stale lock file (90s old, owner alive: true)`, a sentence describing the one thing the class exists to prevent. Both halves now treat unknown as alive, bounded by a ten-minute ceiling so the run semaphore's never-steal-from-a-live-owner opt-out cannot leave an unreadable slot file holding a run slot forever. The reclaim log now names the lock and says what was actually known about its owner.
|
|
187
|
+
|
|
188
|
+
Treating it as *fully* alive was an overcorrection, and it broke a real recovery: a lock file torn by SIGKILL between `open(...,'wx')` and `write()` is unidentifiable by construction, so holding it for the live-owner threshold made knowledge-store init **fail where it used to reclaim and proceed**. A pid is not the only liveness evidence a lock carries — a lock class with a heartbeat has its mtime refreshed by its holder, so an unreadable file that has *also* gone motionless is abandoned on that evidence alone. The unidentified window is now derived from the heartbeat cadence rather than from the live-owner threshold.
|
|
189
|
+
|
|
190
|
+
That still left the recovery unreachable, because two independent clocks were racing and the shorter one always won. The heartbeat interval derived from `liveOwnerStaleThreshold / 4` — 60s for the knowledge-store init lock — and the window doubles it, while the acquirer's stall bound gave up after 20s. So the lock became reclaimable at 120s and was abandoned at 20s, on every lock class configured here; the shared state lock was worse still, its 60s window sitting outside its own 30s acquire ceiling. Two changes close it: the heartbeat interval is capped at 5s (a `utimes` every few seconds costs nothing, and refreshing more often than required only makes a live holder harder to mistake for a dead one), and the acquirer no longer gives up on a lock it can see becoming reclaimable — the absolute ceiling still applies, and a try-take still answers immediately rather than waiting for any window.
|
|
191
|
+
- **Every process wrote to the shared log with no way to tell them apart.** Up to three concurrent research runs plus the elected browser-pool leader share one file, and records carried only a timestamp, level and message. Reconstructing the lock contention above meant inferring process boundaries from timing, which is guesswork — two runs starting a second apart interleave into what reads like a single process contradicting itself. Records now carry `pid`.
|
|
192
|
+
- **A busy browser pool was reported as a broken one.** The healthcheck probe's deadline included queue wait, so during a large search burst it expired without ever reaching a worker and the registry logged `critical component unhealthy mid-run: BrowserRuntime` — while the pool was healthily executing that very burst. It fired twice on the night measured, bracketing the saturated rounds. Arming the probe's deadline on dispatch, like every other task, is the fix: waiting is free and only execution is bounded.
|
|
193
|
+
|
|
194
|
+
Three attempts were then made to add a *second* verdict that would positively excuse a saturated pool, and all three were wrong. They are recorded because each reads sensibly and the next reader will otherwise reach for one of them. "Did the queue dispatch anything recently" cannot fire: healthcheck tasks have strict queue priority, so a freed slot dispatches *this* probe and cancels its wait timer — the timer only fires in a window where nothing dispatched at all. "Are the slots occupied" is wrong in the dangerous direction: `activeCount` rises before `pool.execute`, so six workers hung inside a browser launch are six occupied slots, and the exact incident this was written for would have reported **healthy**. "Has a task completed successfully recently" cannot fire either, for the first reason: a success frees a slot and a freed slot dispatches the probe, so at expiry the last success is necessarily older than the entire budget.
|
|
195
|
+
|
|
196
|
+
The general form of the mistake is that with strict priority, a probe that waited out its whole budget without being dispatched *is* the evidence of a wedge — there is no second signal to consult. The branch is gone. The queue's counters remain in the failure message, where they distinguish a worker wedge (slots busy, nothing completing) from a queue wedge (slots free, nothing dispatched) instead of standing in for the verdict. False positives are bounded by arithmetic rather than judgement: the probe allows 105s while every task that can hold a slot is itself deadlined well under that.
|
|
197
|
+
- **Every capped run announced a final round that never researched.** The loop incremented the round, logged `Round N/N`, fired `onRoundStart`, emitted an SDK `round_start`, and ran a full infrastructure health check — and only *then* tested the cap and broke, having dispatched no researcher. 92 of 93 capped runs in the retained logs did this; one spent **105 seconds** on the phantom round's health check. `onRoundStart` also arms the TUI's deferred panel clear, which is consumed by a researcher start that never comes. The cap is now tested before the announcement, and after steering consumption — a message consumed at the top of the final iteration legitimately buys a real round, and testing earlier would silently drop it.
|
|
198
|
+
|
|
199
|
+
The same off-by-one reached the model. Round-phase guidance and the RUN CONTEXT round line were computed against the *iteration* budget, so the router was calibrated as though another routing decision were coming when the one it was making was its last. Both now use the rounds that can still research. To be precise, because an earlier draft of this note overstated it: a delegation made at the last research round does run and its findings do reach the synthesizer — what did not exist was the round after it. A consequence worth naming is that with the 2/3/3 complexity budgets an unsteered run reaches the router exactly once, at that last round, so LATE is the only phase it sees; the EARLY and MIDDLE branches are now reachable only once steering has extended the budget.
|
|
200
|
+
|
|
201
|
+
The synthesizer is no longer shown a round line at all. It has no round decision to calibrate, and showing it one reintroduced the impossible number the change was meant to remove — the forced final synthesis runs *at* the cap, so it read `Current round: 2 / 1` on every complexity-1 run, and then promised a final synthesis pass to the call that was itself the final synthesis pass.
|
|
202
|
+
|
|
203
|
+
Two follow-on corrections. The final synthesis announced `maxRounds + 1`, a number outside the budget every other event is expressed in; harmless while the phantom round was also announced (1, 2, 3, then a 4 nobody minded), it left a hole once that stopped — a 3-round run emitted `round_start` 1, 2, **4**. It now announces the round it is synthesizing at. And the mid-run health check, which is skipped for round 1 by design, therefore no longer runs at all on a complexity-1 run, which has exactly one research round. That is correct rather than a gap: it is advisory logging, and it used to run only at the phantom round, probing infrastructure the synthesis pass ahead of it does not use.
|
|
204
|
+
- **A busy pool aborted the run outright, because the probe's failure message stopped saying "timeout".** `isBusyPoolHealthFailure` is what stops a probe that queued out under load from killing research, and it decides by regex-matching `/tim(e|ed) out|timeout/` on the component's error string. Rewording the probe's wait-expiry message to "Health check could not reach a worker within 105000ms" removed the only word that classifier looks for — so a saturated pool stopped reading as a timeout, the tolerance gate rejected it, and both `quick-research-orchestrator` and the TUI readiness check threw instead of proceeding. The user then got the last-resort "enable debug logging" text rather than the contention explanation written for exactly this case. The commit that introduced it was the one meant to stop a busy pool being reported as dead; it moved the abort one layer up. The message says "timed out" again, and a test now drives the real message through the real classifier so the two cannot drift apart silently.
|
|
205
|
+
- **The health probe's budget was a hardcoded 105s while the tasks it must out-wait are settable to 240s.** The wait-phase verdict rests on an arithmetic claim — the probe out-waits anything that can hold a worker slot, so a probe that never reached one has found a wedge — and that claim held for the default timeouts and no others. `SEARCH_TIMEOUT_MS` and `BROWSER_TASK_TIMEOUT_MS` are each documented as settable to 120 000, so raising the search timeout alone (130s of slot hold against a 105s budget) made a healthy pool report as wedged, which by the item above aborts the run. The budget is now derived from the longest legal slot hold plus the probe's own work, floored at the historical 105s so default installs are unchanged. That second term fixes a related inversion: the worker runs up to three navigation attempts (primary, retry, neutral-endpoint fallback) at `max(10s, HEALTH_CHECK_TIMEOUT_MS)` each, so raising that knob past ~35s used to make the fallback ladder unreachable — the setting made the health check strictly worse. The follower reads the same function rather than its own copy of the constant.
|
|
206
|
+
- **The queue counted a failed worker task as a success.** Worker tasks report failure by RESOLVING with `{ error: ... }` — poolifier delivers them that way and the scheduler only turns it into a throw afterwards — so `lastSuccessAt` was refreshed by every failure. Its own doc comment claimed the opposite. It is the surviving diagnostic in the health probe's failure message, and it read "last successful task 200ms ago" straight through a total worker outage.
|
|
207
|
+
- **Every search timeout was reported to the researcher as an infrastructure failure.** Two clocks can end a query, and only one was recognised. The scheduler's execution deadline is armed in the same synchronous block as `performSearch`'s own guard, with the same duration derived from the same two config values — so the scheduler's timer is registered first, fires first, and its rejection clears the caller's timer before Node reaches it. `timeoutController.signal.aborted` was therefore false for every real search timeout on the leader path: the query was logged at ERROR, counted as `status=error`, and reported to the researcher as "an infrastructure failure, not a signal about the query itself" rather than as a timeout it could reasonably retry. `timeoutCount` never left zero either, making the whole-burst timeout attribution downstream unreachable. A task timeout is now classified as one whichever clock named it; a genuine backend fault still is not.
|
|
208
|
+
- **A router plan omitting `action` bypassed every cap and every decision event.** `action` is optional in the schema and `parseJsonPlan` accepts a plan without one as long as it carries researchers — its own comment says such a plan "defaults to delegation downstream", which was true of `generatePlan` and never of `updatePlanForRound`. A router emitting `{"researchers":[...]}` fell through every branch and was returned verbatim: no team-size cap, no per-researcher query budget, no round hard cap, and no duplicate-id renumbering — and duplicate ids collide on the `${round}.${id}` report key, silently discarding one researcher's entire report. The orchestrator matched none of its `delegate` branches, so it announced no decision and never grew the progress bar, yet ran the researchers anyway and the bar visibly regressed. Normalized, with `wait` preserved.
|
|
209
|
+
- **The forced synthesis was paid for before the gate that discards it.** When every researcher fails — exactly the insufficient-credit class the researcher classifier exists for — the corpus is empty, so the synthesis call was billed over nothing and its result then thrown away by the zero-report gate a few lines later. The gate does not depend on the synthesis text, only on whether any source material exists, so it now runs first.
|
|
210
|
+
- **Round and evaluation events named rounds that never happened.** A `wait` decision decrements the round and re-enters the loop to run the same round again, re-announcing it — `round_start` read 1, 2, 2, 3. And the final synthesis announced `onEvaluationStart`/`onEvaluationDecision` with `maxRounds` rather than the round it stopped on; those coincide on the round-cap exit but not on wait-exhaustion, which breaks out early and announced an evaluation for a round no `round_start` had ever named, while the round it really stopped on was left with an evaluation slice that never received a decision.
|
|
211
|
+
- **"N searches" in the run summary was exactly double the truth.** `browser_search_queries_total` is incremented once per query, and its only caller then adds successes plus failures to `search_queries_total` — the same queries, one layer up. The summary summed both, and added `queries.length` a third time on the outer-catch path. The per-query counter is now the count, with the caller-level one kept as the fallback for a burst that threw before dispatching anything.
|
|
212
|
+
- **The "N rounds" element of the run summary had never rendered.** It reads `evaluator_runs_total`, whose emit site was deleted in `c90d7f37`, leaving this the counter's only reference in the repository. The value was therefore always zero, and the element is gated on `> 1`, so no multi-round run has shown its round count since. The orchestrator emits a counter that says what it means, at the end of each round that actually researched.
|
|
213
|
+
- **A malformed tool call spent the researcher's budget on a request that was refused.** `recordCall` ran before parameter validation in `youtube_transcript`, `security_search` and `stackexchange`. The sharpest case is the first: its schema caps the URL array while the allowance is a single call, so a researcher passing one URL too many got a validation error *and* lost its only call — and the retry was then told "You have already used your one youtube_transcript call", which is false. The round loses transcripts entirely, for a mistake the researcher could have corrected. `grep` and `search` already validated first.
|
|
214
|
+
- **A dispatch-health gauge kept reporting a pool that no longer existed.** `recordDispatchHealth` returned early when there was no pool, so the last live reading stood forever — a burst that ended with tasks parked left a standing non-zero `browser_pool_parked_tasks` for a dead process, and every later read of it was a report about nothing. The level gauges are zeroed instead; `browser_pool_stolen_tasks` is deliberately not, being cumulative rather than a level, since zeroing it on restart would read as the defect having been fixed. `browser_pool_idle_workers` is now exported alongside, because `parked` is zero by construction when every node is busy and that is not the same fact as the strategy choosing well.
|
|
215
|
+
- **The search-burst progress line reported a count as a rate.** `${queries.length} query/${n === 1 ? '' : 's'}` rendered a 45-query burst as "45 query/s", which reads as throughput and then appears frozen because it is emitted once per burst. It also printed a bare trailing slash, "1 query/", in the singular. Now "45 queries" / "1 query".
|
|
216
|
+
- **A scheduler restart force-tore the shared browser-init lock out from under a live holder.** That lock serializes leader election and browser-server startup across every `getScheduler()` caller in the process. `forceSchedulerRestart` never acquires it, yet disposed it in an unconditional `finally` — and disposal is not a release: it drains for five seconds and then forces the teardown, unlinking the lock file and retiring the instance. Launching a browser server routinely takes longer than that drain, and the retry paths in `task-execution-service` call the restart mid-run precisely when an init is likely in flight. The victim's critical section is not cancelled by the teardown, and the next caller builds a fresh lock instance — new uuid, new queue — over the now-unlinked path, so it acquires immediately: two concurrent inits, two elections, two listening servers. The lock is path-scoped and version-independent, so a restart does not invalidate it; only container teardown retires it now.
|
|
217
|
+
- **A follower could delete a live embedding leader's registration over a two-second blip.** After confirming the leader's process was alive, the wait loop probed its port, waited 500ms, probed once more, and on a second failure cleared the registration. That is well inside an ordinary stall — a saturated accept backlog, an event loop paused by a large batch embed, CPU starvation on a shared box. The leader is never told, treats a missing entry as benign and does not step down, so the follower then wins its own election and initializes the model again: a second GPU context, with the first server still running and now invisible to everyone. Sustained unreachability still elects a replacement, since a registration nobody collects would send every later process to its own in-process embedder — but the threshold is now ten consecutive failed polls against one endpoint, roughly 25 seconds, and a new claimant resets the streak. A failed health check against a leader that *did* accept the socket no longer rejects out of the whole call either; it costs one poll.
|
|
218
|
+
- **The coordinator's round-one direct answer was unreachable, and reachable only in the shape that would have been harmful.** `action` is optional in the parsed schema and the coordinator prompt neither documents nor asks for one, so any value is the model volunteering. With no researchers the emptiness guard replaced the plan wholesale; with researchers present it survived, ended the loop at round 1, and shipped as the final report — no queries, no scrapes, no citations, from a tool whose entire contract is grounded research. Round 1 is now pinned to `delegate` and the orchestrator's direct-answer plumbing is gone with it.
|
|
219
|
+
- **The router was shown two different round budgets in the same prompt.** Its complexity guidance stated the raw complexity-table figure ("Level 3 has 3 rounds available") a few lines above round-phase guidance phrased against the rounds that can still delegate ("Round 1 of 2" — the last iteration only synthesizes). Steering widened the gap by another round each time it extended the budget. Both now come from the same number, and the level-2 text no longer offers "a third round" that does not exist.
|
|
220
|
+
- **Five tools reported a user cancellation to the agent as a fabricated fault.** Every tool wrapper is built never to throw, which is right for a failure and wrong for a Ctrl-C: the prose is written to explain a fault, so `scrape`, `search`, `security_search`, `stackexchange` and `youtube_transcript` answered an abort with rate limiting that was never hit, bot-protection that never triggered, databases that were never unreachable — and told the agent to try again later or carry on with the rest of its research. The agent believes it, retries work the user just stopped, and can carry the invented cause into the report. A cancellation now propagates, which is what unwinds the agent loop; `research_knowledge_search` already did this and now shares the predicate.
|
|
221
|
+
- **Worker-pool health counters vanished after the first run of a session.** `AsyncLocalStorage` resolves at emit time from the async resource that fired, and for a listener on a long-lived emitter that resource is the emitter — here, a pool built lazily inside whichever run first needed a browser. Its error, unhealthy and auto-recovery counters therefore wrote into that first run's metrics registry for the rest of the process: absent from every later run's summary, since the registry is discarded when the run ends, and keeping it reachable through the closure. Those three are process state rather than run accounting and now say so explicitly.
|
|
222
|
+
- **A handled GPU fallback printed a raw multi-line error into the user's session.** Warmup is the first inference, so it is where a GPU that cannot serve the model actually fails, and transformers.js reports that by printing `An error occurred during model execution: ...` plus a dump of the input tensors to `console.error` before rejecting. Every other pipeline invocation runs inside the stderr capture; this one did not, so the dump went to the terminal — for a condition that is caught, falls back to CPU, and lets the run finish. Wrapping the caller would not have been enough: the load's capture has already ended by the time warmup runs, and the embedding server's capture covers only the FIRST `initialize()`, not the re-initialization that follows every idle-timeout GPU teardown. That is the case observed on 2026-08-16 — the session's fourth embedder init, where eighteen `VK_ERROR_OUT_OF_DEVICE_MEMORY` failures were captured into the log while the warmup dump was not, so the user saw the symptom without the cause.
|
|
223
|
+
- **The warmup fallback log named the wrong cause.** It branched on whether the error text contained "validation", reporting "WebGPU validation error" otherwise "WebGPU OOM" — and got it backwards in the case that matters. When the GPU is out of VRAM, Dawn raises `VK_ERROR_OUT_OF_DEVICE_MEMORY` through its device-error callback (native stderr, not the promise) and `CreateBuffer` returns an invalid buffer; what reaches JS is the first thing to *use* it, `[Invalid Buffer] ... While calling CreateBindGroup`, which contains "validation". So genuine VRAM exhaustion was logged as a validation error, with the eighteen allocation failures sitting directly above it in the same file. One message now, which points at the native diagnostics instead of paraphrasing them wrongly.
|
|
224
|
+
- **A researcher's context-usage fallback under-reported the context by the entire cached prefix.** When a session exposes no `getContextUsage()`, the fallback summed `input + output` from cumulative session usage. pi-ai reports cache-read and cache-write tokens as separate fields with `input` already net of both, but those tokens are part of the prompt and occupy the context window exactly like uncached input — the provider merely bills them differently. On a provider where caching works that is most of the prompt (48% of researcher tokens in the measured run), so the tool layer's context gate, which divides this figure by the context window, read near-empty on a session that was nearly full.
|
|
225
|
+
- **The dispatch-health gauges were written into whichever run happened to be first.** `browser_pool_parked_tasks` and the three counts beside it are sampled from the leader's 5s leadership tick — a self-rescheduling timer chain started when this process won the election, inside whichever research run first needed a browser. `AsyncLocalStorage` resolves at emit time from the async resource that fired, so every sample for the rest of the process landed in that first run's registry: absent from every later run's summary, and holding a discarded registry reachable through the timer. The same defect the pool's error counters were moved off in this release, in the one caller added after them. The pool's dispatch shape is process state, and is recorded as such.
|
|
226
|
+
- **The docs still described one evaluator, and two config fields described budgets that had moved.** README, ARCHITECTURE and CONFIGURATION all narrated a single evaluator deciding *and* writing — the shape this release replaced. Two of those were not merely naming: `PLANNING_MAX_TOKENS` was documented as covering "the mid-round evaluator decision (and, when the evaluator synthesizes mid-round, the report)", which is now wrong in both halves (routing has its own, much smaller ceiling, and the router cannot synthesize at all), and `SYNTHESIS_MAX_TOKENS` did not mention that it is also the reserve subtracted from the context window when budgeting the corpus. Corrected together with the prose, so a reader tuning these has the arithmetic in front of them.
|
|
227
|
+
- **The researcher was told its report would not be read.** The `COVERAGE DIGEST` instruction said the research lead reads the digest "INSTEAD of your report". That was true of the first iteration of this protocol and stopped being true when routing was moved back onto the reports themselves — the router now reads each report in full on the round it arrives, with the digest beside it as a claim to check. Telling a researcher its prose has no reader is an argument for writing the findings into the digest, which is the one thing the digest must not carry. The instruction now says what actually happens: read in full once, digest-only thereafter.
|
|
228
|
+
|
|
229
|
+
### Changed (metadata)
|
|
230
|
+
|
|
231
|
+
- **The npm description now matches the repository's About text** — "Web research for your agents with smart and safe tooling + knowledge store". The two had been left deliberately divergent; unifying them also fixes what pi.dev displays, since the gallery renders the npm description. Takes effect on the next publish. The npm `keywords` list is unchanged and already correct.
|
|
232
|
+
|
|
8
233
|
## [1.3.12] - 2026-08-15
|
|
9
234
|
|
|
10
235
|
A six-way independent audit of the whole tree (core/orchestration/LLM,
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -74,12 +74,12 @@ Research
|
|
|
74
74
|
| `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
|
|
75
75
|
| `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
|
|
76
76
|
| `PI_RESEARCH_MAX_FAILED_RESEARCHERS` | `2` | 1–10 | Unique researcher failures that abort the whole run. Raise to let slower, still-in-flight researchers finish before giving up. |
|
|
77
|
-
| `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. |
|
|
77
|
+
| `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. It sets how *fast* a search burst drains, not how large a burst may be — a burst larger than the pool waits its turn rather than being trimmed or timing out. |
|
|
78
78
|
| `PI_RESEARCH_WORKER_CONCURRENCY` | `2` | 1–10 | Tasks per worker process. |
|
|
79
79
|
| `PI_RESEARCH_MAX_CONCURRENT_RUNS` | `3` | ≥1 | Machine-wide cap on research runs executing at the same time, across **every** process (CLI, agent skill, pi extension, SDK). Runs beyond the cap queue rather than fail. All concurrent runs share one leader-elected browser/embedding pool, so oversubscribing it degrades every run at once. |
|
|
80
80
|
| `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` | `600000` | ≥0 | How long a run queues for a free slot before failing with "maximum concurrent research runs reached" (CLI exit `75`). `0` = fail immediately instead of queueing. |
|
|
81
|
-
| `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and
|
|
82
|
-
| `PI_RESEARCH_DISABLED_TOOLS` | _(none)_ | — | Comma-separated research tools to disable for a run (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `grep`, `read`). Removed from every researcher's toolset and named in the coordinator
|
|
81
|
+
| `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and research lead keep using the session model. Accepts `provider/id` or a bare model id. |
|
|
82
|
+
| `PI_RESEARCH_DISABLED_TOOLS` | _(none)_ | — | Comma-separated research tools to disable for a run (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `grep`, `read`). Removed from every researcher's toolset and named in the coordinator and research-lead prompts. Strictly additive — it can only remove capabilities, never grant one, and it stacks on top of the default exclusions rather than replacing them. An unrecognized name excludes nothing and is warned about rather than failing the run. |
|
|
83
83
|
| `PI_RESEARCH_REPORT_EXPORT_ENABLED` (TUI) | `false` | — | Front-ends write a Markdown report to disk and surface its path. |
|
|
84
84
|
| `PI_RESEARCH_REPORT_EXPORT_DIR` | _(smart cwd)_ | — | Pin exported reports to a fixed directory, bypassing the cwd-relative resolution. Useful for the agent skill, which runs from the host agent's arbitrary directory. |
|
|
85
85
|
| `PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING` | `0.15` | 0.05–1.0 | Max fraction of the context window used for initial scrape context. |
|
|
@@ -99,10 +99,10 @@ Timeouts
|
|
|
99
99
|
|
|
100
100
|
| Variable | Default | Range | Description |
|
|
101
101
|
|----------|---------|-------|-------------|
|
|
102
|
-
| `PI_RESEARCH_LLM_TIMEOUT_MS` | `300000` | 60000–1800000 | Coordinator /
|
|
102
|
+
| `PI_RESEARCH_LLM_TIMEOUT_MS` | `300000` | 60000–1800000 | Coordinator / research-lead / repair / knowledge LLM call timeout. |
|
|
103
103
|
| `PI_RESEARCH_SCRAPE_TIMEOUT_MS` | `15000` | 5000–120000 | Per-page scrape (page-load) timeout. |
|
|
104
104
|
| `PI_RESEARCH_SEARCH_TIMEOUT_MS` | `45000` | 5000–120000 | Browser search page timeout. |
|
|
105
|
-
| `PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS` | `10000` | 2000–120000 |
|
|
105
|
+
| `PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS` | `10000` | 2000–120000 | Overhead margin added to each browser op's own timeout (a search task ceiling is `SEARCH_TIMEOUT_MS` + this + a fixed ~120s cold-start allowance; a scrape is `SCRAPE_TIMEOUT_MS` + this + the same allowance). The cold-start allowance covers a worker's first real browser launch + context creation, which is not user-tunable. These ceilings bound **execution**: a task's clock starts when a worker picks it up, so time spent queued behind other work is not charged against it and raising this to cover a busy pool is neither needed nor effective. |
|
|
106
106
|
| `PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS` | `10000` | 2000–120000 | Pre-flight health-check timeout. |
|
|
107
107
|
|
|
108
108
|
LLM output & reasoning
|
|
@@ -111,8 +111,8 @@ These are advanced, env-only knobs (not in the TUI).
|
|
|
111
111
|
|
|
112
112
|
| Variable | Default | Range | Description |
|
|
113
113
|
|----------|---------|-------|-------------|
|
|
114
|
-
| `PI_RESEARCH_LLM_THINKING_LEVEL` | `off` | off · minimal · low · medium · high | Chain-of-thought level for all engine LLM work (coordinator,
|
|
115
|
-
| `PI_RESEARCH_PLANNING_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens for the plan
|
|
114
|
+
| `PI_RESEARCH_LLM_THINKING_LEVEL` | `off` | off · minimal · low · medium · high | Chain-of-thought level for all engine LLM work (coordinator, router, synthesizer, JSON-repair, knowledge extraction, and researcher sub-agents). Off by default — these calls emit structured JSON / cited reports, so a thinking block only consumes the output budget and can truncate the answer. Clamped per model by pi. |
|
|
115
|
+
| `PI_RESEARCH_PLANNING_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens for the coordinator's plan. The router's decision has its own, smaller ceiling; the final synthesis uses `PI_RESEARCH_SYNTHESIS_MAX_TOKENS`. Clamped to the model's real ceiling. |
|
|
116
116
|
| `PI_RESEARCH_SYNTHESIS_MAX_TOKENS` | `32768` | 1024–131072 | Max output tokens for the final synthesized report. Clamped to the model's real ceiling. |
|
|
117
117
|
|
|
118
118
|
Knowledge store
|
|
@@ -239,6 +239,105 @@ just export the variable for that process.
|
|
|
239
239
|
> `/research-config` TUI (which writes it), export variables in your shell, or use a
|
|
240
240
|
> loader such as direnv. `.env.example` is a reference, not an active config file.
|
|
241
241
|
|
|
242
|
+
### Prompt caching
|
|
243
|
+
|
|
244
|
+
A research run re-sends a large, mostly-unchanged prompt many times: a researcher
|
|
245
|
+
re-sends its whole conversation (including every scraped page) on each tool turn, and
|
|
246
|
+
the research lead used to re-send the accumulated findings on every round. Every provider will
|
|
247
|
+
serve that repetition from a prompt cache at a fraction of the input price — but only
|
|
248
|
+
for an **exact prefix** of the request, and only if nothing that varies sits in front
|
|
249
|
+
of it.
|
|
250
|
+
|
|
251
|
+
What pi-research does about it. The research lead is split into two roles so that the
|
|
252
|
+
repetition is removed rather than merely discounted. The **router** decides each round
|
|
253
|
+
whether to continue, reading each report in full on the one round it arrives and only its
|
|
254
|
+
short coverage digest thereafter, instead of re-reading every report collected so far. The **synthesizer** runs once, at the end, and is the only
|
|
255
|
+
call that reads the reports in full. Before the split, one call did both jobs and re-sent
|
|
256
|
+
the whole corpus every round, so its input grew with the square of the round count. The
|
|
257
|
+
synthesizer's corpus is also budgeted against the model's context window: over budget, the
|
|
258
|
+
reports are reduced in partial passes and merged rather than truncated or rejected.
|
|
259
|
+
|
|
260
|
+
How often the router runs depends on the round budget. Depth 2 and 3 route on every round
|
|
261
|
+
but the last. Depth 1's base budget is two rounds and the last one is skipped, so a plain
|
|
262
|
+
depth-1 run never routes — but steering raises the budget (up to two extra rounds), and a
|
|
263
|
+
steered depth-1 run does route. That is also the depth-1 case carrying the most findings,
|
|
264
|
+
so it is where routing on digests rather than on the full corpus matters most.
|
|
265
|
+
|
|
266
|
+
On top of that, both lead prompts are laid out stable-part-first: they interpolate only
|
|
267
|
+
values fixed for the whole run (complexity, team size, query budget, disabled tools), and
|
|
268
|
+
everything that changes between rounds — root query, round number, agenda, executed
|
|
269
|
+
queries, steering, round-phase guidance — is appended afterwards as a `RUN CONTEXT`
|
|
270
|
+
block. Unit tests hold the layout in place; if you edit
|
|
271
|
+
`src/prompts/system-lead-router.md` or `src/prompts/system-lead-synthesizer.md`, keep
|
|
272
|
+
run-varying text out of them.
|
|
273
|
+
|
|
274
|
+
What your provider does about it depends on which API pi talks to (the provider's `api`
|
|
275
|
+
field in `~/.pi/agent/models.json`) and, on OpenAI-compatible endpoints, on one `compat`
|
|
276
|
+
key:
|
|
277
|
+
|
|
278
|
+
| Provider API | Behaviour |
|
|
279
|
+
|--------------|-----------|
|
|
280
|
+
| `anthropic-messages` | pi inserts `cache_control` breakpoints automatically — on the system prompt, the last tool definition, and the last user/assistant/tool-result block. Nothing to configure. |
|
|
281
|
+
| `openai-completions` | pi inserts the **same** Anthropic-style breakpoints into the OpenAI-shaped payload whenever `compat.cacheControlFormat` is `"anthropic"`. Auto-detection sets that key for exactly one case — OpenRouter routing to an `anthropic/*` model — so by default no markers are sent and the provider's own implicit prefix caching is relied on. That default is correct for OpenAI, DeepSeek, Gemini and GLM, which all cache implicitly. |
|
|
282
|
+
| `openai-responses` | No markers at any setting; implicit caching only. |
|
|
283
|
+
|
|
284
|
+
Note that the marker behaviour is gated on `cacheControlFormat`, **not** on the `api`
|
|
285
|
+
field — setting the key makes an `openai-completions` provider emit explicit breakpoints
|
|
286
|
+
exactly like an `anthropic-messages` one. That matters when reading the counters below:
|
|
287
|
+
on a provider you have configured this way, a zero cache read is not evidence that
|
|
288
|
+
markers are missing.
|
|
289
|
+
|
|
290
|
+
The gap is providers that need explicit markers but are reached over an OpenAI-compatible
|
|
291
|
+
endpoint and are not the one auto-detected case. Anything in that category (Qwen through
|
|
292
|
+
OpenRouter, or any gateway fronting Claude) caches **nothing at all**, silently and with
|
|
293
|
+
no error. Fix it per provider with a `compat` block:
|
|
294
|
+
|
|
295
|
+
```json
|
|
296
|
+
{
|
|
297
|
+
"providers": {
|
|
298
|
+
"openrouter": {
|
|
299
|
+
"api": "openai-completions",
|
|
300
|
+
"compat": {
|
|
301
|
+
"cacheControlFormat": "anthropic",
|
|
302
|
+
"sessionAffinityFormat": "openrouter",
|
|
303
|
+
"sendSessionAffinityHeaders": true
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`cacheControlFormat: "anthropic"` forces the markers on. It is safe to set for a whole
|
|
311
|
+
provider: models that do not use markers ignore the extra field. The two session-affinity
|
|
312
|
+
keys ask OpenRouter to keep a conversation on the replica that warmed its cache; without
|
|
313
|
+
them OpenRouter falls back to hashing the first messages, which an agentic loop mutates.
|
|
314
|
+
|
|
315
|
+
`PI_CACHE_RETENTION=long` (a pi variable, not a pi-research one) requests 1-hour
|
|
316
|
+
retention where the provider supports it. It raises the cache-write multiplier from
|
|
317
|
+
1.25x to 2x, so it only pays off when a run's gaps between calls exceed the provider's
|
|
318
|
+
default window — nominally 5 minutes.
|
|
319
|
+
|
|
320
|
+
Measure before enabling it. On a direct probe against GLM over `anthropic-messages`, an
|
|
321
|
+
entry was still read back unchanged after **seven minutes at the default retention**, and
|
|
322
|
+
`long` produced an identical number — so on that route the doubled write multiplier buys
|
|
323
|
+
nothing. The nominal 5-minute figure is a floor providers are free to exceed, not a
|
|
324
|
+
deadline you can plan around.
|
|
325
|
+
|
|
326
|
+
Verifying it works. Set `PI_RESEARCH_DEBUG=true` and read the run log: every LLM call
|
|
327
|
+
records `llm_cache_read_tokens_total` and `llm_cache_write_tokens_total` alongside
|
|
328
|
+
`llm_tokens_total`, labelled by component (`coordinator` / `router` / `synthesizer` /
|
|
329
|
+
`researcher`).
|
|
330
|
+
Cache reads that stay at zero on a multi-turn researcher mean the prefix is not being
|
|
331
|
+
matched — usually a provider with a minimum cacheable prefix (commonly 1024–4096 tokens)
|
|
332
|
+
that short prompts never reach, or an explicit-marker provider missing the `compat` block
|
|
333
|
+
above. A counter that is absent rather than zero means the provider does not report
|
|
334
|
+
caching at all.
|
|
335
|
+
|
|
336
|
+
Cache **writes** are the weaker signal of the two. Several providers report reads and
|
|
337
|
+
omit writes entirely — OpenRouter and Z.ai's Anthropic-compatible endpoint both return
|
|
338
|
+
non-zero `cache_read` alongside a flat zero `cache_write` — so a zero there is normal and
|
|
339
|
+
says nothing about whether caching is working. Judge it by the read counter.
|
|
340
|
+
|
|
242
341
|
### Where files live
|
|
243
342
|
|
|
244
343
|
All pi-research state lives under its own namespace, `~/.pi/research/`:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincoln504/pi-research",
|
|
3
|
-
"version": "1.3
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.4.3",
|
|
4
|
+
"description": "Web research for your agents with smart and safe tooling + knowledge store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
7
7
|
"main": "./src/index.ts",
|
package/scripts/build.cjs
CHANGED
|
@@ -87,9 +87,16 @@ function copyThreadWorker() {
|
|
|
87
87
|
// 'prompts')), so the templates must sit next to the bundle.
|
|
88
88
|
function copyPrompts() {
|
|
89
89
|
fs.mkdirSync(p('dist', 'prompts'), { recursive: true });
|
|
90
|
-
|
|
90
|
+
const sources = fs.readdirSync(p('src', 'prompts'));
|
|
91
|
+
for (const f of sources) {
|
|
91
92
|
fs.copyFileSync(p('src', 'prompts', f), p('dist', 'prompts', f));
|
|
92
93
|
}
|
|
94
|
+
// Prune prompts that no longer exist in src. Copying alone leaves a deleted or renamed
|
|
95
|
+
// prompt behind in an incremental dist forever; verify-package rejects the orphan at
|
|
96
|
+
// publish time, which surfaces as a confusing failure long after the rename.
|
|
97
|
+
for (const f of fs.readdirSync(p('dist', 'prompts'))) {
|
|
98
|
+
if (!sources.includes(f)) fs.rmSync(p('dist', 'prompts', f));
|
|
99
|
+
}
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
const TARGETS = {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: pi-research
|
|
3
3
|
description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
|
|
4
4
|
license: MIT
|
|
5
|
-
metadata: { "author": "Lincoln Deen", "version": "1.3
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.4.3", "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
|
@@ -168,7 +168,11 @@ function makeProgressObserver(): HeadlessObserverOptions {
|
|
|
168
168
|
toStderr(` • round ${data?.round}\n`);
|
|
169
169
|
break;
|
|
170
170
|
case 'search_start':
|
|
171
|
-
|
|
171
|
+
// "N queries", not "N query/s" — the old `query/${n===1?'':'s'}` form rendered a
|
|
172
|
+
// COUNT as what reads unmistakably as a RATE ("45 query/s"), so a burst that was
|
|
173
|
+
// in fact 45 queries looked like 45 per second and then appeared frozen. It also
|
|
174
|
+
// emitted a bare trailing slash ("1 query/") in the singular.
|
|
175
|
+
toStderr(` • search burst: ${data?.queries?.length ?? 0} ${(data?.queries?.length ?? 0) === 1 ? 'query' : 'queries'}\n`);
|
|
172
176
|
break;
|
|
173
177
|
case 'researcher_start':
|
|
174
178
|
toStderr(` • researcher ${data?.id}: ${data?.name}\n`);
|