@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.
Files changed (83) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5558 -4596
  3. package/dist/prompts/research-tool-usage.md +1 -1
  4. package/dist/prompts/researcher.md +1 -0
  5. package/dist/prompts/system-coordinator.md +1 -1
  6. package/dist/prompts/system-lead-router.md +122 -0
  7. package/dist/prompts/system-lead-synthesizer.md +46 -0
  8. package/dist/thread-worker.mjs +123 -30
  9. package/docs/ARCHITECTURE.md +69 -24
  10. package/docs/CHANGELOG.md +215 -0
  11. package/docs/CONFIGURATION.md +108 -8
  12. package/package.json +19 -11
  13. package/scripts/build.cjs +8 -1
  14. package/scripts/cleanup.cjs +26 -5
  15. package/scripts/setup.cjs +25 -1
  16. package/skills/pi-research/SKILL.md +1 -1
  17. package/src/cli.ts +41 -2
  18. package/src/config.ts +12 -8
  19. package/src/constants.ts +1 -1
  20. package/src/core/interfaces/observer-interfaces.ts +3 -3
  21. package/src/core/interfaces/orchestration-interfaces.ts +2 -0
  22. package/src/core/interfaces/planning-interfaces.ts +7 -1
  23. package/src/core/interfaces/scheduler-interfaces.ts +18 -2
  24. package/src/core/llm/agentic-repair.ts +7 -2
  25. package/src/core/llm/inject-date.ts +4 -1
  26. package/src/core/llm/llm-utils.ts +7 -0
  27. package/src/core/planning-service.ts +719 -91
  28. package/src/core/planning-utils.ts +21 -5
  29. package/src/core/service-initialization.ts +19 -3
  30. package/src/core/service-registry.ts +19 -3
  31. package/src/healthcheck/index.ts +11 -2
  32. package/src/index.ts +4 -3
  33. package/src/infrastructure/browser/browser-client.ts +68 -34
  34. package/src/infrastructure/browser/browser-task-scheduler.ts +220 -34
  35. package/src/infrastructure/browser/config.ts +77 -6
  36. package/src/infrastructure/browser/priority-task-queue.ts +55 -1
  37. package/src/infrastructure/browser/scheduler-factory.ts +12 -1
  38. package/src/infrastructure/browser/task-execution-service.ts +17 -7
  39. package/src/infrastructure/browser/thread-worker-browser.ts +66 -20
  40. package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
  41. package/src/infrastructure/browser/thread-worker.mjs +123 -30
  42. package/src/infrastructure/browser/thread-worker.ts +2 -2
  43. package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
  44. package/src/infrastructure/embedding/embedding-client.ts +14 -4
  45. package/src/infrastructure/embedding/embedding-factory.ts +83 -15
  46. package/src/infrastructure/file-lock-service.ts +214 -19
  47. package/src/infrastructure/gpu-resource-service.ts +15 -1
  48. package/src/infrastructure/knowledge-store-service.ts +14 -0
  49. package/src/infrastructure/research-run-semaphore.ts +1 -1
  50. package/src/infrastructure/service-initialization.ts +7 -0
  51. package/src/knowledge/embedder-init.ts +60 -18
  52. package/src/knowledge/store.ts +11 -5
  53. package/src/logger.ts +11 -0
  54. package/src/orchestration/deep-research-orchestrator.ts +141 -87
  55. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  56. package/src/orchestration/research-orchestration-service.ts +30 -1
  57. package/src/orchestration/research-synthesis-service.ts +53 -1
  58. package/src/orchestration/researcher-executor.ts +53 -2
  59. package/src/orchestration/researcher.ts +15 -2
  60. package/src/prompts/research-tool-usage.md +1 -1
  61. package/src/prompts/researcher.md +1 -0
  62. package/src/prompts/system-coordinator.md +1 -1
  63. package/src/prompts/system-lead-router.md +122 -0
  64. package/src/prompts/system-lead-synthesizer.md +46 -0
  65. package/src/sdk.ts +10 -1
  66. package/src/security/nvd-client.ts +55 -4
  67. package/src/security/osv-client.ts +13 -1
  68. package/src/tools/research-knowledge-search.ts +29 -4
  69. package/src/tools/scrape.ts +6 -0
  70. package/src/tools/search.ts +7 -0
  71. package/src/tools/security.ts +27 -9
  72. package/src/tools/stackexchange.ts +13 -7
  73. package/src/tools/youtube-transcript.ts +26 -12
  74. package/src/utils/cancellation.ts +36 -0
  75. package/src/utils/coverage-digest.ts +204 -0
  76. package/src/utils/llm-usage.ts +33 -6
  77. package/src/utils/log-utils.ts +16 -1
  78. package/src/utils/metrics-summary.ts +20 -4
  79. package/src/utils/metrics.ts +27 -1
  80. package/src/web-research/browser-search.ts +93 -20
  81. package/src/web-research/scraper-utils.ts +55 -14
  82. package/dist/prompts/system-lead-evaluator.md +0 -118
  83. package/src/prompts/system-lead-evaluator.md +0 -118
