@lincoln504/pi-research 1.3.11 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.mjs +5558 -4596
- package/dist/prompts/research-tool-usage.md +1 -1
- package/dist/prompts/researcher.md +1 -0
- package/dist/prompts/system-coordinator.md +1 -1
- package/dist/prompts/system-lead-router.md +122 -0
- package/dist/prompts/system-lead-synthesizer.md +46 -0
- package/dist/thread-worker.mjs +123 -30
- package/docs/ARCHITECTURE.md +69 -24
- package/docs/CHANGELOG.md +215 -0
- package/docs/CONFIGURATION.md +108 -8
- package/package.json +19 -11
- package/scripts/build.cjs +8 -1
- package/scripts/cleanup.cjs +26 -5
- package/scripts/setup.cjs +25 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +41 -2
- package/src/config.ts +12 -8
- 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 +719 -91
- package/src/core/planning-utils.ts +21 -5
- package/src/core/service-initialization.ts +19 -3
- package/src/core/service-registry.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 +220 -34
- package/src/infrastructure/browser/config.ts +77 -6
- 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 +17 -7
- package/src/infrastructure/browser/thread-worker-browser.ts +66 -20
- package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
- package/src/infrastructure/browser/thread-worker.mjs +123 -30
- package/src/infrastructure/browser/thread-worker.ts +2 -2
- 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/file-lock-service.ts +214 -19
- package/src/infrastructure/gpu-resource-service.ts +15 -1
- 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 +60 -18
- package/src/knowledge/store.ts +11 -5
- package/src/logger.ts +11 -0
- package/src/orchestration/deep-research-orchestrator.ts +141 -87
- package/src/orchestration/quick-research-orchestrator.ts +11 -2
- package/src/orchestration/research-orchestration-service.ts +30 -1
- 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/prompts/research-tool-usage.md +1 -1
- package/src/prompts/researcher.md +1 -0
- package/src/prompts/system-coordinator.md +1 -1
- package/src/prompts/system-lead-router.md +122 -0
- package/src/prompts/system-lead-synthesizer.md +46 -0
- package/src/sdk.ts +10 -1
- package/src/security/nvd-client.ts +55 -4
- package/src/security/osv-client.ts +13 -1
- package/src/tools/research-knowledge-search.ts +29 -4
- package/src/tools/scrape.ts +6 -0
- package/src/tools/search.ts +7 -0
- package/src/tools/security.ts +27 -9
- 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/llm-usage.ts +33 -6
- package/src/utils/log-utils.ts +16 -1
- package/src/utils/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +93 -20
- package/src/web-research/scraper-utils.ts +55 -14
- package/dist/prompts/system-lead-evaluator.md +0 -118
- package/src/prompts/system-lead-evaluator.md +0 -118
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -31,8 +31,8 @@ pi CLI
|
|
|
31
31
|
search burst, then hands each researcher a set of result URLs to start from.
|
|
32
32
|
4. Researchers scrape and read those pages through the scrape tooling and return cited
|
|
33
33
|
reports. They consider only what they scraped this session.
|
|
34
|
-
5. The
|
|
35
|
-
final report.
|
|
34
|
+
5. The research lead's **router** reviews the round and either runs another round or ends
|
|
35
|
+
the loop; its **synthesizer** then writes the final report from every report collected.
|
|
36
36
|
6. The result is returned as a single cited Markdown report; the cited URLs and their
|
|
37
37
|
summaries are queued into the knowledge store for future runs.
|
|
38
38
|
|
|
@@ -49,11 +49,19 @@ agent skill can pass). The pi extension's `research` tool has a minimum depth of
|
|
|
49
49
|
an in-session agent can never request quick mode.
|
|
50
50
|
|
|
51
51
|
Depth 1–3 — deep (`DeepResearchOrchestrator`): the run proceeds in **rounds**. A round
|
|
52
|
-
is one coordinate → research →
|
|
53
|
-
coordinator in round 1, by the
|
|
54
|
-
in parallel, and the
|
|
55
|
-
Two limits apply independently: how many researchers run *within*
|
|
56
|
-
rounds the run may take.
|
|
52
|
+
is one coordinate → research → route cycle — the round's agenda is planned (by the
|
|
53
|
+
coordinator in round 1, by the research lead's **router** thereafter), a batch of
|
|
54
|
+
**researchers** runs it in parallel, and the router then decides whether to run another
|
|
55
|
+
round or end the loop. Two limits apply independently: how many researchers run *within*
|
|
56
|
+
a round, and how many rounds the run may take.
|
|
57
|
+
|
|
58
|
+
The research lead is two roles, not one call doing both jobs. The router only decides; it
|
|
59
|
+
reads each report in full on the one round it arrives and only that report's short
|
|
60
|
+
coverage digest thereafter, so its input grows with team size rather than with the square
|
|
61
|
+
of the round count. The **synthesizer** runs exactly once, at the end, reads every report
|
|
62
|
+
in full, and writes the report — under a corpus budget derived from the model's context
|
|
63
|
+
window, reducing in partial passes and merging when the corpus does not fit. The two
|
|
64
|
+
prompts are `src/prompts/system-lead-router.md` and `system-lead-synthesizer.md`.
|
|
57
65
|
|
|
58
66
|
| Depth | Label | Researchers per round (max) | Rounds (max) |
|
|
59
67
|
|-------|--------|-----------------------------|--------------|
|
|
@@ -61,7 +69,7 @@ rounds the run may take.
|
|
|
61
69
|
| 2 | deep | 3 | 3 |
|
|
62
70
|
| 3 | ultra | 5 | 3 |
|
|
63
71
|
|
|
64
|
-
These are ceilings, not targets: the coordinator and
|
|
72
|
+
These are ceilings, not targets: the coordinator and router use as many researchers
|
|
65
73
|
and rounds as the topic needs. A depth-2 run, for example, may spawn up to 3 researchers
|
|
66
74
|
in each of up to 3 rounds. Queued steering messages (Alt+Enter) can unlock a few extra
|
|
67
75
|
rounds past the cap (`MAX_EXTRA_ROUNDS_WITH_STEERING`).
|
|
@@ -70,7 +78,7 @@ The coordinator also runs the initial search burst and distributes its result UR
|
|
|
70
78
|
round 1's researchers (`distributeSearchResults`), so in deep mode the researchers
|
|
71
79
|
themselves do not call `search`.
|
|
72
80
|
|
|
73
|
-
LLM-call conventions. Coordinator,
|
|
81
|
+
LLM-call conventions. Coordinator, router, synthesizer, JSON-repair, and
|
|
74
82
|
knowledge-extraction calls go through `completeSimple` (`src/core/llm/pi-ai-completion.ts`)
|
|
75
83
|
with `buildSafeOptions` (`src/core/llm/llm-utils.ts`); researcher sub-agents go through
|
|
76
84
|
`createAgentSession`.
|
|
@@ -301,24 +309,61 @@ Worker pool over direct browser — browser processes are isolated in workers so
|
|
|
301
309
|
in one cannot affect the orchestrator or other sessions.
|
|
302
310
|
|
|
303
311
|
Pinned browser stack — `playwright-core` and `impit` are pinned to exact versions and
|
|
304
|
-
`camoufox-js` to its `0.10.x` line
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
`only-allow pnpm` preinstall guard that
|
|
311
|
-
`npm install -g
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
312
|
+
`camoufox-js` to its `0.10.x` line; the three are coupled and upgraded together, because
|
|
313
|
+
each floating range broke fresh consumer installs that our lockfile masked.
|
|
314
|
+
playwright-core stays at `1.60.0` (1.61+ rejects camoufox's Juggler and fails every
|
|
315
|
+
launch — corroborated upstream, since camoufox-js `0.12.0` declares
|
|
316
|
+
`peerDependencies: { "playwright-core": "<1.61.0" }`, the same bound this pin holds by
|
|
317
|
+
hand). `impit` stays at `0.13.0`: camoufox-js 0.10.2 requires `^0.13.0`, and within that
|
|
318
|
+
range only 0.13.0 lacks the `only-allow pnpm` preinstall guard that breaks
|
|
319
|
+
`npm install -g` (an upstream mistake in 0.13.1/0.14.0, dropped again in 0.14.1). An
|
|
320
|
+
exact pin is required because npm `overrides` do not propagate to consumers. Rationale in
|
|
321
|
+
full: `src/infrastructure/browser/thread-worker-browser.ts`.
|
|
322
|
+
|
|
323
|
+
The two original blockers have both lapsed — camoufox restored Windows binaries in
|
|
324
|
+
`v152.0.4-beta.26` (2026-07-16) and impit's guard is long gone — but camoufox-js `0.12.0`
|
|
325
|
+
is held back by a third, worse one: it requires `better-sqlite3 ^13.0.1`, and
|
|
326
|
+
better-sqlite3 13 **dropped prebuilt binaries**. 12.x installs via
|
|
327
|
+
`prebuild-install || node-gyp rebuild`; 13.x ships no install script at all, so npm
|
|
328
|
+
auto-runs `node-gyp rebuild` against its `binding.gyp` and every install demands a full
|
|
329
|
+
C++ toolchain. This was measured, not inferred: the upgrade was made, and `npm ci` failed
|
|
330
|
+
outright on the Windows CI runner (`could not find a version of Visual Studio 2017 or
|
|
331
|
+
newer`) — i.e. it would break installation for most Windows users, which is exactly what
|
|
332
|
+
this pin exists to prevent. Check better-sqlite3 before camoufox next time.
|
|
333
|
+
|
|
334
|
+
The browser BINARY, by contrast, is not pinned and cannot be. `camoufox-js fetch` takes
|
|
335
|
+
no version argument: it walks the `daijro/camoufox` GitHub releases newest-first and takes
|
|
336
|
+
the first non-prerelease release carrying an asset for this OS/arch. So the binary a
|
|
337
|
+
consumer gets is whatever camoufox published most recently at install time, regardless of
|
|
338
|
+
which camoufox-js version is installed — the npm pins do not freeze it, and a future
|
|
339
|
+
camoufox release could break launches for fresh installs with no change on our side.
|
|
340
|
+
Windows assets were in fact missing from `v146-hardware` through `v152.0.2-alpha` and
|
|
341
|
+
returned in `v152.0.4-beta.26` (2026-07-16). Current newest is `v152.0.4-beta.28`
|
|
342
|
+
(Firefox 152); it launches and drives cleanly under playwright-core `1.60.0` **with
|
|
343
|
+
camoufox-js 0.10.2** (whose `MIN_VERSION` is `beta.19`, so it accepts it), verified
|
|
344
|
+
directly, as does the older `v135.0.1-beta.24` an existing cache may still hold. The
|
|
345
|
+
practical consequence is that browser freshness is independent of the npm pin: a stale
|
|
346
|
+
`camoufox-js` does not mean a stale Firefox. Re-verify a real launch when bumping this
|
|
347
|
+
stack — the unit and integration suites mock the browser and cannot catch a Juggler
|
|
348
|
+
mismatch.
|
|
315
349
|
|
|
316
350
|
Pinned data stack — `apache-arrow` is a direct dependency at `21.1.0`, and `overrides`
|
|
317
351
|
forces the whole tree to that single version so LanceDB and Arrow share one Arrow instance
|
|
318
352
|
(mismatched Arrow copies do not interoperate — arrays built by one are rejected by the
|
|
319
|
-
other). This sits above `@lancedb/lancedb` 0.
|
|
320
|
-
(`>=15.0.0 <=18.1.0`)
|
|
321
|
-
whenever `@lancedb/lancedb` is
|
|
353
|
+
other). This sits above `@lancedb/lancedb` 0.37's declared Arrow peer ceiling
|
|
354
|
+
(`>=15.0.0 <=18.1.0`) — npm will not even resolve the pairing without the override — and
|
|
355
|
+
it is verified working, but it should be re-validated whenever `@lancedb/lancedb` is
|
|
356
|
+
upgraded.
|
|
357
|
+
|
|
358
|
+
`21.1.0` is exact for a measured reason, not caution. Bumping the PATCH-looking minor to
|
|
359
|
+
`21.2.0` breaks the store outright: LanceDB's Rust-side Arrow reader cannot parse the
|
|
360
|
+
schema that Arrow 21.2 writes, failing every table open with
|
|
361
|
+
`Failed to read IPC file: Arrow error: Parser error: Unable to get root as footer:
|
|
362
|
+
RangeOutOfBounds … UnionVariant { variant: "Type::FixedSizeList" }` — 56 unit and 36
|
|
363
|
+
integration tests, every one that touches a real table. Do not treat this range as
|
|
364
|
+
caret-safe. Note also that every `@lancedb/lancedb` release through 0.37 declares the same
|
|
365
|
+
`<=18.1.0` Arrow ceiling, so upgrading LanceDB does not resolve the override; it only
|
|
366
|
+
changes which pairing needs re-validating.
|
|
322
367
|
|
|
323
368
|
Pinned validation library — `typebox` is pinned to the exact version the pi host packages
|
|
324
369
|
depend on (`@earendil-works/pi-ai` / `@earendil-works/pi-coding-agent` 0.84.2 pin `1.3.7`). Every
|
|
@@ -332,7 +377,7 @@ stable `Agent` connector API, so it follows `^8`.)
|
|
|
332
377
|
|
|
333
378
|
Transient-failure resilience — every LLM call is a potential single point of failure on a
|
|
334
379
|
streaming endpoint that can drop mid-response (undici surfaces this as `terminated`). The
|
|
335
|
-
coordinator and
|
|
380
|
+
coordinator and research-lead calls retry fast transient transport failures (socket aborts, 5xx,
|
|
336
381
|
429, provider overload) with bounded exponential backoff — mirroring the per-researcher retry
|
|
337
382
|
(`PI_RESEARCH_MAX_RETRIES`) — and, if still failing, degrade to a deterministic fallback plan
|
|
338
383
|
rather than aborting the run. An app-level LLM timeout is not retried (it already spent the
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,221 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.4.1] - 2026-08-17
|
|
9
|
+
|
|
10
|
+
v1.4.0 was tagged and pushed but never published: CI failed on every commit from the
|
|
11
|
+
moment `search-burst-end-to-end.test.ts` was added through the release commit itself —
|
|
12
|
+
10 of 10 runs, all three platforms, over twelve hours — so the `require-ci-green` release
|
|
13
|
+
gate never opened and `npm publish` never ran. An independent re-audit of the whole
|
|
14
|
+
1.3.12..1.4.0 diff, distrusting the prior review, found the test failure's actual cause
|
|
15
|
+
plus eight more defects.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **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.
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
- **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.
|
|
24
|
+
- **`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.
|
|
25
|
+
- **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.
|
|
26
|
+
- **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.
|
|
27
|
+
- **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.
|
|
28
|
+
|
|
29
|
+
### Verified
|
|
30
|
+
|
|
31
|
+
- Full suites green: 2710 unit tests, 57 + 103 integration (real browsers), typecheck.
|
|
32
|
+
- Each production-code fix carries a regression test that fails against the pre-fix code.
|
|
33
|
+
|
|
34
|
+
## [1.4.0] - 2026-08-17
|
|
35
|
+
|
|
36
|
+
Minor, because the research lead is now two roles rather than one call doing both jobs:
|
|
37
|
+
a **router** that decides each round whether to continue, and a **synthesizer** that
|
|
38
|
+
writes the report once, at the end, under a budget derived from the model's context
|
|
39
|
+
window. That started as an investigation into prompt caching — how pi and each provider
|
|
40
|
+
implement it, whether pi-research's own prompts are shaped so it can work, and what it
|
|
41
|
+
was actually doing — which was instrumented first and then fixed against the
|
|
42
|
+
measurements, and which is what showed that the evaluator's cost was repetition rather
|
|
43
|
+
than a caching misconfiguration.
|
|
44
|
+
|
|
45
|
+
The rest is the largest correctness batch in the 1.3 line: search bursts no longer
|
|
46
|
+
discard their own tail, browser task deadlines and durations stop charging queue wait to
|
|
47
|
+
the work, and a run's round accounting, health verdicts and lock waits were each wrong in
|
|
48
|
+
a way the logs had been recording for months.
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- **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.
|
|
53
|
+
- **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.
|
|
54
|
+
- **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.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- **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.
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
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).
|
|
63
|
+
- **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.
|
|
64
|
+
|
|
65
|
+
- **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.
|
|
66
|
+
|
|
67
|
+
**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.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
**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.
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
- **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.
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- **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.
|
|
79
|
+
- **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.
|
|
80
|
+
- **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.
|
|
81
|
+
|
|
82
|
+
`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.
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
- **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.
|
|
88
|
+
- **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.
|
|
89
|
+
- **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.
|
|
90
|
+
- **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.
|
|
91
|
+
- **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.
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
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.
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
- **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.
|
|
101
|
+
|
|
102
|
+
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.
|
|
103
|
+
- **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.
|
|
104
|
+
- **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.
|
|
105
|
+
- **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.
|
|
106
|
+
|
|
107
|
+
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.
|
|
108
|
+
- **`[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.
|
|
109
|
+
- **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.
|
|
110
|
+
- **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.
|
|
111
|
+
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
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.
|
|
115
|
+
- **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`.
|
|
116
|
+
- **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.
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
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.
|
|
121
|
+
- **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.
|
|
122
|
+
|
|
123
|
+
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.
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
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.
|
|
128
|
+
- **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.
|
|
129
|
+
- **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.
|
|
130
|
+
- **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.
|
|
131
|
+
- **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.
|
|
132
|
+
- **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.
|
|
133
|
+
- **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.
|
|
134
|
+
- **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.
|
|
135
|
+
- **"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.
|
|
136
|
+
- **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.
|
|
137
|
+
- **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.
|
|
138
|
+
- **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.
|
|
139
|
+
- **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".
|
|
140
|
+
- **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.
|
|
141
|
+
- **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.
|
|
142
|
+
- **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.
|
|
143
|
+
- **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.
|
|
144
|
+
- **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.
|
|
145
|
+
- **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.
|
|
146
|
+
- **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.
|
|
147
|
+
- **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.
|
|
148
|
+
- **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.
|
|
149
|
+
- **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.
|
|
150
|
+
- **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.
|
|
151
|
+
- **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.
|
|
152
|
+
|
|
153
|
+
### Changed (metadata)
|
|
154
|
+
|
|
155
|
+
- **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.
|
|
156
|
+
|
|
157
|
+
## [1.3.12] - 2026-08-15
|
|
158
|
+
|
|
159
|
+
A six-way independent audit of the whole tree (core/orchestration/LLM,
|
|
160
|
+
infrastructure/security, knowledge + scrape pipeline, tools/TUI/CLI,
|
|
161
|
+
release/CI/docs, and test-suite integrity), run against the clean 1.3.11 tree,
|
|
162
|
+
followed by a second pass that exercised each external integration live rather
|
|
163
|
+
than reading it. Every new regression test is mutation-verified — re-run against
|
|
164
|
+
the reinstated pre-fix code and confirmed to fail.
|
|
165
|
+
|
|
166
|
+
### Fixed
|
|
167
|
+
|
|
168
|
+
- **`security_search` returned the oldest CVEs on record and could not reach recent ones.** NVD 2.0 returns keyword matches oldest-first and offers no sort parameter (`sortBy` is a 404), so paging from index 0 and stopping at `maxResults` deterministically kept the least useful window. Measured against the live API: a search for `openssl` returned CVE-1999-0428 through 2004 out of 566 matches, and nothing published after 2004 was reachable at any page size — the researcher was being handed 27-year-old advisories as the current vulnerability picture. The client now reads `totalResults` from the first page and seeks once to the tail, then orders the window newest-first (undated entries last). Only fires when the match set is larger than what is returned, so a query whose results already fit still costs exactly one request. The same search now returns the 20 most recent, through 2026-08-13. OSV had a milder form of this — its per-package list is in ID order, so truncation dropped advisories on alphabetical accident — and is now sorted newest-first before the cut.
|
|
169
|
+
- **The NVD rate limiter could leave a search permanently unsettled.** The inter-request spacing timer (up to 6s without an API key) was `unref`'d, but that timer *is* the operation — nothing else settles the awaited promise. With no other ref'd handle, Node treated the process as idle mid-search and the promise never resolved: no result, no error, no timeout. Reproduced deterministically as soon as a second request was issued. It is now ref'd, and remains bounded and abortable so shutdown cannot be delayed beyond one spacing interval. Every other `unref` in the tree guards a timeout against real I/O that holds the loop open by itself; this was the only one with nothing behind it.
|
|
170
|
+
- **ReDoS in the JWT redaction pattern, the sibling the 1.3.9 fix did not reach.** Bounding the URL-credential scheme fixed one quadratic pattern and left an identical one next to it: JWT segments exclude `.`, so an unbounded `[A-Za-z0-9_-]+` consumes to the end of a dotless base64url run and then backtracks one character at a time — and because `_` counts as a non-alphanumeric left boundary, the same run offers a fresh start position every few characters. Measured 393ms of blocked event loop per log message across the 40 000-character scan window, on a function that runs for every log line over largely untrusted content. Bounding the segments brings the identical input to 41ms with no loss of coverage; the bounds sit far above any real token.
|
|
171
|
+
- **`PI_RESEARCH_DISABLED_TOOLS` and the SDK's `excludeTools` accepted unknown tool names silently.** `RESEARCH_TOOL_NAMES` documents itself as the vocabulary all three exclusion surfaces accept, but only `--exclude-tools` validated against it — the other two reached the same blind Set union, where a typo excludes nothing while the user believes the tool is off for a whole billed run. Both now warn at the shared chokepoint, naming the offending value and the valid set. A warning rather than an error there: that path serves a library API and an ambient env var, where aborting a legitimate request over one unrecognized name is the wrong trade. The CLI keeps its fail-fast.
|
|
172
|
+
|
|
173
|
+
- **`PI_RESEARCH_STATE_DIR` pointed one directory short could wipe `config.env` and the knowledge store.** `scripts/cleanup.cjs` decided whether an override was safe to remove wholesale by testing whether `<dirName>/research` appeared as any two adjacent path segments — a naming-convention match, not the ownership proof its own comment required. That also matched the research **root** (`~/.pi/research`, which holds `config.env` and `knowledge_db/`), unrelated directories beneath it (`/mnt/backup/.pi/research/mystate`), and traversal forms (`.../research/state/../../..`) that resolve outside our tree entirely — each taking a recursive `rmSync` with no prompt. Dropping the documented `/state` suffix is a one-directory-off mistake the runtime honors literally, so a deliberate `npm run cleanup` could silently destroy every saved setting and the entire knowledge store. The check now requires the full default shape — the resolved path's trailing segments being `<dirName>/research/state` — which keeps a relocated-but-default layout reclaimable while refusing the root, siblings, and traversal escapes; everything else falls through to the targeted, filename-scoped sweep.
|
|
174
|
+
- **The GPU lock could be stolen from a live process that was still using it.** The staleness backstop was a hardcoded 5 minutes, which exactly equalled the default `EMBEDDING_MODEL_INIT_TIMEOUT_MS` (300000, schema max 600000) — and the embedder holds that lock across the whole model download plus pipeline load. Dead owners are already reclaimed separately via PID+startTime liveness, so this path only ever fires against a process just proven alive: a first-time model fetch running inside its own permitted budget would have the lock taken out from under it, re-admitting exactly the concurrent WebGPU/Dawn initialization the lock exists to prevent. The threshold is now 15 minutes, strictly above the configurable maximum hold.
|
|
175
|
+
- **The CPU-fallback embedder load leaked a native ONNX session on timeout.** `loadPipelineWithTimeout` grew a late-arrival disposal guard in 1.3.11, but the sibling `loadModelOnCPU` — the path taken *after* a WebGPU failure, on a host already under memory pressure — kept the same uncancellable `withTimeout` race with no such guard, orphaning one native session per retry. It now disposes the late arrival identically.
|
|
176
|
+
- **Concurrent knowledge searches tore down each other's TUI indicator.** The widget ID is a single global slot and the tool is `executionMode: 'parallel'` (and separately drivable via `/knowledge-store`), so the first search to finish removed the panel while a sibling was still running. Show/hide is now reference-counted; only the last one out removes it.
|
|
177
|
+
- **`registerAndReplace()` had no disposal-in-flight guard.** Unlike `clear()`/`replace()`/`disposeAll()`, it read and disposed `registration.instance` without joining an in-flight `disposalPromise`, then swapped the whole registration object — so a concurrent teardown could dispose the same instance twice and have its replacement orphaned. Currently unreachable (no call sites), fixed as a latent landmine.
|
|
178
|
+
- **The SSRF-safe undici `Agent` was never released, and was not actually a singleton.** It is meant to be one process-lifetime instance holding keep-alive sockets to every host scraped; the CLI hard-exits so it never noticed, but an embedding host (the pi extension) survives SDK init/shutdown cycles. Two problems: nothing ever closed it, and the cache held the *resolved value*, assigned only after `await import('undici')` — so every concurrent first caller passed the `undefined` check, built its own `Agent`, and all but the last were handed live instances the cache never referenced. Those opened sockets that no shutdown path could ever close, which is the very leak being fixed; scrapes run concurrently by design, so the first burst hit it. The cache now holds the construction *promise*, so concurrent callers join one build, and `shutdownResearchSDK()` closes it and clears the cache — awaiting an in-flight construction rather than orphaning it — so a later re-initialization rebuilds instead of reusing a closed Agent.
|
|
179
|
+
- **A cancelled run reported itself as a component failure in `--json`.** A Ctrl-C unwinding through the planner surfaces internally as `Coordinator failed: Request was aborted`, and that raw wording was emitted as the payload's `error`. The plain-text branch has always refused to say "failed" for a deliberate stop — "Cancellation is a completed intention, not a failure" — but JSON said it anyway, and SKILL.md instructs agents to relay these messages, so a user who pressed Ctrl-C was told the coordinator broke. `error` now reads `pi-research research cancelled.` with the original wording preserved under `cause`; `cancelled`, `exitCode` and the absence of `retryable` are unchanged. Verified against a real interrupted run: exit 130, correct payload.
|
|
180
|
+
- **Two prompts hardcoded the stale-year examples that `injectCurrentDate` was deliberately generalized away from.** That helper derives its examples from the current year precisely so the guidance "stays correct in any year (a hardcoded '2024 or 2025' reads wrong from 2027 on)" — and the coordinator and evaluator templates then said exactly `2024 or 2025` in their own bodies, duplicating the injected guidance with values that expire. From January 2027 they would have told the model the stale years were 2024/2025 while the actually-stale ones were 2025/2026, implicitly legitimising the year to avoid. Both now refer to the injected anchor instead of restating years.
|
|
181
|
+
- **`PLAYWRIGHT_BROWSERS_PATH` moved where the browser was looked for but never where it was installed** — plumbing added, and the documentation corrected to stop overclaiming. It was documented as overriding "where the camoufox binary is stored and looked up" and only ever did the second half: camoufox-js below 0.12.0 hardcodes `userCacheDir("camoufox")` and honours no variable at all. Setting it downloads to the default cache, leaves the postinstall's "already installed" probe permanently unsatisfied (so every install re-fetches), and makes the runtime report the browser missing right after a successful download. `CAMOUFOX_INSTALL_DIR` — the variable camoufox-js 0.12.0 introduced — now takes precedence, `PLAYWRIGHT_BROWSERS_PATH` is an alias, and both the postinstall fetch and the browser workers are handed it. **This is inert on the pinned camoufox-js 0.10.x** (see the dependency note below for why the pin cannot advance), so the plumbing is correct-and-waiting rather than a live fix; `CONFIGURATION.md` now states plainly that a custom browser directory is unsupported until the pin moves.
|
|
182
|
+
|
|
183
|
+
### Tests
|
|
184
|
+
|
|
185
|
+
- The 1.3.11 backup-pruner TTL retention fix — a silent permanent-data-loss fix — shipped with no regression test. Six now cover it (retention inside the TTL, pruning past it, never deleting the newest, ranking by the trailing backup timestamp rather than whole-name sort, migration-temp reaping, and never touching a temp-named live table); two fail against the pre-fix code.
|
|
186
|
+
- `task-execution-service`'s module-level liveness-probe cache (1s) and restart cooldown (10s) both outlive a test, and the reset seam that existed for exactly this had zero callers — leaving the suite order-dependent. The seam now resets both globals and is called in `beforeEach`.
|
|
187
|
+
- `cli-bridge-env.test.ts` wrote fixtures into the real home directory; a hard kill before its cleanup stranded them with nothing to sweep up. It now redirects `HOME`/`USERPROFILE` to a temp dir.
|
|
188
|
+
- Fake timers in `circuit-breaker.test.ts` and `retry-utils.test.ts` were never uninstalled (`restoreAllMocks` does not do it), leaving the clock faked at file end.
|
|
189
|
+
- **`verify-package` now checks that `dist/prompts` is byte-identical to `src/prompts`, not merely present.** The existing check counted files, so a stale copy passed every gate — and `dist/prompts` is what the bundled CLI and the agent-skill engine actually load. Editing a prompt without rebuilding leaves the shipped product running the old instructions with a fully green suite; that state existed in this working tree and the new check caught it. A truncated or partial copy fails here too.
|
|
190
|
+
- **The shipped prompt templates are now rendered through the real substitution path and asserted to leave no `{{placeholder}}` behind**, for the coordinator and for the evaluator at all three complexity levels (its template branches on complexity, so one level passing proves little). Previously one placeholder was covered, by name, against a stub template — so adding a placeholder to a `.md` without wiring it into the call map sent literal braces to the model as instructions it could not act on. The test reads the real files, so it cannot drift from what ships.
|
|
191
|
+
- New coverage for the NVD recency seek (tail offset requested, no extra request when results already fit, newest-first ordering) and for the rate-limiter timer staying ref'd — the last asserted directly, because that failure is invisible under a test runner, which always holds the event loop open by itself.
|
|
192
|
+
- **The JWT ReDoS regression test asserts a complexity class rather than a wall-clock budget.** Its first form used a 200ms threshold with roughly 2× headroom, which cannot work here: the 193-file parallel suite inflated a 41ms post-fix pass to 288ms, overlapping the ~393ms pre-fix cost on an idle machine, so any fixed number either flakes under load or stops catching the regression. It now doubles the input and requires the cost not to more than double — both measurements inflate together under load, so the ratio is stable. Measured 1.87× fixed against 4.01× unbounded on the raw pattern (2.06× through `redactSecrets`), threshold 3.0, mutation-verified at 4.80× against the pre-fix pattern.
|
|
193
|
+
|
|
194
|
+
### Changed
|
|
195
|
+
|
|
196
|
+
- **npm keywords aligned with the repository's GitHub topics.** All eight topics (`agent-skills`, `ai-research`, `browser-automation`, `claude-code`, `llm-tools`, `web-access`, `web-scraping`, `web-search`) are now present; `claude-code` and `codex` were missing entirely despite both being first-class agent-skill install targets alongside the already-listed `openclaw`. Added rather than replaced: `pi-package` is load-bearing, not decorative — pi's own package documentation states the pi.dev gallery lists packages carrying that keyword, so swapping the list wholesale for the GitHub topics would have delisted this package from the pi ecosystem. pi has no separate tag field of its own (its manifest accepts only `extensions`/`skills`/`prompts`/`themes`/`video`/`image`), so npm `keywords` is the single tag surface for both registries.
|
|
197
|
+
|
|
198
|
+
### Added
|
|
199
|
+
|
|
200
|
+
- **`status` now reports whether the stealth browser is installed**, with the exact command to fix it. This became urgent rather than cosmetic: npm 11.19 blocks package install scripts by default, so a fresh `npm install -g @lincoln504/pi-research` now skips this package's `postinstall` — and therefore the camoufox download — while reporting success. Verified by installing the packed tarball under npm 11.19: everything else survives (onnxruntime, sharp, esbuild and webgpu all ship prebuilt platform packages, and `dist/` is in the tarball), so the browser is the single casualty and nothing else signals it. Previously `status` answered `ready: true` with no browser present and the first symptom was a scrape degrading mid-run. A missing browser is reported alongside `ready` rather than folded into it, because the fetch path still serves most pages. The check is a resolve plus an `existsSync` — it never launches anything.
|
|
201
|
+
|
|
202
|
+
### Dependencies
|
|
203
|
+
|
|
204
|
+
Every upgrade below was validated by actually running the subsystem it touches, not by a green suite alone — the browser is mocked throughout the test suite, so a Juggler mismatch would reach production with the whole unit suite passing.
|
|
205
|
+
|
|
206
|
+
- **Browser binary refreshed to Firefox 152 without moving the npm pin.** The camoufox *binary* is fetched dynamically and is not governed by the `camoufox-js` version, so `v152.0.4-beta.28` (Firefox 152) runs fine on the pinned camoufox-js 0.10.2, whose `MIN_VERSION` of `beta.19` accepts it. That closes a real staleness gap — the stealth browser had been presenting Firefox 135 from March 2025 while the fetch path advertised Firefox 153, itself a bot signal. Verified by launching and driving a real browser (both 152.0.4-beta.28 and the older 135.0.1-beta.24 launch, navigate and report a correct UA under playwright-core 1.60.0) and by all 13 live-network search/scrape connectivity tests.
|
|
207
|
+
- **`camoufox-js` stays on ^0.10.2 and `impit` on 0.13.0 — attempted, measured, reverted.** Both blockers ARCHITECTURE.md recorded had genuinely lapsed (camoufox restored Windows assets in `v152.0.4-beta.26`; impit's `only-allow pnpm` guard existed only in 0.13.1/0.14.0), so the upgrade was made — and CI caught a third, worse blocker on Windows. camoufox-js 0.12.0 requires `better-sqlite3 ^13.0.1`, and better-sqlite3 13 **dropped prebuilt binaries**: 12.x installs via `prebuild-install || node-gyp rebuild`, while 13.x ships no install script at all, so npm auto-runs `node-gyp rebuild` against its `binding.gyp` and every install demands a full C++ toolchain. `npm ci` failed outright on the Windows runner (`could not find a version of Visual Studio 2017 or newer`) — i.e. it would break installation for most Windows users, exactly what this pin exists to prevent. The blocker is now better understood than before: check better-sqlite3 first, not camoufox.
|
|
208
|
+
- **`@lancedb/lancedb` 0.29.0 → 0.37.1.** Validated beyond the suite: 0.37.1 opens a real 116 MB, 2249-row store written by 0.29 and serves a live knowledge search against it with real embeddings. Two measured invariants the store's own code depends on were re-checked and hold: the three commit-conflict strings `isLanceCommitConflict` matches are still present in the 0.37.1 native binary, and `createIndex` still advances `version()` by exactly one for a vector index, an FTS index and a `replace: true` rebuild alike.
|
|
209
|
+
- **`youtubei.js` ^17 → ^18** (live transcript fetch verified) and **`@types/jsdom` ^28 → ^30**.
|
|
210
|
+
|
|
211
|
+
Held deliberately, each for a checked reason rather than caution:
|
|
212
|
+
|
|
213
|
+
- **`apache-arrow` stays at `21.1.0`.** The minor bump to 21.2.0 breaks the knowledge store outright — LanceDB's Rust-side reader cannot parse the schema Arrow 21.2 writes, failing every real-table open with `Unable to get root as footer: RangeOutOfBounds … Type::FixedSizeList`, across 56 unit and 36 integration tests. Attempted, measured, reverted; the pin's rationale is now empirical rather than assumed.
|
|
214
|
+
- **`bgutils-js` stays at ^3.** v4 removes the root export entirely (no `"."` in its `exports` map), so the `await import('bgutils-js')` the PoToken path uses would throw outright, and restructures the API (`Challenge.create` → a free `getChallenge`, `BotGuardClient` now event-emitter based with a different `snapshot` shape). That is a rewrite of the most fragile integration in the tree, and v3 demonstrably works — a live transcript fetch succeeds today.
|
|
215
|
+
- **`playwright-core` stays at `1.60.0`** (camoufox-js peer range, above) and **`typebox` at `1.3.7`** (pi 0.84.2, the newest published, pins exactly that).
|
|
216
|
+
- **`typescript` stays in `>=6.0.3 <6.1.0`.** TypeScript 7 is unusable here regardless of its own merits: typescript-eslint's newest release (8.67.0) still declares `typescript: ">=4.8.4 <6.1.0"` and hardcodes the same supported range, so 7.x would break linting. 6.0.3 is already the newest release in the supported line.
|
|
217
|
+
|
|
218
|
+
### Verified, no change needed
|
|
219
|
+
|
|
220
|
+
- Every external integration was exercised against its live API rather than read: NVD, OSV, CISA KEV, and GitHub Advisories all return current data; YouTube transcripts resolve correctly despite `youtubei.js` and `bgutils-js` each being a major behind; StackExchange returns canonical high-score answers on the path the tool actually uses. CISA KEV and GitHub Advisories already order results deliberately (by due date and `direction=desc` respectively) and were left alone.
|
|
221
|
+
- The published package points `pi.extensions` at raw `src/`, which contains two `enum`s and parameter properties — both fatal under Node's strip-only type stripping. pi loads extensions through `jiti`, which transpiles fully, so this is safe; noted because the failure mode would be a total load failure, not a degradation.
|
|
222
|
+
|
|
8
223
|
## [1.3.11] - 2026-08-14
|
|
9
224
|
|
|
10
225
|
Alongside the revert below, a four-way independent audit (docs-vs-code
|