@lincoln504/pi-research 1.3.12 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5495 -4645
  3. package/dist/prompts/research-tool-usage.md +1 -1
  4. package/dist/prompts/researcher.md +1 -0
  5. package/dist/prompts/system-lead-router.md +122 -0
  6. package/dist/prompts/system-lead-synthesizer.md +46 -0
  7. package/dist/thread-worker.mjs +94 -19
  8. package/docs/ARCHITECTURE.md +18 -10
  9. package/docs/CHANGELOG.md +149 -0
  10. package/docs/CONFIGURATION.md +106 -7
  11. package/package.json +2 -2
  12. package/scripts/build.cjs +8 -1
  13. package/skills/pi-research/SKILL.md +1 -1
  14. package/src/cli.ts +5 -1
  15. package/src/config.ts +12 -8
  16. package/src/constants.ts +1 -1
  17. package/src/core/interfaces/observer-interfaces.ts +3 -3
  18. package/src/core/interfaces/orchestration-interfaces.ts +2 -0
  19. package/src/core/interfaces/planning-interfaces.ts +7 -1
  20. package/src/core/interfaces/scheduler-interfaces.ts +18 -2
  21. package/src/core/llm/agentic-repair.ts +7 -2
  22. package/src/core/llm/inject-date.ts +4 -1
  23. package/src/core/llm/llm-utils.ts +7 -0
  24. package/src/core/planning-service.ts +719 -91
  25. package/src/core/planning-utils.ts +21 -5
  26. package/src/core/service-initialization.ts +19 -3
  27. package/src/healthcheck/index.ts +11 -2
  28. package/src/index.ts +4 -3
  29. package/src/infrastructure/browser/browser-client.ts +68 -34
  30. package/src/infrastructure/browser/browser-task-scheduler.ts +220 -34
  31. package/src/infrastructure/browser/config.ts +41 -1
  32. package/src/infrastructure/browser/priority-task-queue.ts +55 -1
  33. package/src/infrastructure/browser/scheduler-factory.ts +12 -1
  34. package/src/infrastructure/browser/task-execution-service.ts +5 -5
  35. package/src/infrastructure/browser/thread-worker-browser.ts +32 -2
  36. package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
  37. package/src/infrastructure/browser/thread-worker.mjs +94 -19
  38. package/src/infrastructure/browser/thread-worker.ts +2 -2
  39. package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
  40. package/src/infrastructure/embedding/embedding-client.ts +14 -4
  41. package/src/infrastructure/embedding/embedding-factory.ts +83 -15
  42. package/src/infrastructure/file-lock-service.ts +214 -19
  43. package/src/infrastructure/knowledge-store-service.ts +14 -0
  44. package/src/infrastructure/research-run-semaphore.ts +1 -1
  45. package/src/infrastructure/service-initialization.ts +7 -0
  46. package/src/knowledge/embedder-init.ts +43 -17
  47. package/src/logger.ts +11 -0
  48. package/src/orchestration/deep-research-orchestrator.ts +141 -87
  49. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  50. package/src/orchestration/research-orchestration-service.ts +8 -0
  51. package/src/orchestration/research-synthesis-service.ts +53 -1
  52. package/src/orchestration/researcher-executor.ts +53 -2
  53. package/src/orchestration/researcher.ts +15 -2
  54. package/src/prompts/research-tool-usage.md +1 -1
  55. package/src/prompts/researcher.md +1 -0
  56. package/src/prompts/system-lead-router.md +122 -0
  57. package/src/prompts/system-lead-synthesizer.md +46 -0
  58. package/src/tools/research-knowledge-search.ts +11 -3
  59. package/src/tools/scrape.ts +6 -0
  60. package/src/tools/search.ts +7 -0
  61. package/src/tools/security.ts +27 -9
  62. package/src/tools/stackexchange.ts +13 -7
  63. package/src/tools/youtube-transcript.ts +26 -12
  64. package/src/utils/cancellation.ts +36 -0
  65. package/src/utils/coverage-digest.ts +204 -0
  66. package/src/utils/llm-usage.ts +33 -6
  67. package/src/utils/metrics-summary.ts +20 -4
  68. package/src/utils/metrics.ts +27 -1
  69. package/src/web-research/browser-search.ts +93 -20
  70. package/dist/prompts/system-lead-evaluator.md +0 -118
  71. package/src/prompts/system-lead-evaluator.md +0 -118
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,155 @@ 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
+
8
157
  ## [1.3.12] - 2026-08-15