@@ -159,7 +159,17 @@ export class DeepResearchOrchestrator {
159
159
 
160
160
  const MAX_WAIT_RETRIES = 5;
161
161
  let waitRetryCount = 0;
162
- let loopSynthesisPlan: ResearchPlan | null = null;
162
+ // Whether the ROUTER chose to finish inside the loop, as opposed to the loop ending
163
+ // because the round budget ran out. The router never carries the report: it is asked
164
+ // for a decision, is given no corpus to write from (it sees each report once, on the
165
+ // round it arrives, and digests thereafter), and any content it returns anyway is
166
+ // discarded in planning-service. So this only records which UI and observer events
167
+ // already fired, not a result to reuse. Final synthesis runs either way, below.
168
+ let routerChoseSynthesis = false;
169
+ // Highest round number handed to observer.onRoundStart. The final synthesis
170
+ // announces itself too, and it must not repeat or skip: `round_start` is an SDK
171
+ // event and a consumer counting rounds reads the gaps.
172
+ let lastAnnouncedRound = 0;
163
173
 
164
174
  try {
165
175
  while (this.currentRound < maxRounds) {
@@ -190,11 +200,36 @@ export class DeepResearchOrchestrator {
190
200
  }
191
201
  }
192
202
 
203
+ // The final iteration synthesizes; it never researches. Leave BEFORE announcing
204
+ // it, because announcing it is the whole problem: the log said `Round 2/2`, the
205
+ // observer fired onRoundStart (which arms the panel's deferred clear, consumed
206
+ // by a researcher start that never comes), the SDK emitted a `round_start`
207
+ // event, and a full infrastructure health check ran — 105 seconds of it on one
208
+ // measured run — for a round in which no researcher was ever dispatched. 92 of
209
+ // 93 capped runs in the retained logs announced a round that did nothing.
210
+ //
211
+ // This must sit AFTER the steering block above: a message consumed at the top of
212
+ // the final iteration raises maxRounds and legitimately turns this into a real
213
+ // research round. It must also not fire on round 1, which researches even when
214
+ // the budget is 1. The increment stays — the forced synthesis below is told
215
+ // which round it is synthesizing at.
216
+ if (this.currentRound > 1 && this.currentRound >= maxRounds) {
217
+ logger.log(`[DeepOrchestrator] Round cap (${maxRounds}) reached — skipping the evaluator and going straight to final synthesis ${this.elapsed()}`);
218
+ break;
219
+ }
220
+
193
221
  const roundLabel = this.currentRound > baseMaxRounds
194
222
  ? `Round ${this.currentRound}/${maxRounds} (extra, steering-driven, base=${baseMaxRounds})`
195
223
  : `Round ${this.currentRound}/${maxRounds}`;
196
224
  logger.log(`[DeepOrchestrator] ${roundLabel} ${this.elapsed()}`);
197
- observer?.onRoundStart?.(this.currentRound);
225
+ // Guarded, because a 'wait' decision decrements the round and re-enters the loop
226
+ // to run the SAME round again. Announcing it each time emitted `round_start`
227
+ // 1, 2, 2, 3 — a duplicate rather than the gap the post-loop guard was added for,
228
+ // but the same broken contract for a consumer counting rounds.
229
+ if (this.currentRound > lastAnnouncedRound) {
230
+ observer?.onRoundStart?.(this.currentRound);
231
+ lastAnnouncedRound = this.currentRound;
232
+ }
198
233
 
199
234
  // Check infrastructure health (advisory: logs status, never aborts the run).
200
235
  await orchestrationService.checkHealth(this.currentRound, researchId, ctx);
@@ -217,17 +252,6 @@ export class DeepResearchOrchestrator {
217
252
  excludeTools: this.options.excludeTools,
218
253
  steeringMessages: steeringTexts,
219
254
  });
220
- } else if (this.currentRound >= maxRounds) {
221
- // Last iteration: the `plan.action === 'synthesize' || currentRound >=
222
- // maxRounds` break below fires no matter what the evaluator decides, and
223
- // the forced-synthesis path after the loop runs updatePlanForRound again
224
- // with mustSynthesize. So on every capped run whose evaluator would have
225
- // said `delegate` — the common case, since it is being cut off mid-plan —
226
- // this call is a second full-context coordinator request whose result is
227
- // discarded. Break straight to the forced synthesis: exactly one
228
- // coordinator call either way, and the same outcome.
229
- logger.log(`[DeepOrchestrator] Round cap (${maxRounds}) reached — skipping the evaluator and going straight to final synthesis ${this.elapsed()}`);
230
- break;
231
255
  } else {
232
256
  const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
233
257
  observer?.onEvaluationStart?.(this.currentRound);
@@ -241,7 +265,12 @@ export class DeepResearchOrchestrator {
241
265
  modelRegistry: ctx.modelRegistry,
242
266
  cwd: ctx.cwd,
243
267
  config: this.config,
268
+ // Both are used by routing, for different rounds: the bodies of the reports
269
+ // that arrived THIS round are sent in full, and every earlier researcher is
270
+ // represented by its digest alone. Passing reports also lets a caller-side
271
+ // digest miss fall back to deriving one from the body.
244
272
  reports: synthesisService.getAllReports(researchId),
273
+ digests: synthesisService.getAllDigests(researchId),
245
274
  previousPlan: planningService.getCurrentPlan(researchId),
246
275
  totalResearchersPlanned: planningService.getTotalResearchersPlanned(researchId),
247
276
  // Live, steering-extended budget — see maxRounds comment above. Without
@@ -271,7 +300,12 @@ export class DeepResearchOrchestrator {
271
300
  if (plan.action === 'synthesize' || this.currentRound >= maxRounds) {
272
301
  logger.log(`[DeepOrchestrator] Synthesis phase reached at Round ${this.currentRound} ${this.elapsed()}`);
273
302
  if (plan.action === 'synthesize') {
274
- loopSynthesisPlan = plan;
303
+ // Only the ROUTER (round 2+) can reach this: generatePlan pins round 1 to
304
+ // 'delegate' so a coordinator can never end the run before any source is
305
+ // read. The router's decision ends the loop, but it has never held the
306
+ // whole corpus — only this round's reports and earlier digests — so the
307
+ // report itself is written by the terminal synthesis below.
308
+ routerChoseSynthesis = true;
275
309
  observer?.onEvaluationDecision?.('synthesize', plan, this.currentRound);
276
310
  }
277
311
  break;
@@ -426,22 +460,71 @@ export class DeepResearchOrchestrator {
426
460
 
427
461
  // 5. Evaluation Phase
428
462
  observer?.onEvaluationProgress?.('evaluating');
463
+
464
+ // Counted at the END of a round that actually researched, which is what the
465
+ // run summary means by "rounds". The counter it used to read
466
+ // (`evaluator_runs_total`) lost its emit site in c90d7f37 and nothing noticed,
467
+ // because the only consumer gates on `> 1` — so a value permanently stuck at 0
468
+ // is indistinguishable from a single-round run, and the "N rounds" line has
469
+ // never rendered since.
470
+ metrics.increment('research_rounds_completed_total', 1, { mode: 'deep', complexity: String(complexity) });
471
+ }
472
+
473
+ // Final Synthesis — always its own call. Routing and synthesis are separate jobs:
474
+ // no router call ever holds the whole corpus (it reads each report on the single
475
+ // round it arrives, and digests after that), so none of them can produce the report
476
+ // and there is nothing from the loop to reuse. This costs one extra call on runs the
477
+ // router ends early, and in exchange removes a full re-send of the corpus from every
478
+ // round that preceded it.
479
+ //
480
+ // ZERO reports collected AND researchers actually failed — the run attempted
481
+ // research and produced nothing groundable. Checked BEFORE the synthesis call
482
+ // rather than after it: with every researcher failed (exactly the insufficient-
483
+ // credit class the researcher classifier exists for) the corpus is empty, so the
484
+ // call is billed over nothing and its result is then discarded by this very gate.
485
+ // The verdict does not depend on the synthesis text — it depends on whether there
486
+ // was any source material — so nothing is lost by asking first.
487
+ //
488
+ // Gated on failures, not merely on the absence of reports: a run cancelled before
489
+ // any researcher finished has no reports and no failures, and must not be reported
490
+ // as having produced nothing groundable.
491
+ const synthesisCheckService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
492
+ const hasReports = synthesisCheckService.hasReports(researchId);
493
+ const failedIds = getFailedResearchers(this.options.sessionId, researchId);
494
+ if (!hasReports && failedIds.length > 0) {
495
+ const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
496
+ const causes = failedIds
497
+ .map((id) => `researcher ${id}: ${reasons[id] ?? 'no usable report'}`)
498
+ .join('; ');
499
+ throw new Error(
500
+ `Research produced no report — no source material was collected. Causes: ${causes}`
501
+ );
429
502
  }
430
503
 
431
- // Final Synthesis — reuse the plan from the loop if the evaluator already
432
- // decided to synthesize (avoids a second full LLM call). Only call the
433
- // evaluator again when the loop ended because maxRounds was hit without
434
- // the evaluator explicitly choosing to synthesize.
435
504
  logger.log(`[DeepOrchestrator] Final synthesis ${this.elapsed()}`);
436
- // Only spin up the evaluation-phase UI when we still have to CALL the evaluator
437
- // (loop ended on maxRounds). If the evaluator already chose to synthesize inside
438
- // the loop, its eval slice was completed at that point (onEvaluationDecision) and
439
- // its decision is final — re-adding a fresh 'eval' box here would leave a
440
- // perpetually-"evaluating" slice that nothing ever completes, and re-emit
441
- // start/progress after the decision. Symmetric with the decision guard below.
442
- if (loopSynthesisPlan === null) {
443
- observer?.onRoundStart?.(maxRounds + 1); // Progress indicator for synthesis
444
- observer?.onEvaluationStart?.(maxRounds);
505
+ // Only spin up the evaluation-phase UI when the loop ended on maxRounds. If the
506
+ // router already chose to finish inside the loop, its eval slice was completed at
507
+ // that point (onEvaluationDecision) and its decision is final — re-adding a fresh
508
+ // 'eval' box here would leave a perpetually-"evaluating" slice that nothing ever
509
+ // completes, and re-emit start/progress after the decision. Symmetric with the
510
+ // decision guard below.
511
+ if (!routerChoseSynthesis) {
512
+ // The synthesis pass announces the round it is synthesizing AT, which is the
513
+ // one the loop stopped on — not `maxRounds + 1`, a number outside the budget
514
+ // every other event is expressed in. That was harmless while the loop
515
+ // announced the phantom final round (1,2,3 then a 4 nobody minded); with the
516
+ // phantom gone it left a hole: a 3-round run emitted round_start 1, 2, 4.
517
+ // Guarded so a one-round budget cannot announce the same round twice.
518
+ if (this.currentRound > lastAnnouncedRound) {
519
+ observer?.onRoundStart?.(this.currentRound);
520
+ lastAnnouncedRound = this.currentRound;
521
+ }
522
+ // The round this synthesis actually happens at, not the budget. They coincide on
523
+ // the round-cap exit but not on the wait-exhaustion one, which breaks out early —
524
+ // that path announced `evaluation_start` for a round no `round_start` had ever
525
+ // named, and the round it really stopped on was left with an evaluation slice
526
+ // that never received a decision.
527
+ observer?.onEvaluationStart?.(this.currentRound);
445
528
  observer?.onEvaluationProgress?.('evaluating');
446
529
  }
447
530
 
@@ -451,81 +534,52 @@ export class DeepResearchOrchestrator {
451
534
  // toast and then be destroyed at teardown. With the flag off first (this and
452
535
  // the consume run synchronously — no interleaving window), any later steer
453
536
  // takes the input handler's fall-through path (forwarded to pi, accurate
454
- // toast). On the evaluator-chose-synthesize path this is idempotent: the
537
+ // toast). On the router-chose-synthesis path this is idempotent: the
455
538
  // observer already flipped the flag in onEvaluationDecision('synthesize').
456
539
  // NOTE: this must also come after the onEvaluationStart above, which re-sets
457
540
  // the flag to true (correct for in-loop evaluations, wrong for this final one).
458
541
  observer?.onSynthesisStart?.();
459
542
 
460
543
  // One final check for new steering messages before synthesis. Done for BOTH
461
- // exit paths: a message arriving after the evaluator chose 'synthesize' (the
462
- // loopSynthesisPlan path) was previously never consumed nor appended — fully
463
- // dropped. Consuming here marks it active so appendSteeringGuidance includes it.
544
+ // exit paths: a message arriving after the router chose to finish was previously
545
+ // never consumed nor appended — fully dropped. Consuming here marks it active so
546
+ // appendSteeringGuidance includes it.
464
547
  consumeQueuedMessages(this.options.sessionId);
465
548
  const finalSteeringTexts = getSteeringMessages(this.options.sessionId).map(m => m.text);
466
549
 
467
- let finalReport: ResearchPlan;
468
- if (loopSynthesisPlan !== null) {
469
- finalReport = loopSynthesisPlan;
470
- } else {
471
- const synthesisServiceFinal = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
472
- finalReport = await planningService.updatePlanForRound({
473
- sessionId: researchId,
474
- query: query,
475
- complexity,
476
- round: maxRounds,
477
- model: coordinatorModel,
478
- modelRegistry: ctx.modelRegistry,
479
- cwd: ctx.cwd,
480
- config: this.config,
481
- reports: synthesisServiceFinal.getAllReports(researchId),
482
- previousPlan: planningService.getCurrentPlan(researchId),
483
- totalResearchersPlanned: planningService.getTotalResearchersPlanned(researchId),
484
- mustSynthesize: true,
485
- // Live, steering-extended budget — see the in-loop call above.
486
- maxRounds,
487
- signal,
488
- observer,
489
- excludeTools: this.options.excludeTools,
490
- steeringMessages: finalSteeringTexts,
491
- });
492
- }
550
+ const synthesisServiceFinal = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
551
+ const finalReport: ResearchPlan = await planningService.updatePlanForRound({
552
+ sessionId: researchId,
553
+ query: query,
554
+ complexity,
555
+ round: this.currentRound,
556
+ model: coordinatorModel,
557
+ modelRegistry: ctx.modelRegistry,
558
+ cwd: ctx.cwd,
559
+ config: this.config,
560
+ reports: synthesisServiceFinal.getAllReports(researchId),
561
+ previousPlan: planningService.getCurrentPlan(researchId),
562
+ totalResearchersPlanned: planningService.getTotalResearchersPlanned(researchId),
563
+ mustSynthesize: true,
564
+ // Live, steering-extended budget — see the in-loop call above.
565
+ maxRounds,
566
+ signal,
567
+ observer,
568
+ excludeTools: this.options.excludeTools,
569
+ steeringMessages: finalSteeringTexts,
570
+ });
493
571
 
494
572
  // Only emit the synthesis decision here for the FORCED-synthesis path. When the
495
- // evaluator itself chose to synthesize inside the loop (loopSynthesisPlan set),
496
- // onEvaluationDecision('synthesize', ...) was already fired at that point; emitting
497
- // again would double-count synthesis for observers (e.g. the TUI progress bar).
498
- if (loopSynthesisPlan === null) {
499
- observer?.onEvaluationDecision?.('synthesize', finalReport, maxRounds);
573
+ // router chose to finish inside the loop, onEvaluationDecision('synthesize', ...)
574
+ // was already fired at that point; emitting again would double-count synthesis for
575
+ // observers (e.g. the TUI progress bar).
576
+ if (!routerChoseSynthesis) {
577
+ // Same round the matching onEvaluationStart above used — see there.
578
+ observer?.onEvaluationDecision?.('synthesize', finalReport, this.currentRound);
500
579
  }
501
580
 
502
581
  let result = finalReport.content || '';
503
582
 
504
- // ZERO reports collected AND researchers actually failed — the run attempted
505
- // research and produced nothing groundable. This gate is deliberately
506
- // independent of the synthesis text: with the default single-researcher plan
507
- // the fast-stop threshold (MAX_FAILED_RESEARCHERS = 2) never trips, so a lone
508
- // researcher that exhausts its retries (e.g. a provider rate limit) reaches
509
- // synthesis with no source material. Whether the LLM then returns an empty
510
- // string OR chatty refusal prose ("no information was found"), that is a
511
- // FAILED run: throw, and surface the recorded root causes so the user sees
512
- // the 429/model error, not a shrug (and never a source-free report shipped
513
- // as success). The failed-researcher condition keeps the designed
514
- // immediate-synthesis path intact: a coordinator that chose to answer
515
- // directly (no delegation, no failures) is not a failed run.
516
- const synthesisCheckService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
517
- const hasReports = synthesisCheckService.hasReports(researchId);
518
- const failedIds = getFailedResearchers(this.options.sessionId, researchId);
519
- if (!hasReports && failedIds.length > 0) {
520
- const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
521
- const causes = failedIds
522
- .map((id) => `researcher ${id}: ${reasons[id] ?? 'no usable report'}`)
523
- .join('; ');
524
- throw new Error(
525
- `Research produced no report — no source material was collected. Causes: ${causes}`
526
- );
527
- }
528
-
529
583
  if (!result.trim()) {
530
584
  if (hasReports) {
531
585
  // Content is empty but reports exist — build a fallback synthesis.
@@ -169,7 +169,10 @@ export class QuickResearchOrchestrator {
169
169
  .replace('{{store_section}}', () => storeSection)
170
170
  .replace('{{evidence_section}}', () => evidenceLines.join('\n\n'))
171
171
  .replace('{{coordination_section}}', '')
172
- .replace('{{extra_tool_guidelines}}', '- `search`: Perform broad web searches (Round 1 only).');
172
+ .replace('{{extra_tool_guidelines}}', '- `search`: Perform broad web searches (Round 1 only).')
173
+ // Quick research has no research lead and no next round — see
174
+ // RESEARCHER_DIGEST_SECTION. Its report IS the deliverable.
175
+ .replace('{{digest_section}}', '');
173
176
 
174
177
  logger.debug(`[QuickOrchestrator] System Prompt:\n${prompt}`);
175
178
 
@@ -325,7 +328,13 @@ export class QuickResearchOrchestrator {
325
328
  // Store report in synthesis service so citations can be verified/processed
326
329
  const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
327
330
  synthesisService.storeReport(this.options.researchId, 'quick', result);
328
-
331
+ // Continue from the STORED body. Quick research uses the same researcher prompt as
332
+ // a deep run, so its report opens with a COVERAGE DIGEST block — routing metadata
333
+ // for a lead that does not exist in this mode. Unlike a deep run, this text IS the
334
+ // deliverable and goes straight to the user, so reading it back is what keeps the
335
+ // digest out of the delivered document.
336
+ result = synthesisService.getReport(this.options.researchId, 'quick') ?? result;
337
+
329
338
  // Ensure CITED LINKS section is accurate and consistent
330
339
  result = synthesisService.ensureCitedLinks(this.options.researchId, result);
331
340
 
@@ -14,7 +14,7 @@ import type { Model } from '@earendil-works/pi-ai';
14
14
  import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
15
15
  import type { QueryResultWithError } from '../web-research/types.ts';
16
16
  import type { RunResearchersOptions } from './orchestration-types.ts';
17
- import { RESEARCHER_LAUNCH_DELAY_MS, resolveExcludedTools } from '../constants.ts';
17
+ import { RESEARCHER_LAUNCH_DELAY_MS, RESEARCH_TOOL_NAMES, resolveExcludedTools } from '../constants.ts';
18
18
  import { search } from '../web-research/search.ts';
19
19
  import { parseCitations } from '../utils/text-utils.ts';
20
20
  import { logger, resetLogger } from '../logger.ts';
@@ -98,6 +98,27 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
98
98
  const configDisabledTools = researchConfig.DISABLED_TOOLS
99
99
  ? researchConfig.DISABLED_TOOLS.split(',').map((s) => s.trim()).filter(Boolean)
100
100
  : [];
101
+ // Unknown names are a SILENT no-op downstream (the merge is a blind Set union),
102
+ // so a typo leaves the tool enabled for a whole billed run while the user
103
+ // believes it is off. `--exclude-tools` fails fast on this in the CLI parser;
104
+ // its two siblings — the SDK's `excludeTools` option and
105
+ // PI_RESEARCH_DISABLED_TOOLS — reach the same union with no check at all, and
106
+ // RESEARCH_TOOL_NAMES already documents itself as the vocabulary all THREE
107
+ // accept. Warn rather than throw here: this chokepoint is mid-run and serves a
108
+ // library API and an ambient env var, where aborting a legitimate request over
109
+ // one unrecognized name is the wrong trade — the CLI keeps its fail-fast.
110
+ for (const [surface, names] of [
111
+ ['excludeTools', excludeTools ?? []],
112
+ ['PI_RESEARCH_DISABLED_TOOLS', configDisabledTools],
113
+ ] as const) {
114
+ const unknown = names.filter((t) => !RESEARCH_TOOL_NAMES.includes(t));
115
+ if (unknown.length > 0) {
116
+ logger.warn(
117
+ `[ResearchOrchestrationService] ${surface}: ignoring unknown tool name${unknown.length === 1 ? '' : 's'} ` +
118
+ `${unknown.map((t) => `"${t}"`).join(', ')} — these exclude nothing. Valid names: ${RESEARCH_TOOL_NAMES.join(', ')}.`
119
+ );
120
+ }
121
+ }
101
122
  const mergedExcludeTools = resolveExcludedTools(excludeTools, configDisabledTools);
102
123
 
103
124
  // Resolve model using centralized priority logic
@@ -643,6 +664,14 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
643
664
  * @param ctx - Optional extension context for container isolation
644
665
  */
645
666
  async checkHealth(round: number, _researchId?: string, ctx?: any): Promise<void> {
667
+ // Round 1 is skipped because there is nothing to diagnose yet — the pool was
668
+ // built moments earlier and no work has run through it. A consequence worth
669
+ // stating: a complexity-1 run has exactly ONE research round, so it now never
670
+ // runs this at all. That is correct rather than a gap. It used to run at the
671
+ // phantom final round, which dispatched no researchers and on one measured run
672
+ // spent 105 seconds probing infrastructure that the synthesis pass ahead of it
673
+ // does not use. This check is advisory logging; a quick run loses nothing but
674
+ // that cost.
646
675
  if (round <= 1) return;
647
676
 
648
677
  try {
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { parseCitations } from '../utils/text-utils.ts';
13
13
  import { normalizeCitations, formatCitedLinks, rewriteCitationMarkers, type GlobalCitation } from '../utils/citation-utils.ts';
14
+ import { splitCoverageDigest, deriveCoverageDigest } from '../utils/coverage-digest.ts';
14
15
  import { lastCitedLinksHeaderIndex, lastCitedLinksHeader } from '../utils/text-utils.ts';
15
16
  import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
16
17
  import { stripTrailingLlmPunctuation } from '../utils/url-utils.ts';
@@ -77,6 +78,12 @@ export class ResearchSynthesisService implements IService {
77
78
 
78
79
  // Map of sessionId -> Map<reportId, reportContent>
79
80
  private sessions = new Map<string, Map<string, string>>();
81
+ // Map of sessionId -> Map<reportId, coverageDigest>, split off the report at store time.
82
+ // Kept parallel to `sessions` rather than folded into it so every existing consumer of a
83
+ // report keeps receiving a plain string body. Lifecycle is mirrored everywhere `sessions`
84
+ // is mutated (store, LRU evict, clear, dispose) — a digest map that outlives its reports
85
+ // would hand the router coverage for findings the synthesizer no longer has.
86
+ private digests = new Map<string, Map<string, string>>();
80
87
  // Cap to prevent unbounded growth from orphaned sessions during long-lived Pi
81
88
  // sessions. Generous because each session map is small and the normal path clears
82
89
  // reports via cleanupResearchServices on run completion — this is only a backstop
@@ -103,6 +110,7 @@ export class ResearchSynthesisService implements IService {
103
110
  if (oldestKey !== undefined) {
104
111
  logger.warn(`[ResearchSynthesisService] Session limit (${ResearchSynthesisService.MAX_SESSIONS}) reached, evicting least-recently-used: ${oldestKey}`);
105
112
  this.sessions.delete(oldestKey);
113
+ this.digests.delete(oldestKey);
106
114
  }
107
115
  }
108
116
  reports = new Map<string, string>();
@@ -136,7 +144,47 @@ export class ResearchSynthesisService implements IService {
136
144
  * @param report - The researcher's report content
137
145
  */
138
146
  storeReport(sessionId: string, id: string, report: string): void {
139
- this.getSessionReports(sessionId).set(id, report);
147
+ // Split the coverage digest off the head of the report here, at the single point every
148
+ // report enters the system. Downstream consumers — citation normalization, the
149
+ // synthesis corpus, the fallback synthesis, the knowledge store — then see a body with
150
+ // no routing metadata in it, unchanged from before this protocol existed.
151
+ const { digest, body } = splitCoverageDigest(report);
152
+ this.getSessionReports(sessionId).set(id, body);
153
+
154
+ // Storing the same id twice REPLACES the report, so it must replace the digest too.
155
+ // Setting only when the new report carries one would leave the previous report's
156
+ // coverage claims attached to a body that no longer supports them — the router would
157
+ // then route on findings that were superseded. Delete on absence so the derived digest
158
+ // (which reads the current body) takes over instead.
159
+ let sessionDigests = this.digests.get(sessionId);
160
+ if (digest) {
161
+ if (!sessionDigests) {
162
+ sessionDigests = new Map<string, string>();
163
+ this.digests.set(sessionId, sessionDigests);
164
+ }
165
+ sessionDigests.set(id, digest);
166
+ } else if (sessionDigests) {
167
+ sessionDigests.delete(id);
168
+ if (sessionDigests.size === 0) this.digests.delete(sessionId);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Coverage digests for every stored report, in report order.
174
+ *
175
+ * A report whose researcher emitted no digest gets one derived from its body, so the
176
+ * router sees an entry for every researcher that ran. The alternative — omitting it —
177
+ * reads to the router as "that researcher produced nothing", which is both wrong and
178
+ * the more dangerous error: it argues for re-delegating work already done.
179
+ */
180
+ getAllDigests(sessionId: string): Map<string, string> {
181
+ const reports = this.peekSessionReports(sessionId);
182
+ const stored = this.digests.get(sessionId);
183
+ const out = new Map<string, string>();
184
+ for (const [id, body] of reports.entries()) {
185
+ out.set(id, stored?.get(id) ?? deriveCoverageDigest(body));
186
+ }
187
+ return out;
140
188
  }
141
189
 
142
190
  /**
@@ -187,8 +235,10 @@ export class ResearchSynthesisService implements IService {
187
235
  clearReports(sessionId?: string): void {
188
236
  if (sessionId) {
189
237
  this.sessions.delete(sessionId);
238
+ this.digests.delete(sessionId);
190
239
  } else {
191
240
  this.sessions.clear();
241
+ this.digests.clear();
192
242
  }
193
243
  }
194
244
 
@@ -442,6 +492,7 @@ export class ResearchSynthesisService implements IService {
442
492
  */
443
493
  reset(): void {
444
494
  this.sessions.clear();
495
+ this.digests.clear();
445
496
  }
446
497
 
447
498
  async initialize(): Promise<void> {
@@ -461,6 +512,7 @@ export class ResearchSynthesisService implements IService {
461
512
  this.lifecycle = ServiceLifecycle.DISPOSING;
462
513
  logger.debug('[ResearchSynthesisService] Disposing...');
463
514
  this.sessions.clear();
515
+ this.digests.clear();
464
516
  this.lifecycle = ServiceLifecycle.DISPOSED;
465
517
  logger.debug('[ResearchSynthesisService] Disposed');
466
518
  }
@@ -23,6 +23,42 @@ import { recordResearcherFailure, getSteeringMessages, shouldStopResearch } from
23
23
  import { isAbortSentinel, boundSessionAbort } from './abort-utils.ts';
24
24
  import type { RunResearcherOptions } from './orchestration-types.ts';
25
25
  import { search } from '../web-research/search.ts';
26
+ import { RESEARCHER_DIGEST_SECTION } from '../utils/coverage-digest.ts';
27
+
28
+ /**
29
+ * Failures a retry cannot fix, because the next attempt sends the same request to
30
+ * the same account and gets the same answer.
31
+ *
32
+ * Deliberately narrow. The retry loop is otherwise unclassified and that is mostly
33
+ * FINE — the log history says so. Across 53 days the loop retried 126 attempts:
34
+ * "produced no text output" recovered 26/26; a provider rejecting
35
+ * `reasoning_effort: 'none'` recovered ~18/19, because OpenRouter routes the same
36
+ * model to different upstream providers per request and only some reject it;
37
+ * token-limit truncation recovered ~16/17. Classifying any of those as permanent
38
+ * would break runs that currently succeed.
39
+ *
40
+ * Insufficient credits is the one class with no recoveries at all: 30 retried
41
+ * attempts, every one belonging to an episode that went on to exhaust, 0 of 10
42
+ * recovered. Those ten cost five runs their entire output — searches already paid
43
+ * for, 70 results collected and discarded, and a synthesis call billed over an
44
+ * empty corpus before the run threw. The account balance does not change in the
45
+ * two seconds between attempts.
46
+ *
47
+ * Note what this does NOT cover: a researcher timeout retries and recovers ~88% of
48
+ * the time, so it stays retriable even though three attempts at a 15-minute budget
49
+ * cost 45 minutes of wall clock. That cost is real and worth addressing, but by
50
+ * bounding total retry time rather than by refusing the retry that usually works.
51
+ */
52
+ export function isUnretriableResearcherError(error: unknown): boolean {
53
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
54
+ // Matched on the provider's wording rather than a bare "402": a status code can
55
+ // appear inside an unrelated payload (a token count, a URL, a quoted response
56
+ // body), and this gate ends a researcher's run — it must not fire on a
57
+ // coincidence. Same reasoning as isTransientSynthesisError's boundary anchoring.
58
+ return message.includes('requires more credits')
59
+ || message.includes('insufficient credits')
60
+ || message.includes('insufficient_quota');
61
+ }
26
62
 
27
63
  /**
28
64
  * Run a single researcher with retries
@@ -205,6 +241,7 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
205
241
  .replace('{{evidence_section}}', () => evidenceSection)
206
242
  .replace('{{coordination_section}}', () => previousQueriesSection)
207
243
  .replace('{{extra_tool_guidelines}}', '')
244
+ .replace('{{digest_section}}', () => RESEARCHER_DIGEST_SECTION)
208
245
  .trim() + steeringSection;
209
246
 
210
247
  logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:\n${prompt}`);
@@ -453,7 +490,11 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
453
490
  const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
454
491
  synthesisService.storeReport(researchId, `${round}.${id}`, responseText);
455
492
 
456
- observer?.onResearcherComplete?.(id, responseText);
493
+ // Emit what was STORED, not what the model returned: storeReport splits the coverage
494
+ // digest off the head of the report, and the SDK's `researcher_complete` event should
495
+ // carry the report a consumer would get from the run, not the routing metadata that
496
+ // was stripped out of it.
497
+ observer?.onResearcherComplete?.(id, synthesisService.getReport(researchId, `${round}.${id}`) ?? responseText);
457
498
  return;
458
499
  } catch (err) {
459
500
  lastError = err;
@@ -484,7 +525,8 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
484
525
  const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
485
526
  synthesisService.storeReport(researchId, `${round}.${id}`, partialResponse + '\n\n---\n*WARNING: This report was truncated due to a timeout/error. Content may be incomplete.*');
486
527
  logger.log(`[ResearcherExecutor] Researcher ${id} salvaged partial content (${partialResponse.length} chars) after error: ${errMsg}`);
487
- observer?.onResearcherComplete?.(id, partialResponse);
528
+ // As on the success path: emit the stored body, with the digest already split off.
529
+ observer?.onResearcherComplete?.(id, synthesisService.getReport(researchId, `${round}.${id}`) ?? partialResponse);
488
530
  return;
489
531
  }
490
532
  } catch (salvageErr) {
@@ -508,6 +550,15 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
508
550
  break; // Break out of the attempt loop
509
551
  }
510
552
 
553
+ // A failure the account cannot retry its way out of. Spending the remaining
554
+ // attempts changes nothing except how long the user waits for the same answer,
555
+ // and it buries the real cause under three duplicate warnings.
556
+ if (isUnretriableResearcherError(err)) {
557
+ logger.error(`[ResearcherExecutor] Researcher ${id} failed with an unretriable provider error, not retrying: ${errMsg}`);
558
+ metrics.increment('researcher_unretriable_total', 1, { mode: 'deep', complexity: String(complexity) });
559
+ break;
560
+ }
561
+
511
562
  if (attempt < maxAttempts) {
512
563
  logger.warn(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} failed: ${errMsg}; will retry`);
513
564
  } else {
@@ -109,10 +109,23 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
109
109
  if (usage && typeof usage.tokens === 'number') return usage.tokens;
110
110
  }
111
111
 
112
- // Fallback to cumulative session usage
112
+ // Fallback to cumulative session usage. Cache-read and cache-write tokens are
113
+ // part of the prompt and occupy the context window exactly like uncached input;
114
+ // the providers simply bill them differently. pi-ai reports them as SEPARATE
115
+ // fields with `input` already net of both (openai-completions computes
116
+ // `input = prompt_tokens - cacheRead - cacheWrite`), so summing input+output
117
+ // alone under-reports the real context by the entire cached prefix. On a
118
+ // provider where caching works that is most of the prompt, which would leave
119
+ // the tool layer's context gate (it divides this by contextWindowSize) reading
120
+ // near-empty on a session that is nearly full.
113
121
  if (typeof (sessionRef.session as any).getUsage === 'function') {
114
122
  const usage = (sessionRef.session as any).getUsage();
115
- return (usage.input || 0) + (usage.output || 0);
123
+ return (
124
+ (usage.input || 0) +
125
+ (usage.output || 0) +
126
+ (usage.cacheRead || 0) +
127
+ (usage.cacheWrite || 0)
128
+ );
116
129
  }
117
130
 
118
131
  return 0;
@@ -61,7 +61,7 @@ The `research` tool retrieves from the internet. Do NOT use it when the answer c
61
61
  - `depth: 3` — **ABSOLUTELY NEVER without explicit user request.** The user MUST use trigger words like "ultra", "exhaustive", "comprehensive deep-dive", or "maximum research". If the user does not explicitly request this level, use depth 1 or depth 2.
62
62
 
63
63
  **How depth works internally:**
64
- - `depth: 1-3` — AI-orchestrated: coordinator plans a team, researchers execute, evaluator decides whether to continue deeper. Team size and number of rounds scale with complexity. The coordinator and evaluator dynamically determine how many researchers are needed each round — it's not a fixed number.
64
+ - `depth: 1-3` — AI-orchestrated: coordinator plans a team, researchers execute, a research lead decides whether to continue deeper and writes the final report. Team size and number of rounds scale with complexity. The coordinator and the lead dynamically determine how many researchers are needed each round — it's not a fixed number.
65
65
 
66
66
  **Max siblings per round by depth:**
67
67
  - `depth: 1` — up to {{max_team_size_l1}} researchers per round
@@ -73,6 +73,7 @@ Write your report immediately after scraping is complete, or as soon as a tool r
73
73
 
74
74
  ## Report Format
75
75
 
76
+ {{digest_section}}
76
77
  Use [N] inline citations throughout. Write in plain prose — no markdown headings, no bullet points, no bold or italic text. Use clear paragraphs separated by blank lines. The full CITED LINKS list goes at the very end.
77
78
 
78
79
  [Topic Title]
@@ -36,7 +36,7 @@ Before splitting the query into researchers, pin down its scope — exactly what
36
36
 
37
37
  1. **Plan ONLY Round 1**: Split the query into distinct sub-topics, all inside the scope above, each assigned to a researcher. **Do NOT plan multiple rounds in advance** — additional rounds are only delegated reactively when Round 1 findings reveal gaps.
38
38
  2. **Assign Goals**: Each researcher gets a focused goal covering a specific angle or time period.
39
- 3. **Temporal Awareness**: Lead with the current month and year shown above. Generate time-sensitive queries using that exact month and year (e.g., append the current year and, where relevant, the current month; "latest", "current status"). Never anchor queries to an older year (e.g. 2024 or 2025) unless the user explicitly asks about the past. Research MUST be grounded in the present month and year.
39
+ 3. **Temporal Awareness**: Lead with the current month and year shown above. Generate time-sensitive queries using that exact month and year (e.g., append the current year and, where relevant, the current month; "latest", "current status"). Never anchor queries to an older year than the one shown above unless the user explicitly asks about the past. Research MUST be grounded in the present month and year.
40
40
  4. **Query Planning**: For EACH researcher, generate targeted, specific queries within the budget. Fill the budget when the topic is broad enough to warrant it, but don't pad queries for a narrow topic. Each round is planned independently — make Round 1 count.
41
41
  5. **YouTube Discovery**: For roughly **one in {{youtube_query_every_n}}** of each researcher's queries, append the word `youtube` to the query text (e.g. `"<topic> explained youtube"`). DuckDuckGo rarely surfaces YouTube on its own, and YouTube links let researchers read video transcripts. Skip this only when the topic is clearly unsuited to video (e.g. a single numeric lookup).
42
42
  6. **Seed Search Burst**: All queries across all researchers are combined into a single pre-search pass that seeds the global link pool.