@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
@@ -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.
@@ -0,0 +1,122 @@
1
+ # Research Lead — Routing
2
+
3
+ You decide whether this research run continues or ends.
4
+
5
+ You are given the message in two parts.
6
+
7
+ **NEW SINCE YOUR LAST DECISION** holds the **full reports** from the researchers that have just finished. This is the evidence your decision turns on — read it. Each report is preceded by the **coverage digest** its own researcher wrote for it: treat that as a *claim*, and check it against the report underneath. A digest saying `Gaps: none` above a thin or hedged report is the single most important thing you can catch, because nobody else will.
8
+
9
+ **REVIEWED IN EARLIER ROUNDS** holds only the coverage digests of reports you already read in full on a previous round and already judged. They are not repeated in full — you have seen them.
10
+
11
+ Everything specific to this run — the root query, the current round and round budget, the agenda so far, the queries already executed, any additional user guidance, and the phase guidance for this particular round — is supplied in a **RUN CONTEXT** block at the END of the message you are given, after the reports. Read that block before deciding; the instructions below tell you how to decide, the RUN CONTEXT tells you what you are deciding about.
12
+
13
+ You never write the report. When you decide the research is complete, a separate synthesis step reads every full report and writes it. Your output is a decision, not prose.
14
+
15
+ ## CRITICAL: Steering Compliance Checkpoint
16
+ - **STRICT STEERING**: If the RUN CONTEXT block carries an "ADDITIONAL USER GUIDANCE" section, you MUST treat every point in it as a **mandatory rule and instruction** for your decisions, next round goals, and queries. Do not ignore them. They are directional requirements.
17
+ - **STEERING SATISFACTION**: Before deciding to FINISH, you MUST explicitly verify that ALL user steering requirements are actually covered by the findings — not merely claimed by a digest. If ANY steering requirement is unaddressed, or only superficially covered, you MUST DELEGATE — do NOT finish. The user's guidance takes priority over round budget or feeling that research is "complete enough."
18
+
19
+ {{disabled_tools_section}}
20
+
21
+ ---
22
+
23
+ ## Complexity-Aware Decision Thresholds
24
+
25
+ **Complexity Level**: {{complexity_label}}
26
+
27
+ {{complexity_guidance}}
28
+
29
+ The phase guidance for the current round appears in the RUN CONTEXT block; it refines these thresholds for where this run currently sits in its round budget.
30
+
31
+ ---
32
+
33
+ ## Reading the evidence
34
+
35
+ For **earlier rounds** you have only the digest, and you already judged that work when it arrived. For **new reports** you have the prose; the digest beside it is a claim to verify.
36
+
37
+ Read a digest as follows:
38
+
39
+ - **`Covered`** tells you what is established. Treat a specific, concrete `Covered` line as reliable; treat a vague one ("general background on the topic") as weak coverage that may need a follow-up.
40
+ - **`Gaps`** is a researcher telling you directly what it could not reach. A non-"none" `Gaps` line on a topic the root query asks about is the strongest possible signal to delegate.
41
+ - **`Unsubstantiated`** flags claims that were looked for and not grounded. Delegating a targeted follow-up on those is usually higher value than opening a new angle.
42
+ - **`Sources: 0` or a very low count** means that researcher produced little usable material regardless of what its other lines claim. Re-cover that goal.
43
+ - **A digest that says coverage is unknown** was derived mechanically because that researcher emitted no digest of its own. For a new report that costs you nothing — read the report. For an earlier round it means coverage there is unverified, so weigh it accordingly.
44
+
45
+ **Verify the new reports rather than trusting their digests.** For each new report, read the prose and ask whether it actually establishes what its `Covered` line claims. Specific, sourced prose backs the claim up. Hedged prose, "not found in sources", a handful of thin paragraphs, or a report that never reaches its stated topic does not — in that case the goal is still open however confidently the digest is worded, and you should delegate it again.
46
+
47
+ Then compare everything covered — earlier digests plus what the new reports actually establish — against the root query and the initial agenda. What the root query asks for and nothing covers is a gap.
48
+
49
+ ---
50
+
51
+ ## Decision Framework
52
+
53
+ **FINISH if:** Coverage across the new reports and the earlier digests meets the complexity-specific synthesis criteria above. ("Finish" is the decision; the literal value you emit for it is `"action": "synthesize"`.)
54
+ **DELEGATE if:** Coverage meets the complexity-specific delegation criteria above.
55
+ **Note on Guidance**: If the user provides additional guidance mid-research (via Alt+Enter or direct follow-up), those messages will be provided to you. You MUST incorporate them into your next round's delegation goals and queries.
56
+
57
+ **STAY IN SCOPE (every delegation):**
58
+ - Going deeper on in-scope material is always welcome. Widening to new areas (breadth) is the drift to avoid — only do it when genuinely appropriate and helpful for the topic.
59
+ - A gap is missing depth on what was asked — not a broader topic next to it. Don't add anything the query didn't ask for, even if it came up in the findings.
60
+ - Match the query's scope-bounding constraints exactly — don't broaden them, don't narrow them.
61
+
62
+ **COMMON MISTAKES TO AVOID:**
63
+ - Do NOT finish early just because you've done "enough" rounds. Finish only when the evidence shows comprehensive coverage across all major topics the query asks about.
64
+ - Do NOT finish with gaps remaining, hoping they can be "filled in later". Complete the research first.
65
+ - Do NOT invent a gap outside the query's scope just to keep going or fill the budget. Finish what was asked; don't add what wasn't.
66
+ - Do NOT write findings, prose, or a report. You have read findings, but writing the report is a separate step's job. A `reason` is one sentence.
67
+
68
+ Use unique, targeted queries for any new researchers.
69
+
70
+ **Decision**: Return valid JSON in a code block:
71
+
72
+ **If the research is complete**:
73
+ ```json
74
+ {
75
+ "action": "synthesize",
76
+ "reason": "<one sentence naming the coverage that makes this complete>"
77
+ }
78
+ ```
79
+
80
+ **If delegating**:
81
+ ```json
82
+ {
83
+ "action": "delegate",
84
+ "researchers": [
85
+ {
86
+ "id": "<CURRENT_ROUND>.1",
87
+ "name": "<Researcher Specialty>",
88
+ "goal": "<Focused gap or new angle to investigate>",
89
+ "queries": ["<query_1>", "<query_2>", "<query_3>", "..."]
90
+ }
91
+ ],
92
+ "allQueries": ["<flat_list_of_all_queries_from_all_new_researchers>"]
93
+ }
94
+ ```
95
+
96
+ **DELEGATION REQUIREMENTS**:
97
+ - **CRITICAL — Queries are mandatory**: Every researcher MUST have at least one query. Never plan a researcher without queries. Researchers receive ONLY the search results you delegate to them.
98
+ - **Maximize queries**: For EACH researcher, generate the maximum number of targeted, specific queries within the budget. Do not plan fewer than needed — fill the budget. Queries should target primary sources and authoritative evidence.
99
+ - **YouTube discovery**: For roughly one in {{youtube_query_every_n}} queries, append the word `youtube` to the query text (e.g. `"<topic> analysis youtube"`). DuckDuckGo rarely surfaces YouTube on its own, and YouTube links let researchers read video transcripts. Skip only when the gap is clearly unsuited to video.
100
+ - **Temporal anchoring**: Anchor every new time-sensitive query to the current month and year shown at the top of this prompt. Use that exact year (and month where relevant); never default to an older year than the one shown there unless the user explicitly asks about the past.
101
+ - **Flexible coverage**: Use up to {{max_team_size}} researchers to cover distinct angles in parallel. Scale based on research gaps — a single well-targeted researcher is often sufficient for focused gaps.
102
+ - **Source diversity**: Encourage researchers to find multiple authoritative sources per topic area to enable comprehensive citations in the final synthesis.
103
+
104
+ ---
105
+
106
+ ## Quality Standards for Delegation
107
+
108
+ When delegating, ensure:
109
+ - **Query Specificity**: Each new query targets distinct, unexplored territory
110
+ - **No Redundancy**: Do not repeat queries from previous rounds
111
+ - **Specialized Focus**: Each new researcher has a clear, distinct angle
112
+ - **Gap-Driven**: Only delegate when the evidence shows a gap that existing coverage cannot resolve
113
+ - **Progressive Depth**: New queries go deeper, or to a new angle still inside the query's scope — never outside it, and never repeating surface-level coverage
114
+
115
+ ---
116
+
117
+ ## Output Requirements
118
+
119
+ - **Researcher IDs**: Use Round.Index format — the current round number from the RUN CONTEXT block, then the researcher index. In round 3 that is **3.1**, **3.2**, and so on.
120
+ - **Query Budget**: Use the complexity-specific budget ({{query_budget}} per researcher). Fill each researcher's query budget completely.
121
+ - **Team Size**: Scale researcher count to match the gaps. Use up to {{max_team_size}} researchers when delegating, but a single well-targeted researcher is often sufficient for focused gaps. Don't pad the team when fewer researchers will cover the remaining gaps efficiently.
122
+ - **Format**: ONLY return valid JSON in a code block. Never return report prose.
@@ -0,0 +1,46 @@
1
+ # Research Lead — Synthesis
2
+
3
+ You write the final report for this research run.
4
+
5
+ The research is finished. Every researcher report collected during the run is supplied to you in full, followed by a **Global Source List**, followed by a **RUN CONTEXT** block carrying the root query and any additional user guidance. Read the RUN CONTEXT block before writing: the instructions below tell you how to write, it tells you what the report is about.
6
+
7
+ You do not decide anything and you do not plan further research. Your only output is the report.
8
+
9
+ ## CRITICAL: Steering Compliance
10
+ If the RUN CONTEXT block carries an "ADDITIONAL USER GUIDANCE" section, treat every point in it as a **mandatory rule** governing the report's content, structure, and emphasis. The user's guidance overrides your own judgment about what to include or how to organize.
11
+
12
+ {{disabled_tools_section}}
13
+
14
+ ---
15
+
16
+ ## Synthesis Protocol
17
+
18
+ 1. **Organization**: Organize the report logically **BY TOPIC**. Do NOT structure it by researcher or round.
19
+ 2. **Anonymity**: Do NOT reference "researchers", "agents", "reports", or the research process. Present the findings as a direct, unified knowledge base.
20
+ 3. **Master Links List**: A **Global Source List** has been provided to you, after the findings. Use these sequential numbers [1], [2], [3], etc., for all inline citations. The researcher reports provided to you have already been normalized to these global numbers. This list includes all URLs found during live research and any relevant entries from the **Local and Global Knowledge Stores**.
21
+ 4. **Exhaustive Synthesis**: Use ALL findings from ALL reports. Include every fact, date, name, and statistic verbatim. Longer is better.
22
+ 5. **Strict Grounding**: Every sentence must come from a report. Use [N] inline citations. No prior knowledge.
23
+ 6. **CRITICAL — Links at Bottom Only**:
24
+ - Write all topic sections first with inline citations [1], [2], etc., using the numbers from the **Global Source List**.
25
+ - Place exactly ONE `CITED LINKS` section at the VERY END of the entire synthesis.
26
+ - This section must contain the complete master list of all unique URLs exactly as provided in the **Global Source List**.
27
+ - Do NOT include any links within topic sections or subsections.
28
+ - Format: `[1] https://url.com [Source: ...] — brief description` on each line.
29
+
30
+ > **MANDATORY TERMINATION RULE**: Your synthesis is not complete until it ends with `CITED LINKS`. Do NOT close the JSON object or stop generating until this section is written in full. A synthesis without `CITED LINKS` is a failed output.
31
+
32
+ ---
33
+
34
+ ## Output Requirements
35
+
36
+ - **Synthesis Quality**: Logical topic-based structure in plain prose, maximal detail, NO mention of researchers, NO markdown headings or bullets.
37
+ - **Format**: ONLY return valid JSON in a code block:
38
+
39
+ ```json
40
+ {
41
+ "action": "synthesize",
42
+ "content": "<Plain prose report with [N] citations, organized by topic. No markdown headings, bullets, or bold text. Followed by the mandatory CITED LINKS section.>"
43
+ }
44
+ ```
45
+
46
+ {{partial_synthesis_section}}
@@ -99,7 +99,10 @@ var init_log_utils = __esm({
99
99
  `${B_LEFT}(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})${B_RIGHT}`,
100
100
  "g"
101
101
  );
102
- JWT_PATTERN = new RegExp(`${B_LEFT}eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+`, "g");
102
+ JWT_PATTERN = new RegExp(
103
+ `${B_LEFT}eyJ[A-Za-z0-9_-]{1,1024}\\.[A-Za-z0-9_-]{1,8192}\\.[A-Za-z0-9_-]{1,4096}`,
104
+ "g"
105
+ );
103
106
  BASIC_AUTH_PATTERN = new RegExp(`${B_LEFT}Basic\\s+[A-Za-z0-9+/]{16,}={0,2}`, "gi");
104
107
  BEARER_AUTH_PATTERN = new RegExp(`${B_LEFT}Bearer\\s+[A-Za-z0-9._~+/-]+=*`, "gi");
105
108
  PROVIDER_TOKEN_PATTERN = new RegExp(
@@ -1057,6 +1060,17 @@ var init_logger = __esm({
1057
1060
  timestamp,
1058
1061
  level,
1059
1062
  ...getLogContext(),
1063
+ // Every process writes to the SAME log file — up to three concurrent research
1064
+ // runs plus the elected browser-pool leader — and until this field existed
1065
+ // there was nothing to tell their lines apart. Reconstructing a lock
1066
+ // contention from the log then meant inferring process boundaries from
1067
+ // timing, which is guesswork: two runs a second apart interleave into what
1068
+ // reads like one process contradicting itself. Twelve bytes a line buys
1069
+ // attributable forensics.
1070
+ //
1071
+ // After the context spread, not before: which process wrote a line is not
1072
+ // something a scoped context gets to claim otherwise.
1073
+ pid: process.pid,
1060
1074
  message,
1061
1075
  ...firstError ? { errorMessage: redactSecrets(firstError.message), errorStack: redactSecrets(firstError.stack ?? "") } : {}
1062
1076
  };
@@ -1163,7 +1177,7 @@ var init_metrics = __esm({
1163
1177
  increment(name, value = 1, labels) {
1164
1178
  const key = this.getKey(name, labels);
1165
1179
  this.counters.set(key, (this.counters.get(key) ?? 0) + value);
1166
- if (name === "llm_cost_total" || name === "llm_tokens_total") {
1180
+ if (name.startsWith("llm_")) {
1167
1181
  logger.debug(`[Metrics] Incremented ${name} by ${value}${labels ? ` (labels: ${JSON.stringify(labels)})` : ""}`);
1168
1182
  }
1169
1183
  }
@@ -1267,6 +1281,28 @@ var init_metrics = __esm({
1267
1281
  async measure(name, action, labels) {
1268
1282
  return this.getActive().measure(name, action, labels);
1269
1283
  }
1284
+ /**
1285
+ * Emit API that ALWAYS targets the session registry, ignoring any active run
1286
+ * context.
1287
+ *
1288
+ * For process-lifetime callbacks that happen to be *registered* during a run.
1289
+ * AsyncLocalStorage resolves at emit time from the async resource that fired,
1290
+ * and for a listener installed on a long-lived emitter that resource is the
1291
+ * emitter itself — created, for every lazily-initialized subsystem here, inside
1292
+ * whichever run first needed it. So a worker-pool error handler installed during
1293
+ * run 1 keeps writing into run 1's registry for the rest of the process: those
1294
+ * counts disappear from every later run's summary (the registry was discarded
1295
+ * when the run ended) and the dead registry stays reachable through the closure.
1296
+ *
1297
+ * Use this only where the emission is genuinely process-scoped. Anything that
1298
+ * describes work a run asked for belongs in the run registry, which is what the
1299
+ * plain emit methods above give you.
1300
+ */
1301
+ session = {
1302
+ increment: (name, value = 1, labels) => this._session.increment(name, value, labels),
1303
+ setGauge: (name, value, labels) => this._session.setGauge(name, value, labels),
1304
+ observe: (name, value, labels) => this._session.observe(name, value, labels)
1305
+ };
1270
1306
  // ── Session-level read API ─────────────────────────────────────────────
1271
1307
  /** Snapshot of the session registry (infrastructure / cross-run metrics). */
1272
1308
  getSessionSnapshot() {
@@ -1509,6 +1545,7 @@ var scraper_utils_exports = {};
1509
1545
  __export(scraper_utils_exports, {
1510
1546
  createJsMarkdownConverter: () => createJsMarkdownConverter,
1511
1547
  createNativeMarkdownConverter: () => createNativeMarkdownConverter,
1548
+ disposeSsrfSafeFetcher: () => disposeSsrfSafeFetcher,
1512
1549
  extractDomain: () => extractDomain,
1513
1550
  formatErrorWithCause: () => formatErrorWithCause,
1514
1551
  getRandomUserAgent: () => getRandomUserAgent,
@@ -1644,19 +1681,33 @@ function isSsrfBlockError(error) {
1644
1681
  }
1645
1682
  return false;
1646
1683
  }
1647
- async function getSsrfSafeFetcher() {
1648
- if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
1684
+ function getSsrfSafeFetcher() {
1685
+ cachedSsrfFetcher ??= (async () => {
1686
+ try {
1687
+ const { Agent, fetch: undiciFetch } = await import("undici");
1688
+ return {
1689
+ fetch: undiciFetch,
1690
+ dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
1691
+ };
1692
+ } catch (e) {
1693
+ logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
1694
+ return null;
1695
+ }
1696
+ })();
1697
+ return cachedSsrfFetcher;
1698
+ }
1699
+ async function disposeSsrfSafeFetcher() {
1700
+ const pending = cachedSsrfFetcher;
1701
+ cachedSsrfFetcher = void 0;
1702
+ if (pending === void 0) return;
1703
+ const cached = await pending.catch(() => null);
1704
+ const closable = cached?.dispatcher;
1705
+ if (!closable?.close) return;
1649
1706
  try {
1650
- const { Agent, fetch: undiciFetch } = await import("undici");
1651
- cachedSsrfFetcher = {
1652
- fetch: undiciFetch,
1653
- dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
1654
- };
1707
+ await closable.close();
1655
1708
  } catch (e) {
1656
- logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
1657
- cachedSsrfFetcher = null;
1709
+ logger.debug(`[Scrapers] Error closing SSRF dispatcher: ${e instanceof Error ? e.message : String(e)}`);
1658
1710
  }
1659
- return cachedSsrfFetcher;
1660
1711
  }
1661
1712
  function formatErrorWithCause(err) {
1662
1713
  try {
@@ -2152,11 +2203,21 @@ async function extractSearchResults(page) {
2152
2203
  return found;
2153
2204
  });
2154
2205
  }
2155
- async function executeSearchTask(_context, query) {
2206
+ function bindTaskAbortToPage(page, signal) {
2207
+ if (!signal) return;
2208
+ const onAbort = () => {
2209
+ page.close().catch(() => {
2210
+ });
2211
+ };
2212
+ if (signal.aborted) onAbort();
2213
+ else signal.addEventListener("abort", onAbort, { once: true });
2214
+ }
2215
+ async function executeSearchTask(_context, query, signal) {
2156
2216
  const page = await createPageSafe(_context);
2157
2217
  const SEARCH_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"], 45e3);
2158
2218
  page.setDefaultTimeout(SEARCH_TIMEOUT);
2159
2219
  page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
2220
+ bindTaskAbortToPage(page, signal);
2160
2221
  try {
2161
2222
  logToDebugFile2("DEBUG", `[Worker-${workerId2}] Starting search for: ${query}`);
2162
2223
  if (process.env["PI_RESEARCH_MOCK_SEARCH"] === "true") {
@@ -2369,10 +2430,11 @@ async function executeScrapeTask(_context, url, signal) {
2369
2430
  }
2370
2431
  var HEALTH_PRIMARY_URL = "https://lite.duckduckgo.com/lite/";
2371
2432
  var HEALTH_FALLBACK_URL = "https://example.com/";
2372
- async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PRIMARY_URL) {
2433
+ async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PRIMARY_URL, signal) {
2373
2434
  const page = await createPageSafe(_context);
2374
2435
  page.setDefaultTimeout(navTimeoutMs);
2375
2436
  page.setDefaultNavigationTimeout(navTimeoutMs);
2437
+ bindTaskAbortToPage(page, signal);
2376
2438
  try {
2377
2439
  const navStart = Date.now();
2378
2440
  await page.goto(url, { waitUntil: "domcontentloaded" });
@@ -2388,25 +2450,25 @@ async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PR
2388
2450
  throw error;
2389
2451
  }
2390
2452
  }
2391
- async function executeHealthCheck(_context, initMs) {
2453
+ async function executeHealthCheck(_context, initMs, signal) {
2392
2454
  const configuredMs = parseTimeoutMs(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"], 0);
2393
2455
  const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(1e4, configuredMs) : 1e4;
2394
2456
  if (initMs > 3e3) {
2395
2457
  logToDebugFile2("WARN", `[Worker-${workerId2}] Browser init was slow: ${initMs}ms \u2014 leaving less headroom for DDG navigation.`);
2396
2458
  }
2397
2459
  try {
2398
- return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
2460
+ return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
2399
2461
  } catch (firstError) {
2400
2462
  logToDebugFile2("WARN", `[Worker-${workerId2}] Health check attempt 1 failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Retrying once...`);
2401
2463
  try {
2402
- return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
2464
+ return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
2403
2465
  } catch (retryError) {
2404
2466
  const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
2405
2467
  const isTimeout = /tim(e|ed)\s*out|timeout/i.test(retryMsg);
2406
2468
  if (!isTimeout) {
2407
2469
  logToDebugFile2("WARN", `[Worker-${workerId2}] Search-provider health probe failed twice (${retryMsg}); verifying open-web reachability via fallback endpoint...`);
2408
2470
  try {
2409
- const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL);
2471
+ const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL, signal);
2410
2472
  logToDebugFile2("WARN", `[Worker-${workerId2}] Fallback endpoint reachable \u2014 open web is up, search provider is degraded. Proceeding with research.`);
2411
2473
  return fallback;
2412
2474
  } catch (fallbackError) {
@@ -2510,7 +2572,7 @@ var ConfigSchema = Type.Object({
2510
2572
  * never killed before it has used its full nav budget plus time waiting in the queue.
2511
2573
  * (default: 10000) */
2512
2574
  BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2e3, maximum: 12e4, default: 1e4 }),
2513
- /** Timeout for coordinator/evaluator/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
2575
+ /** Timeout for coordinator/research-lead/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
2514
2576
  * Not exposed in TUI — controlled via PI_RESEARCH_LLM_TIMEOUT_MS env var.
2515
2577
  * Ceiling raised to 30min (matching RESEARCHER_TIMEOUT_MS): a slow model on a
2516
2578
  * large-context synthesis/plan call can legitimately run >10min, and the old
@@ -2518,7 +2580,7 @@ var ConfigSchema = Type.Object({
2518
2580
  * (e.g. 900000), causing those calls to time out mid-generation. */
2519
2581
  LLM_TIMEOUT_MS: Type.Number({ minimum: 6e4, maximum: 18e5, default: 3e5 }),
2520
2582
  /** Chain-of-thought "thinking" level for the engine's own LLM calls (coordinator,
2521
- * evaluator, synthesis, JSON-repair, knowledge extraction) AND the researcher
2583
+ * router, synthesizer, JSON-repair, knowledge extraction) AND the researcher
2522
2584
  * sub-agents. Default 'off': these calls emit structured JSON / cited reports, not
2523
2585
  * open-ended reasoning, so thinking only burns the output-token budget (often
2524
2586
  * truncating before the JSON/report block) and adds latency for at most a marginal
@@ -2533,22 +2595,26 @@ var ConfigSchema = Type.Object({
2533
2595
  Type.Literal("medium"),
2534
2596
  Type.Literal("high")
2535
2597
  ], { default: "off" }),
2536
- /** Max output tokens for the coordinator plan + mid-round evaluator decision (and,
2537
- * when the evaluator synthesizes mid-round, the report). Default 16384. The blanket
2598
+ /** Max output tokens for the coordinator's round-1 plan. Default 16384. The routing
2599
+ * decision has its OWN, much smaller ceiling (it emits a decision, never a report,
2600
+ * and cannot synthesize mid-round under the split protocol); the final report uses
2601
+ * SYNTHESIS_MAX_TOKENS below. The blanket
2538
2602
  * 4096 cap previously throttled every call and could be exhausted by a thinking block
2539
2603
  * before any text was emitted. Capped at the model's real ceiling at call time.
2540
2604
  * Not exposed in TUI — controlled via PI_RESEARCH_PLANNING_MAX_TOKENS env var. */
2541
2605
  PLANNING_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
2542
- /** Max output tokens for the final synthesized research report (the forced-synthesis
2543
- * evaluator call). Default 32768 so a full cited report is never truncated. Capped at
2606
+ /** Max output tokens for the final synthesized research report (the synthesizer call,
2607
+ * and each pass of a reduce when the corpus does not fit in one). Default 32768 so a
2608
+ * full cited report is never truncated. It is also the reserve subtracted from the
2609
+ * model's context window when budgeting that corpus. Capped at
2544
2610
  * the model's real maxTokens at call time.
2545
2611
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
2546
2612
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
2547
2613
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
2548
2614
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
2549
2615
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
2550
- * and the knowledge synthesis background LLM. The coordinator and evaluator always
2551
- * use the caller's model (ctx.model).
2616
+ * and the knowledge synthesis background LLM. The coordinator and the research lead
2617
+ * always use the caller's model (ctx.model).
2552
2618
  */
2553
2619
  RESEARCH_MODEL: Type.Optional(Type.String()),
2554
2620
  /** Explicit directory for the knowledge store database (overrides default) */
@@ -2602,6 +2668,7 @@ function resolveHeadlessMode() {
2602
2668
  if (process.env["PI_RESEARCH_USE_XVFB"] === "true") return "virtual";
2603
2669
  return true;
2604
2670
  }
2671
+ var HEALTHCHECK_MIN_BUDGET_MS = 45e3 + 6e4;
2605
2672
 
2606
2673
  // src/infrastructure/browser/thread-worker-browser.ts
2607
2674
  var browser = null;
@@ -2716,11 +2783,24 @@ async function initBrowser() {
2716
2783
  });
2717
2784
  launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));
2718
2785
  let launchTimeoutId;
2786
+ let raceLost = false;
2787
+ launchPromise.then((b) => {
2788
+ if (!raceLost) return;
2789
+ logToDebugFile3("WARN", `[Worker-${workerId3}] Browser launch completed after its deadline; closing the orphaned instance.`);
2790
+ try {
2791
+ b?.close?.();
2792
+ } catch {
2793
+ }
2794
+ }).catch(() => {
2795
+ });
2719
2796
  try {
2720
2797
  return await Promise.race([
2721
2798
  launchPromise,
2722
2799
  new Promise((_, reject) => {
2723
- launchTimeoutId = setTimeout(() => reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`)), launchTimeoutMs);
2800
+ launchTimeoutId = setTimeout(() => {
2801
+ raceLost = true;
2802
+ reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`));
2803
+ }, launchTimeoutMs);
2724
2804
  })
2725
2805
  ]);
2726
2806
  } finally {
@@ -2745,10 +2825,23 @@ async function initBrowser() {
2745
2825
  const contextPromise = browser.newContext();
2746
2826
  contextPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser context rejection: ${err.message}`));
2747
2827
  let contextTimeoutId;
2828
+ let contextRaceLost = false;
2829
+ contextPromise.then((c) => {
2830
+ if (!contextRaceLost) return;
2831
+ logToDebugFile3("WARN", `[Worker-${workerId3}] Browser context creation completed after its deadline; closing the orphaned context.`);
2832
+ try {
2833
+ c?.close?.();
2834
+ } catch {
2835
+ }
2836
+ }).catch(() => {
2837
+ });
2748
2838
  context = await Promise.race([
2749
2839
  contextPromise,
2750
2840
  new Promise((_, reject) => {
2751
- contextTimeoutId = setTimeout(() => reject(new Error("Browser context creation timed out after 30000ms")), 3e4);
2841
+ contextTimeoutId = setTimeout(() => {
2842
+ contextRaceLost = true;
2843
+ reject(new Error("Browser context creation timed out after 30000ms"));
2844
+ }, 3e4);
2752
2845
  })
2753
2846
  ]);
2754
2847
  if (contextTimeoutId) clearTimeout(contextTimeoutId);
@@ -2864,14 +2957,14 @@ async function runTask(data) {
2864
2957
  let result;
2865
2958
  if (type === "search") {
2866
2959
  if (!query) throw new Error("Search task requires a query");
2867
- const searchResult = await executeSearchTask(getContext(), query);
2960
+ const searchResult = await executeSearchTask(getContext(), query, abortController.signal);
2868
2961
  result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
2869
2962
  } else if (type === "scrape") {
2870
2963
  if (!url) throw new Error("Scrape task requires a URL");
2871
2964
  const scrapeResult = await executeScrapeTask(getContext(), url, abortController.signal);
2872
2965
  result = { ...scrapeResult, duration: Date.now() - startTime };
2873
2966
  } else if (type === "healthcheck") {
2874
- const healthResult = await executeHealthCheck(getContext(), initMs);
2967
+ const healthResult = await executeHealthCheck(getContext(), initMs, abortController.signal);
2875
2968
  result = { ...healthResult, duration: Date.now() - startTime };
2876
2969
  } else {
2877
2970
  result = { error: "Unknown task type", duration: Date.now() - startTime };