9
158
 
10
159
  A six-way independent audit of the whole tree (core/orchestration/LLM,
@@ -74,12 +74,12 @@ Research
74
74
  | `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
75
75
  | `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
76
76
  | `PI_RESEARCH_MAX_FAILED_RESEARCHERS` | `2` | 1–10 | Unique researcher failures that abort the whole run. Raise to let slower, still-in-flight researchers finish before giving up. |
77
- | `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. |
77
+ | `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. It sets how *fast* a search burst drains, not how large a burst may be — a burst larger than the pool waits its turn rather than being trimmed or timing out. |
78
78
  | `PI_RESEARCH_WORKER_CONCURRENCY` | `2` | 1–10 | Tasks per worker process. |
79
79
  | `PI_RESEARCH_MAX_CONCURRENT_RUNS` | `3` | ≥1 | Machine-wide cap on research runs executing at the same time, across **every** process (CLI, agent skill, pi extension, SDK). Runs beyond the cap queue rather than fail. All concurrent runs share one leader-elected browser/embedding pool, so oversubscribing it degrades every run at once. |
80
80
  | `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` | `600000` | ≥0 | How long a run queues for a free slot before failing with "maximum concurrent research runs reached" (CLI exit `75`). `0` = fail immediately instead of queueing. |
81
- | `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and evaluator keep using the session model. Accepts `provider/id` or a bare model id. |
82
- | `PI_RESEARCH_DISABLED_TOOLS` | _(none)_ | — | Comma-separated research tools to disable for a run (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `grep`, `read`). Removed from every researcher's toolset and named in the coordinator/evaluator prompt. Strictly additive — it can only remove capabilities, never grant one, and it stacks on top of the default exclusions rather than replacing them. An unrecognized name excludes nothing and is warned about rather than failing the run. |
81
+ | `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and research lead keep using the session model. Accepts `provider/id` or a bare model id. |
82
+ | `PI_RESEARCH_DISABLED_TOOLS` | _(none)_ | — | Comma-separated research tools to disable for a run (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `grep`, `read`). Removed from every researcher's toolset and named in the coordinator and research-lead prompts. Strictly additive — it can only remove capabilities, never grant one, and it stacks on top of the default exclusions rather than replacing them. An unrecognized name excludes nothing and is warned about rather than failing the run. |
83
83
  | `PI_RESEARCH_REPORT_EXPORT_ENABLED` (TUI) | `false` | — | Front-ends write a Markdown report to disk and surface its path. |
84
84
  | `PI_RESEARCH_REPORT_EXPORT_DIR` | _(smart cwd)_ | — | Pin exported reports to a fixed directory, bypassing the cwd-relative resolution. Useful for the agent skill, which runs from the host agent's arbitrary directory. |
85
85
  | `PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING` | `0.15` | 0.05–1.0 | Max fraction of the context window used for initial scrape context. |
@@ -99,10 +99,10 @@ Timeouts
99
99
 
100
100
  | Variable | Default | Range | Description |
101
101
  |----------|---------|-------|-------------|
102
- | `PI_RESEARCH_LLM_TIMEOUT_MS` | `300000` | 60000–1800000 | Coordinator / evaluator / repair / knowledge LLM call timeout. |
102
+ | `PI_RESEARCH_LLM_TIMEOUT_MS` | `300000` | 60000–1800000 | Coordinator / research-lead / repair / knowledge LLM call timeout. |
103
103
  | `PI_RESEARCH_SCRAPE_TIMEOUT_MS` | `15000` | 5000–120000 | Per-page scrape (page-load) timeout. |
104
104
  | `PI_RESEARCH_SEARCH_TIMEOUT_MS` | `45000` | 5000–120000 | Browser search page timeout. |
105
- | `PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS` | `10000` | 2000–120000 | Queue-wait / overhead margin added to each browser op's own timeout (a search task ceiling is `SEARCH_TIMEOUT_MS` + this; a scrape is `SCRAPE_TIMEOUT_MS` + this). |
105
+ | `PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS` | `10000` | 2000–120000 | Overhead margin added to each browser op's own timeout (a search task ceiling is `SEARCH_TIMEOUT_MS` + this; a scrape is `SCRAPE_TIMEOUT_MS` + this). These ceilings bound **execution**: a task's clock starts when a worker picks it up, so time spent queued behind other work is not charged against it and raising this to cover a busy pool is neither needed nor effective. |
106
106
  | `PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS` | `10000` | 2000–120000 | Pre-flight health-check timeout. |
107
107
 
108
108
  LLM output & reasoning
@@ -111,8 +111,8 @@ These are advanced, env-only knobs (not in the TUI).
111
111
 
112
112
  | Variable | Default | Range | Description |
113
113
  |----------|---------|-------|-------------|
114
- | `PI_RESEARCH_LLM_THINKING_LEVEL` | `off` | off · minimal · low · medium · high | Chain-of-thought level for all engine LLM work (coordinator, evaluator, synthesis, JSON-repair, knowledge extraction, and researcher sub-agents). Off by default — these calls emit structured JSON / cited reports, so a thinking block only consumes the output budget and can truncate the answer. Clamped per model by pi. |
115
- | `PI_RESEARCH_PLANNING_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens for the plan + mid-round evaluator decision. Clamped to the model's real ceiling. |
114
+ | `PI_RESEARCH_LLM_THINKING_LEVEL` | `off` | off · minimal · low · medium · high | Chain-of-thought level for all engine LLM work (coordinator, router, synthesizer, JSON-repair, knowledge extraction, and researcher sub-agents). Off by default — these calls emit structured JSON / cited reports, so a thinking block only consumes the output budget and can truncate the answer. Clamped per model by pi. |
115
+ | `PI_RESEARCH_PLANNING_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens for the coordinator's plan. The router's decision has its own, smaller ceiling; the final synthesis uses `PI_RESEARCH_SYNTHESIS_MAX_TOKENS`. Clamped to the model's real ceiling. |
116
116
  | `PI_RESEARCH_SYNTHESIS_MAX_TOKENS` | `32768` | 1024–131072 | Max output tokens for the final synthesized report. Clamped to the model's real ceiling. |
117
117
 
118
118
  Knowledge store
@@ -239,6 +239,105 @@ just export the variable for that process.
239
239
  > `/research-config` TUI (which writes it), export variables in your shell, or use a
240
240
  > loader such as direnv. `.env.example` is a reference, not an active config file.
241
241
 
242
+ ### Prompt caching
243
+
244
+ A research run re-sends a large, mostly-unchanged prompt many times: a researcher
245
+ re-sends its whole conversation (including every scraped page) on each tool turn, and
246
+ the research lead used to re-send the accumulated findings on every round. Every provider will
247
+ serve that repetition from a prompt cache at a fraction of the input price — but only
248
+ for an **exact prefix** of the request, and only if nothing that varies sits in front
249
+ of it.
250
+
251
+ What pi-research does about it. The research lead is split into two roles so that the
252
+ repetition is removed rather than merely discounted. The **router** decides each round
253
+ whether to continue, reading each report in full on the one round it arrives and only its
254
+ short coverage digest thereafter, instead of re-reading every report collected so far. The **synthesizer** runs once, at the end, and is the only
255
+ call that reads the reports in full. Before the split, one call did both jobs and re-sent
256
+ the whole corpus every round, so its input grew with the square of the round count. The
257
+ synthesizer's corpus is also budgeted against the model's context window: over budget, the
258
+ reports are reduced in partial passes and merged rather than truncated or rejected.
259
+
260
+ How often the router runs depends on the round budget. Depth 2 and 3 route on every round
261
+ but the last. Depth 1's base budget is two rounds and the last one is skipped, so a plain
262
+ depth-1 run never routes — but steering raises the budget (up to two extra rounds), and a
263
+ steered depth-1 run does route. That is also the depth-1 case carrying the most findings,
264
+ so it is where routing on digests rather than on the full corpus matters most.
265
+
266
+ On top of that, both lead prompts are laid out stable-part-first: they interpolate only
267
+ values fixed for the whole run (complexity, team size, query budget, disabled tools), and
268
+ everything that changes between rounds — root query, round number, agenda, executed
269
+ queries, steering, round-phase guidance — is appended afterwards as a `RUN CONTEXT`
270
+ block. Unit tests hold the layout in place; if you edit
271
+ `src/prompts/system-lead-router.md` or `src/prompts/system-lead-synthesizer.md`, keep
272
+ run-varying text out of them.
273
+
274
+ What your provider does about it depends on which API pi talks to (the provider's `api`
275
+ field in `~/.pi/agent/models.json`) and, on OpenAI-compatible endpoints, on one `compat`
276
+ key:
277
+
278
+ | Provider API | Behaviour |
279
+ |--------------|-----------|
280
+ | `anthropic-messages` | pi inserts `cache_control` breakpoints automatically — on the system prompt, the last tool definition, and the last user/assistant/tool-result block. Nothing to configure. |
281
+ | `openai-completions` | pi inserts the **same** Anthropic-style breakpoints into the OpenAI-shaped payload whenever `compat.cacheControlFormat` is `"anthropic"`. Auto-detection sets that key for exactly one case — OpenRouter routing to an `anthropic/*` model — so by default no markers are sent and the provider's own implicit prefix caching is relied on. That default is correct for OpenAI, DeepSeek, Gemini and GLM, which all cache implicitly. |
282
+ | `openai-responses` | No markers at any setting; implicit caching only. |
283
+
284
+ Note that the marker behaviour is gated on `cacheControlFormat`, **not** on the `api`
285
+ field — setting the key makes an `openai-completions` provider emit explicit breakpoints
286
+ exactly like an `anthropic-messages` one. That matters when reading the counters below:
287
+ on a provider you have configured this way, a zero cache read is not evidence that
288
+ markers are missing.
289
+
290
+ The gap is providers that need explicit markers but are reached over an OpenAI-compatible
291
+ endpoint and are not the one auto-detected case. Anything in that category (Qwen through
292
+ OpenRouter, or any gateway fronting Claude) caches **nothing at all**, silently and with
293
+ no error. Fix it per provider with a `compat` block:
294
+
295
+ ```json
296
+ {
297
+ "providers": {
298
+ "openrouter": {
299
+ "api": "openai-completions",
300
+ "compat": {
301
+ "cacheControlFormat": "anthropic",
302
+ "sessionAffinityFormat": "openrouter",
303
+ "sendSessionAffinityHeaders": true
304
+ }
305
+ }
306
+ }
307
+ }
308
+ ```
309
+
310
+ `cacheControlFormat: "anthropic"` forces the markers on. It is safe to set for a whole
311
+ provider: models that do not use markers ignore the extra field. The two session-affinity
312
+ keys ask OpenRouter to keep a conversation on the replica that warmed its cache; without
313
+ them OpenRouter falls back to hashing the first messages, which an agentic loop mutates.
314
+
315
+ `PI_CACHE_RETENTION=long` (a pi variable, not a pi-research one) requests 1-hour
316
+ retention where the provider supports it. It raises the cache-write multiplier from
317
+ 1.25x to 2x, so it only pays off when a run's gaps between calls exceed the provider's
318
+ default window — nominally 5 minutes.
319
+
320
+ Measure before enabling it. On a direct probe against GLM over `anthropic-messages`, an
321
+ entry was still read back unchanged after **seven minutes at the default retention**, and
322
+ `long` produced an identical number — so on that route the doubled write multiplier buys
323
+ nothing. The nominal 5-minute figure is a floor providers are free to exceed, not a
324
+ deadline you can plan around.
325
+
326
+ Verifying it works. Set `PI_RESEARCH_DEBUG=true` and read the run log: every LLM call
327
+ records `llm_cache_read_tokens_total` and `llm_cache_write_tokens_total` alongside
328
+ `llm_tokens_total`, labelled by component (`coordinator` / `router` / `synthesizer` /
329
+ `researcher`).
330
+ Cache reads that stay at zero on a multi-turn researcher mean the prefix is not being
331
+ matched — usually a provider with a minimum cacheable prefix (commonly 1024–4096 tokens)
332
+ that short prompts never reach, or an explicit-marker provider missing the `compat` block
333
+ above. A counter that is absent rather than zero means the provider does not report
334
+ caching at all.
335
+
336
+ Cache **writes** are the weaker signal of the two. Several providers report reads and
337
+ omit writes entirely — OpenRouter and Z.ai's Anthropic-compatible endpoint both return
338
+ non-zero `cache_read` alongside a flat zero `cache_write` — so a zero there is normal and
339
+ says nothing about whether caching is working. Judge it by the read counter.
340
+
242
341
  ### Where files live
243
342
 
244
343
  All pi-research state lives under its own namespace, `~/.pi/research/`:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.3.12",
4
- "description": "Free unlimited web search and deep research for the Pi coding agent. Local knowledge store. Also works from Claude Code, Codex, and OpenClaw.",
3
+ "version": "1.4.1",
4
+ "description": "Web research for your agents with smart and safe tooling + knowledge store",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "main": "./src/index.ts",
package/scripts/build.cjs CHANGED
@@ -87,9 +87,16 @@ function copyThreadWorker() {
87
87
  // 'prompts')), so the templates must sit next to the bundle.
88
88
  function copyPrompts() {
89
89
  fs.mkdirSync(p('dist', 'prompts'), { recursive: true });
90
- for (const f of fs.readdirSync(p('src', 'prompts'))) {
90
+ const sources = fs.readdirSync(p('src', 'prompts'));
91
+ for (const f of sources) {
91
92
  fs.copyFileSync(p('src', 'prompts', f), p('dist', 'prompts', f));
92
93
  }
94
+ // Prune prompts that no longer exist in src. Copying alone leaves a deleted or renamed
95
+ // prompt behind in an incremental dist forever; verify-package rejects the orphan at
96
+ // publish time, which surfaces as a confusing failure long after the rename.
97
+ for (const f of fs.readdirSync(p('dist', 'prompts'))) {
98
+ if (!sources.includes(f)) fs.rmSync(p('dist', 'prompts', f));
99
+ }
93
100
  }
94
101
 
95
102
  const TARGETS = {
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.3.12", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.4.1", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/src/cli.ts CHANGED
@@ -168,7 +168,11 @@ function makeProgressObserver(): HeadlessObserverOptions {
168
168
  toStderr(` • round ${data?.round}\n`);
169
169
  break;
170
170
  case 'search_start':
171
- toStderr(` • search burst: ${data?.queries?.length ?? 0} query/${(data?.queries?.length ?? 0) === 1 ? '' : 's'}\n`);
171
+ // "N queries", not "N query/s" — the old `query/${n===1?'':'s'}` form rendered a
172
+ // COUNT as what reads unmistakably as a RATE ("45 query/s"), so a burst that was
173
+ // in fact 45 queries looked like 45 per second and then appeared frozen. It also
174
+ // emitted a bare trailing slash ("1 query/") in the singular.
175
+ toStderr(` • search burst: ${data?.queries?.length ?? 0} ${(data?.queries?.length ?? 0) === 1 ? 'query' : 'queries'}\n`);
172
176
  break;
173
177
  case 'researcher_start':
174
178
  toStderr(` • researcher ${data?.id}: ${data?.name}\n`);
package/src/config.ts CHANGED
@@ -88,7 +88,7 @@ export const ConfigSchema = Type.Object({
88
88
  * never killed before it has used its full nav budget plus time waiting in the queue.
89
89
  * (default: 10000) */
90
90
  BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2000, maximum: 120000, default: 10000 }),
91
- /** Timeout for coordinator/evaluator/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
91
+ /** Timeout for coordinator/research-lead/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
92
92
  * Not exposed in TUI — controlled via PI_RESEARCH_LLM_TIMEOUT_MS env var.
93
93
  * Ceiling raised to 30min (matching RESEARCHER_TIMEOUT_MS): a slow model on a
94
94
  * large-context synthesis/plan call can legitimately run >10min, and the old
@@ -96,7 +96,7 @@ export const ConfigSchema = Type.Object({
96
96
  * (e.g. 900000), causing those calls to time out mid-generation. */
97
97
  LLM_TIMEOUT_MS: Type.Number({ minimum: 60000, maximum: 1800000, default: 300000 }),
98
98
  /** Chain-of-thought "thinking" level for the engine's own LLM calls (coordinator,
99
- * evaluator, synthesis, JSON-repair, knowledge extraction) AND the researcher
99
+ * router, synthesizer, JSON-repair, knowledge extraction) AND the researcher
100
100
  * sub-agents. Default 'off': these calls emit structured JSON / cited reports, not
101
101
  * open-ended reasoning, so thinking only burns the output-token budget (often
102
102
  * truncating before the JSON/report block) and adds latency for at most a marginal
@@ -111,22 +111,26 @@ export const ConfigSchema = Type.Object({
111
111
  Type.Literal('medium'),
112
112
  Type.Literal('high'),
113
113
  ], { default: 'off' }),
114
- /** Max output tokens for the coordinator plan + mid-round evaluator decision (and,
115
- * when the evaluator synthesizes mid-round, the report). Default 16384. The blanket
114
+ /** Max output tokens for the coordinator's round-1 plan. Default 16384. The routing
115
+ * decision has its OWN, much smaller ceiling (it emits a decision, never a report,
116
+ * and cannot synthesize mid-round under the split protocol); the final report uses
117
+ * SYNTHESIS_MAX_TOKENS below. The blanket
116
118
  * 4096 cap previously throttled every call and could be exhausted by a thinking block
117
119
  * before any text was emitted. Capped at the model's real ceiling at call time.
118
120
  * Not exposed in TUI — controlled via PI_RESEARCH_PLANNING_MAX_TOKENS env var. */
119
121
  PLANNING_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
120
- /** Max output tokens for the final synthesized research report (the forced-synthesis
121
- * evaluator call). Default 32768 so a full cited report is never truncated. Capped at
122
+ /** Max output tokens for the final synthesized research report (the synthesizer call,
123
+ * and each pass of a reduce when the corpus does not fit in one). Default 32768 so a
124
+ * full cited report is never truncated. It is also the reserve subtracted from the
125
+ * model's context window when budgeting that corpus. Capped at
122
126
  * the model's real maxTokens at call time.
123
127
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
124
128
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
125
129
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
126
130
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
127
131
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
128
- * and the knowledge synthesis background LLM. The coordinator and evaluator always
129
- * use the caller's model (ctx.model).
132
+ * and the knowledge synthesis background LLM. The coordinator and the research lead
133
+ * always use the caller's model (ctx.model).
130
134
  */
131
135
  RESEARCH_MODEL: Type.Optional(Type.String()),
132
136
  /** Explicit directory for the knowledge store database (overrides default) */
package/src/constants.ts CHANGED
@@ -69,7 +69,7 @@ export function getUnitsPerResearcher(config?: Config): number {
69
69
  return 1 + Math.min(batches, 4);
70
70
  }
71
71
 
72
- /** Progress bar weight per lead evaluator round */
72
+ /** Progress bar weight per research-lead round */
73
73
  export const LEAD_EVAL_UNITS = 2;
74
74
 
75
75
 
@@ -58,9 +58,9 @@ export interface ResearchObserver {
58
58
  // Synthesis/Completion
59
59
  /**
60
60
  * Fired when the run enters FINAL synthesis / stops consuming steering:
61
- * - deep mode: right before the forced (mustSynthesize) evaluator call, and
62
- * on the evaluator-chose-synthesize path (idempotent there — the flag is
63
- * already off via onEvaluationDecision('synthesize')).
61
+ * - deep mode: right before the synthesizer call, and on the path where the
62
+ * router chose to finish early (idempotent there — the flag is already off
63
+ * via onEvaluationDecision('synthesize')).
64
64
  * - quick mode: as soon as the researcher session settles, i.e. the point
65
65
  * the 500ms steering-consume poller stops running.
66
66
  * TUI consequence: steeringAcceptable flips false, so a late steer takes the
@@ -59,6 +59,8 @@ export interface RunResearchersOptions {
59
59
  export interface IResearchSynthesisService extends IService {
60
60
  storeReport(researchId: string, researcherId: string, report: string): void;
61
61
  getAllReports(researchId: string): Map<string, string>;
62
+ /** Coverage digest per stored report, derived when a researcher emitted none. */
63
+ getAllDigests(researchId: string): Map<string, string>;
62
64
  getReport(researchId: string, id: string): string | undefined;
63
65
  getReportsForRound(researchId: string, round: number): Map<string, string>;
64
66
  getReportCount(researchId: string): number;
@@ -56,6 +56,12 @@ export interface GenerateQueriesOptions {
56
56
  export interface UpdatePlanOptions {
57
57
  sessionId: string;
58
58
  reports: Map<string, string>;
59
+ /** Coverage digest per report id. The router reads the FRESH round's reports in full
60
+ * (evidence-based routing) and falls back to these digests only for PRIOR rounds — it
61
+ * does not read digests instead of the current round's reports. Optional: when omitted,
62
+ * digests are derived from the report bodies so a caller that doesn't track them still
63
+ * routes on coverage rather than on nothing. */
64
+ digests?: Map<string, string>;
59
65
  round: number;
60
66
  query: string;
61
67
  complexity: 1 | 2 | 3;
@@ -98,7 +104,7 @@ export interface IPlanningService extends IService {
98
104
  getTeamSize(complexity: 1 | 2 | 3): number;
99
105
  getQueryBudget(complexity: 1 | 2 | 3): number;
100
106
  getComplexityGuidance(complexity: 1 | 2 | 3, maxTeamSize: number, queryBudget: number): string;
101
- getEvaluatorComplexityGuidance(complexity: 1 | 2 | 3): string;
107
+ getEvaluatorComplexityGuidance(complexity: 1 | 2 | 3, researchRounds: number): string;
102
108
  getRoundPhaseGuidance(currentRound: number, maxRounds: number, complexity: 1 | 2 | 3, maxTeamSize: number): string;
103
109
  capResearcherQueries(plan: ResearchPlan, complexity: 1 | 2 | 3, serviceName: string): ResearchPlan;
104
110
  parseJsonPlan(text: string): ResearchPlan;
@@ -70,9 +70,25 @@ export interface ScrapeResult {
70
70
  /**
71
71
  * Base scheduler interface
72
72
  */
73
+ /**
74
+ * Reports when a task's EXECUTION starts and stops.
75
+ *
76
+ * Callers that keep their own guard over a scheduler call arm it on 'dispatched'
77
+ * rather than at call time, so queue wait never counts against the work's budget,
78
+ * and disarm it on 'settled' so the guard is not left running over a period in
79
+ * which nothing is executing — between a failed attempt and its retry, that
80
+ * period includes a backoff sleep, a re-enqueue and a fresh queue wait, and a
81
+ * timer left armed across it kills the retry for the predecessor's overrun.
82
+ *
83
+ * An implementation that cannot observe dispatch (BrowserClient, which only sees
84
+ * the leader's final reply) never calls it at all, and the caller's guard stays
85
+ * disarmed — that implementation bounds the call itself.
86
+ */
87
+ export type TaskDispatchListener = (event: 'dispatched' | 'settled') => void;
88
+
73
89
  export interface IScheduler {
74
- runSearch(query: string, config?: Config, signal?: AbortSignal): Promise<SearchResult[]>;
75
- runScrape(url: string, config?: Config, signal?: AbortSignal): Promise<ScrapeResult>;
90
+ runSearch(query: string, config?: Config, signal?: AbortSignal, onTaskEvent?: TaskDispatchListener): Promise<SearchResult[]>;
91
+ runScrape(url: string, config?: Config, signal?: AbortSignal, onTaskEvent?: TaskDispatchListener): Promise<ScrapeResult>;
76
92
  runHealthCheck(config?: Config, signal?: AbortSignal): Promise<{ success: boolean }>;
77
93
  shutdown(): Promise<void>;
78
94
  resetIdleTimerOnActivity?(): void;