@lincoln504/pi-research 1.3.12 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5670 -4705
  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 +169 -62
  8. package/docs/ARCHITECTURE.md +18 -10
  9. package/docs/CHANGELOG.md +225 -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 +22 -12
  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 +766 -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 +236 -43
  31. package/src/infrastructure/browser/config.ts +79 -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 +87 -36
  36. package/src/infrastructure/browser/thread-worker-messaging.ts +99 -10
  37. package/src/infrastructure/browser/thread-worker.mjs +169 -62
  38. package/src/infrastructure/browser/thread-worker.ts +13 -5
  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/embedding/embedding-server.ts +34 -0
  43. package/src/infrastructure/file-lock-service.ts +214 -19
  44. package/src/infrastructure/knowledge-store-service.ts +14 -0
  45. package/src/infrastructure/research-run-semaphore.ts +1 -1
  46. package/src/infrastructure/service-initialization.ts +7 -0
  47. package/src/knowledge/embedder-init.ts +46 -19
  48. package/src/knowledge/embedder.ts +11 -3
  49. package/src/knowledge/index.ts +36 -10
  50. package/src/knowledge/store.ts +29 -5
  51. package/src/logger.ts +11 -0
  52. package/src/orchestration/deep-research-orchestrator.ts +143 -87
  53. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  54. package/src/orchestration/research-orchestration-service.ts +8 -0
  55. package/src/orchestration/research-synthesis-service.ts +53 -1
  56. package/src/orchestration/researcher-executor.ts +53 -2
  57. package/src/orchestration/researcher.ts +15 -2
  58. package/src/orchestration/session-state.ts +16 -0
  59. package/src/prompts/research-tool-usage.md +1 -1
  60. package/src/prompts/researcher.md +1 -0
  61. package/src/prompts/system-lead-router.md +122 -0
  62. package/src/prompts/system-lead-synthesizer.md +46 -0
  63. package/src/sdk.ts +10 -0
  64. package/src/skill-install/skill-installer.ts +5 -1
  65. package/src/stackexchange/index.ts +3 -0
  66. package/src/stackexchange/rest-client.ts +7 -12
  67. package/src/tools/research-knowledge-search.ts +11 -3
  68. package/src/tools/research-tool-definition.ts +38 -7
  69. package/src/tools/scrape.ts +6 -0
  70. package/src/tools/search.ts +7 -0
  71. package/src/tools/security.ts +31 -10
  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/host-config.ts +12 -1
  77. package/src/utils/llm-usage.ts +33 -6
  78. package/src/utils/log-utils.ts +10 -0
  79. package/src/utils/metrics-summary.ts +20 -4
  80. package/src/utils/metrics.ts +27 -1
  81. package/src/web-research/browser-search.ts +102 -22
  82. package/src/web-research/web-scraper.ts +27 -16
  83. package/src/youtube/potoken.ts +8 -1
  84. package/src/youtube/transcript-client.ts +56 -5
  85. package/dist/prompts/system-lead-evaluator.md +0 -118
  86. package/src/prompts/system-lead-evaluator.md +0 -118
@@ -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}}
package/src/sdk.ts CHANGED
@@ -369,6 +369,16 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
369
369
  * Public initialization. Guarantees the SDK is ready for use.
370
370
  */
371
371
  export async function initResearchSDK(options: ResearchSDKOptions = {}): Promise<void> {
372
+ // A shutdown may be in flight: isInitialized only flips to false near the END
373
+ // of _doShutdown (after container disposal), so without this a caller racing a
374
+ // shutdown would hit the isInitialized branch below, log its "already
375
+ // initialized" warning, and return believing the SDK is ready — while the
376
+ // container it would use is mid-teardown or about to be nulled out from under
377
+ // it. Wait the shutdown out first so this call observes the real post-shutdown
378
+ // state, mirroring shutdownResearchSDK's symmetric wait on _initPromise.
379
+ if (_shutdownPromise) {
380
+ await _shutdownPromise.catch(() => { /* a failed shutdown must not block a fresh init */ });
381
+ }
372
382
  if (isInitialized) {
373
383
  logger.warn('[SDK] Research SDK already initialized. Call shutdownResearchSDK() first if you want to re-initialize with new options.');
374
384
  return;
@@ -480,7 +480,11 @@ export function uninstallSkill(toolIds?: string[], opts: InstallOptions = {}): U
480
480
  }
481
481
 
482
482
  for (const { tool, path: skillPath } of targets.values()) {
483
- if (toolIds && toolIds.length && !toolIds.includes(tool)) {
483
+ // undefined = every manifest entry; an explicit (including empty) list means
484
+ // exactly those tools. `toolIds.length` used to gate this, so `[]` was
485
+ // indistinguishable from `undefined` and silently uninstalled everything —
486
+ // inconsistent with this file's other list params, where empty means none.
487
+ if (toolIds !== undefined && !toolIds.includes(tool)) {
484
488
  const e = manifest.entries.find(x => x.path === skillPath);
485
489
  if (e) keep.push(e);
486
490
  continue;
@@ -27,6 +27,7 @@ import {
27
27
  } from './output/compact.ts';
28
28
  import type { ExtensionContext, AgentToolResult } from '@earendil-works/pi-coding-agent';
29
29
  import { logger } from '../logger.ts';
30
+ import { metrics } from '../utils/metrics.ts';
30
31
 
31
32
  function notify(
32
33
  ctx: ExtensionContext,
@@ -86,6 +87,7 @@ export async function stackexchangeCommand(options: {
86
87
  try {
87
88
  // Check quota before making requests
88
89
  if (client.isQuotaExhausted()) {
90
+ metrics.increment('stackexchange_quota_exhausted_total', 1);
89
91
  const quota = client.getQuotaInfo();
90
92
  notify(
91
93
  ctx,
@@ -103,6 +105,7 @@ export async function stackexchangeCommand(options: {
103
105
 
104
106
  // Warn if quota is low
105
107
  if (client.isQuotaLow()) {
108
+ metrics.increment('stackexchange_quota_low_total', 1);
106
109
  const quota = client.getQuotaInfo();
107
110
  notify(
108
111
  ctx,
@@ -193,24 +193,19 @@ export class StackExchangeClient {
193
193
  });
194
194
  }
195
195
 
196
+ // Pure accessor — no metrics side effects. `stackexchangeCommand()` calls this
197
+ // repeatedly per invocation (pre-request gate checks, success footer, error
198
+ // footer) purely to read current state for display; incrementing counters here
199
+ // used to record 2-3x the real number of quota-low/exhausted events per call.
200
+ // The counters are incremented once, at the single gate check that actually
201
+ // observes each event (see stackexchange/index.ts).
196
202
  getQuotaInfo(): { remaining: number; max: number; requestCount: number; lastBackoff: number | null } {
197
- const info = {
203
+ return {
198
204
  remaining: this.quotaRemaining,
199
205
  max: this.quotaMax,
200
206
  requestCount: this.requestCount,
201
207
  lastBackoff: this.lastBackoff,
202
208
  };
203
-
204
- // Track quota exhaustion events
205
- if (this.isQuotaExhausted()) {
206
- metrics.increment('stackexchange_quota_exhausted_total', 1);
207
- }
208
-
209
- if (this.isQuotaLow()) {
210
- metrics.increment('stackexchange_quota_low_total', 1);
211
- }
212
-
213
- return info;
214
209
  }
215
210
 
216
211
  isQuotaExhausted(): boolean {
@@ -54,6 +54,7 @@ import { loadPrompt } from '../core/llm/prompts.ts';
54
54
  import { formatParentContext } from '../orchestration/session-context.ts';
55
55
  import { getConfig, type ConfigInterface, type Config } from '../config.ts';
56
56
  import { createKnowledgeSearchPanel } from '../tui/knowledge-search-panel.ts';
57
+ import { isCancellation } from '../utils/cancellation.ts';
57
58
 
58
59
  // ---------------------------------------------------------------------------
59
60
  // Phase 1: Tool Parameters
@@ -439,6 +440,7 @@ export async function runBackgroundExtraction(
439
440
  maxTokens: number,
440
441
  thinkingLevel: Config['LLM_THINKING_LEVEL'],
441
442
  signal?: AbortSignal,
443
+ sessionId?: string,
442
444
  ): Promise<ResearchKnowledgeSynthesisResponse> {
443
445
  const promptTemplate = loadPrompt('research-knowledge-search-extractor');
444
446
  if (!promptTemplate) {
@@ -479,7 +481,8 @@ export async function runBackgroundExtraction(
479
481
  }, buildSafeOptions(model, {
480
482
  apiKey: auth.apiKey,
481
483
  headers: auth.headers,
482
- signal
484
+ signal,
485
+ sessionId,
483
486
  }, maxTokens, thinkingLevel)),
484
487
  llmTimeout,
485
488
  'knowledge-search-extraction',
@@ -537,6 +540,7 @@ export async function runBackgroundExtraction(
537
540
  context: 'Knowledge search extraction — synthesizing answer from reference documents',
538
541
  serviceName: 'ResearchKnowledgeSearch',
539
542
  signal,
543
+ sessionId,
540
544
  // Match the budget of the call being repaired so a large synthesis is not re-truncated
541
545
  // at repairJsonWithLlm's smaller default (16384). Mirrors the planning-service repairs.
542
546
  maxTokens,
@@ -972,6 +976,11 @@ export function createResearchKnowledgeSearchTool(iface?: ConfigInterface): Tool
972
976
  config.SYNTHESIS_MAX_TOKENS,
973
977
  config.LLM_THINKING_LEVEL,
974
978
  signal,
979
+ // Cache/session-affinity hint only (keeps repeated calls on the same warmed
980
+ // replica) — same resolution research-config.ts uses, since this tool has no
981
+ // researchId of its own to key on. ctx.sessionId is read defensively: it is
982
+ // not on every host's ExtensionContext shape, hence the `as any`.
983
+ (ctx as any).sessionId || ctx.sessionManager?.getSessionId(),
975
984
  );
976
985
 
977
986
  const durationMs = Date.now() - startTime;
@@ -983,7 +992,6 @@ export function createResearchKnowledgeSearchTool(iface?: ConfigInterface): Tool
983
992
  return buildSteeringResult(result, urls);
984
993
  } catch (error) {
985
994
  const durationMs = Date.now() - startTime;
986
- const msg = error instanceof Error ? error.message : String(error);
987
995
 
988
996
  // A CANCELLED search is not an empty store. Reporting the miss string here
989
997
  // told the calling agent, definitively, that the knowledge store has
@@ -991,7 +999,7 @@ export function createResearchKnowledgeSearchTool(iface?: ConfigInterface): Tool
991
999
  // the very work the user just interrupted. Propagate the abort instead;
992
1000
  // the run is ending either way. (Same misclassification as the search
993
1001
  // layer's "browser workers may be unavailable" on Ctrl-C.)
994
- if (signal?.aborted || (error instanceof Error && error.name === 'AbortError') || /^(aborted|research (aborted|cancelled))$/i.test(msg)) {
1002
+ if (isCancellation(error, signal)) {
995
1003
  metrics.observe('research_knowledge_search_duration_ms', durationMs, { status: 'cancelled' });
996
1004
  metrics.increment('research_knowledge_search_total', 1, { status: 'cancelled' });
997
1005
  logger.debug('[research-knowledge-search] Cancelled mid-search — propagating rather than reporting an empty store.');
@@ -17,6 +17,7 @@ import type {
17
17
  import type { ExtendedExtensionContext } from '../types/extension-context.ts';
18
18
  import type { ResearchDepth, CleanupContext } from '../types/index.ts';
19
19
  import { Type, type Static } from 'typebox';
20
+ import { Value } from 'typebox/value';
20
21
  import { validateConfig, getConfig, type ConfigInterface } from '../config.ts';
21
22
  import { tryGetServiceContainerFromCtx, getService } from '../core/service-registry.ts';
22
23
 
@@ -28,7 +29,7 @@ import { redactSecrets } from '../utils/log-utils.ts';
28
29
  import { exportResearchReport, appendExportMessage } from '../utils/research-export.ts';
29
30
  import { validateAndSanitizeQuery } from '../utils/input-validation.ts';
30
31
  import { validateInitialLinks, MAX_INITIAL_LINKS, MAX_INITIAL_LINK_CHARS } from '../utils/url-utils.ts';
31
- import { startResearchSession, endResearchSession, registerSessionAbort, clearSteeringMessages, getPiActivePanels } from '../orchestration/session-state.ts';
32
+ import { startResearchSession, endResearchSession, registerSessionAbort, clearSteeringMessages, getActiveResearchRunCount } from '../orchestration/session-state.ts';
32
33
  import { createResearchTuiManager, hideWorkingIndicator } from '../tui/research-tui-manager.ts';
33
34
  import { createCleanupFunction } from '../cleanup/research-cleanup.ts';
34
35
  import { createResearchObserver, createObserverState, stopObserverWaveAnimation } from '../observers/research-observer-impl.ts';
@@ -118,6 +119,21 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
118
119
 
119
120
  type ResearchParams = Static<typeof parameters>;
120
121
 
122
+ // Same field shapes as `parameters`, but WITHOUT the initialLinks count/length
123
+ // bounds — used only for the runtime execute() gate below. validateInitialLinks
124
+ // already enforces those same two bounds with a specific, friendly message
125
+ // ("at most N URLs...", "at most N characters..."); checking them again here
126
+ // would just produce the generic 'invalid_parameters' rejection first and mask
127
+ // that better message. Structural type-checking (initialLinks must still be an
128
+ // array of strings) is kept, since validateInitialLinks assumes that shape.
129
+ const executeShapeCheck = Type.Object({
130
+ query: Type.String(),
131
+ depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 3 })),
132
+ model: Type.Optional(Type.String()),
133
+ excludeTools: Type.Optional(Type.Array(Type.String())),
134
+ initialLinks: Type.Optional(Type.Array(Type.String())),
135
+ });
136
+
121
137
  return {
122
138
  name: 'research',
123
139
  label: 'Research',
@@ -169,6 +185,17 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
169
185
  };
170
186
  }
171
187
 
188
+ // Every other tool in this codebase re-validates params against its own
189
+ // schema before trusting it (the host does not enforce this) — this was the
190
+ // one exception, relying entirely on incidental downstream checks (a
191
+ // typeof on query, a warn-gate on excludeTools) for fields that skip both.
192
+ if (!Value.Check(executeShapeCheck, params)) {
193
+ return {
194
+ content: [{ type: 'text', text: 'Error: invalid parameters for research tool.' }],
195
+ details: { error: 'invalid_parameters' },
196
+ };
197
+ }
198
+
172
199
  const { query, depth: rawDepth, model: modelId, excludeTools: paramExcludeTools, initialLinks } = params as ResearchParams;
173
200
 
174
201
  // Enforce the same bounds the CLI applies to --initial-links: these links are
@@ -227,11 +254,13 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
227
254
  }
228
255
  // Steering messages are shared per pi-session. Only reset them when THIS is
229
256
  // the last active run — otherwise a finishing run wipes a concurrent sibling
230
- // run's queued/active steering mid-flight. This run's panel is still
231
- // registered here (endResearchSession runs later in cleanup()), so a count
232
- // of <= 1 means no other run is active.
257
+ // run's queued/active steering mid-flight. This run's abort registration is
258
+ // still in place here (endResearchSession runs later in cleanup()), so a
259
+ // count of <= 1 means no other run is active. getActiveResearchRunCount
260
+ // (not getPiActivePanels) because headless runs never register a panel —
261
+ // a panel-based check here was always a no-op for headless callers.
233
262
  try {
234
- if (getPiActivePanels(piSessionId).length <= 1) clearSteeringMessages(piSessionId);
263
+ if (getActiveResearchRunCount(piSessionId) <= 1) clearSteeringMessages(piSessionId);
235
264
  } catch (e) { logger.error('[research] Clear steering messages failed:', e); }
236
265
  if (detachAbortListener) {
237
266
  try { detachAbortListener(); } catch { /* best-effort */ } finally { detachAbortListener = undefined; }
@@ -344,9 +373,11 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
344
373
  // module-level piSessions map keyed by the STABLE pi session id, so
345
374
  // without this every headless run leaked them for the process
346
375
  // lifetime (the TUI branch frees them via createCleanupFunction, the
347
- // SDK in its own finally; this branch freed only steering).
376
+ // SDK in its own finally). Steering is NOT cleared here — teardownUi()
377
+ // already does that, gated on getActiveResearchRunCount so a finishing
378
+ // run doesn't wipe a concurrent sibling headless run's steering; an
379
+ // unconditional clear here bypassed that guard entirely.
348
380
  endResearchSession(piSessionId, sessionResearchId);
349
- clearSteeringMessages(piSessionId);
350
381
  };
351
382
 
352
383
  // Minimal stub — only stopObserverWaveAnimation touches panelState in the
@@ -31,6 +31,7 @@ import { ServiceNames } from '../core/service-interfaces.ts';
31
31
  import type { IKnowledgeStoreService } from '../core/service-interfaces.ts';
32
32
  import { logger } from '../logger.ts';
33
33
  import { metrics } from '../utils/metrics.ts';
34
+ import { isCancellation } from '../utils/cancellation.ts';
34
35
 
35
36
  export function createScrapeTool(options: {
36
37
  ctx: ExtensionContext;
@@ -267,6 +268,11 @@ export function createScrapeTool(options: {
267
268
  }, container);
268
269
  freshResults = Array.isArray(results) ? results : [];
269
270
  } catch (error) {
271
+ // A cancelled scrape is not a failed scrape — see isCancellation.
272
+ if (isCancellation(error, signal)) {
273
+ metrics.increment('tool_scrape_calls_total', 1, { status: 'cancelled' });
274
+ throw error;
275
+ }
270
276
  const message = error instanceof Error ? error.message : String(error);
271
277
  logger.error(`[scrape tool] Scrape failed: ${message}`);
272
278
  metrics.increment('tool_scrape_calls_total', 1, { status: 'error' });
@@ -13,6 +13,7 @@ import type { SystemResearchState } from '../orchestration/deep-research-types.t
13
13
  import { logger } from '../logger.ts';
14
14
  import { type Config, getConfig } from '../config.ts';
15
15
  import { metrics } from '../utils/metrics.ts';
16
+ import { isCancellation } from '../utils/cancellation.ts';
16
17
  import { tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
17
18
 
18
19
  export function createSearchTool(options: {
@@ -134,6 +135,12 @@ export function createSearchTool(options: {
134
135
  };
135
136
  } catch (error) {
136
137
  const elapsed = Date.now() - startTime;
138
+ // A cancelled search is not a failed search — see isCancellation.
139
+ if (isCancellation(error, signal)) {
140
+ metrics.observe('tool_search_duration_ms', elapsed, { status: 'cancelled' });
141
+ metrics.increment('tool_search_calls_total', 1, { status: 'cancelled' });
142
+ throw error;
143
+ }
137
144
  metrics.observe('tool_search_duration_ms', elapsed, { status: 'error' });
138
145
  metrics.increment('tool_search_calls_total', 1, { status: 'error' });
139
146
  const msg = error instanceof Error ? error.message : String(error);
@@ -11,6 +11,7 @@ import { searchSecurityDatabases } from '../security/index.ts';
11
11
  import type { ToolUsageTracker } from '../utils/tool-usage-tracker.ts';
12
12
  import { getMaxGatheringCalls } from '../constants.ts';
13
13
  import { metrics } from '../utils/metrics.ts';
14
+ import { isCancellation } from '../utils/cancellation.ts';
14
15
  import type { SecuritySearchParams } from '../security/types.ts';
15
16
 
16
17
  const MAX_DESCRIPTION_CHARS = 300;
@@ -78,18 +79,17 @@ export function createSecuritySearchTool(options: {
78
79
  _extensionCtx,
79
80
  ): Promise<AgentToolResult<unknown>> {
80
81
  const startTime = Date.now();
81
- metrics.increment('tool_security_search_calls_total', 1);
82
82
 
83
- // Record call in tracker - returns false if limit reached
84
- const allowed = options.tracker.recordCall('security_search');
85
- if (!allowed) {
86
- metrics.increment('tool_security_search_calls_total', 1, { status: 'rate_limited' });
87
- return {
88
- content: [{ type: 'text', text: options.tracker.getLimitMessage('security_search') }],
89
- details: { blocked: true, reason: 'limit_reached' },
90
- };
91
- }
83
+ // Every exit path below increments `tool_security_search_calls_total` itself
84
+ // (labeled with its own status) — there is no separate unconditional increment
85
+ // here. There used to be one, which double-counted every single call: sumCounter
86
+ // matches both the bare key and every labeled variant of the same base name, so
87
+ // the Session Metrics / Research Summary view reported exactly 2x the real count.
92
88
 
89
+ // Validate BEFORE spending the budget: a rejected call did no work, and charging
90
+ // it against the shared MAX_GATHERING_CALLS allowance turns a malformed call into
91
+ // a permanently lost one. See youtube-transcript for the sharpest case (a single
92
+ // allowed call, forfeited by a schema violation).
93
93
  if (!Value.Check(SecuritySearchParamsSchema, params)) {
94
94
  metrics.increment('tool_security_search_calls_total', 1, { status: 'invalid_params' });
95
95
  return {
@@ -150,6 +150,19 @@ export function createSecuritySearchTool(options: {
150
150
  }
151
151
  const maxResults = p.maxResults ?? 20;
152
152
 
153
+ // Record call in tracker - returns false if limit reached. Deliberately AFTER
154
+ // every semantic-validation reject above (terms/databases/severity), same
155
+ // reasoning as the schema check: a rejected call did no work and must not
156
+ // permanently consume one of the researcher's shared MAX_GATHERING_CALLS slots.
157
+ const allowed = options.tracker.recordCall('security_search');
158
+ if (!allowed) {
159
+ metrics.increment('tool_security_search_calls_total', 1, { status: 'rate_limited' });
160
+ return {
161
+ content: [{ type: 'text', text: options.tracker.getLimitMessage('security_search') }],
162
+ details: { blocked: true, reason: 'limit_reached' },
163
+ };
164
+ }
165
+
153
166
  metrics.increment('tool_security_search_terms_total', terms.length);
154
167
  metrics.increment('tool_security_search_databases_total', databases.length);
155
168
 
@@ -167,6 +180,14 @@ export function createSecuritySearchTool(options: {
167
180
  results = await searchSecurityDatabases(searchParams, signal);
168
181
  } catch (error) {
169
182
  const duration = Date.now() - startTime;
183
+ // A cancelled search is not a database outage — see isCancellation. The
184
+ // "temporary issue - try again later" text below invites exactly the retry
185
+ // the user just interrupted.
186
+ if (isCancellation(error, signal)) {
187
+ metrics.observe('tool_security_search_duration_ms', duration, { status: 'cancelled' });
188
+ metrics.increment('tool_security_search_calls_total', 1, { status: 'cancelled' });
189
+ throw error;
190
+ }
170
191
  metrics.observe('tool_security_search_duration_ms', duration, { status: 'error' });
171
192
  metrics.increment('tool_security_search_calls_total', 1, { status: 'error' });
172
193
  const errorMsg = error instanceof Error ? error.message : String(error);
@@ -10,6 +10,7 @@ import { Value } from 'typebox/value';
10
10
  import { stackexchangeCommand } from '../stackexchange/index.ts';
11
11
  import type { ToolUsageTracker } from '../utils/tool-usage-tracker.ts';
12
12
  import { getMaxGatheringCalls } from '../constants.ts';
13
+ import { isCancellation } from '../utils/cancellation.ts';
13
14
 
14
15
  export function createStackexchangeTool(options: {
15
16
  ctx: ExtensionContext;
@@ -74,6 +75,15 @@ export function createStackexchangeTool(options: {
74
75
  _onUpdate,
75
76
  extensionCtx,
76
77
  ): Promise<AgentToolResult<unknown>> {
78
+ // Validate BEFORE spending the budget — see security.ts. A rejected call did no
79
+ // work and must not consume one of MAX_GATHERING_CALLS.
80
+ if (!Value.Check(StackExchangeParamsSchema, params)) {
81
+ return {
82
+ content: [{ type: 'text', text: 'Invalid parameters for stackexchange tool.' }],
83
+ details: { error: 'invalid_parameters' },
84
+ };
85
+ }
86
+
77
87
  // Record call in tracker - returns false if limit reached
78
88
  const allowed = tracker.recordCall('stackexchange');
79
89
  if (!allowed) {
@@ -83,13 +93,6 @@ export function createStackexchangeTool(options: {
83
93
  };
84
94
  }
85
95
 
86
- if (!Value.Check(StackExchangeParamsSchema, params)) {
87
- return {
88
- content: [{ type: 'text', text: 'Invalid parameters for stackexchange tool.' }],
89
- details: { error: 'invalid_parameters' },
90
- };
91
- }
92
-
93
96
  const p = params as StackExchangeParams;
94
97
  const command = p.command;
95
98
 
@@ -105,6 +108,9 @@ export function createStackexchangeTool(options: {
105
108
  signal,
106
109
  });
107
110
  } catch (error) {
111
+ // A cancelled query was never rate-limited — see isCancellation. Naming a
112
+ // quota the run never hit is a cause the agent can carry into its report.
113
+ if (isCancellation(error, signal)) throw error;
108
114
  const errorMsg = error instanceof Error ? error.message : String(error);
109
115
  return {
110
116
  content: [
@@ -24,6 +24,7 @@ import { cacheScrapedContent, registerScrapedLinks, registerTranscribedLinks, re
24
24
  import { type Config, getConfig } from '../config.ts';
25
25
  import { logger } from '../logger.ts';
26
26
  import { metrics } from '../utils/metrics.ts';
27
+ import { isCancellation } from '../utils/cancellation.ts';
27
28
 
28
29
  export function createYoutubeTranscriptTool(options: {
29
30
  ctx: ExtensionContext;
@@ -75,18 +76,12 @@ export function createYoutubeTranscriptTool(options: {
75
76
  _onUpdate: AgentToolUpdateCallback<unknown>,
76
77
  extensionCtx: ExtensionContext,
77
78
  ): Promise<AgentToolResult<unknown>> {
78
- // Rate-limit enforcement (one call per researcher).
79
- if (tracker) {
80
- const allowed = tracker.recordCall('youtube_transcript');
81
- if (!allowed) {
82
- metrics.increment('tool_youtube_transcript_calls_total', 1, { status: 'rate_limited' });
83
- return {
84
- content: [{ type: 'text', text: tracker.getLimitMessage('youtube_transcript') }],
85
- details: { blocked: true, reason: 'limit_reached' },
86
- };
87
- }
88
- }
89
-
79
+ // Validate BEFORE spending the budget. Charging a rejected call is not a rate
80
+ // limit, it is a forfeit: this tool's schema caps `urls` at maxVideos while the
81
+ // per-researcher allowance is a single call, so a researcher passing one URL too
82
+ // many got a validation error AND lost its only call — and the retry was then told
83
+ // "You have already used your one youtube_transcript call", which is false. The
84
+ // round loses transcripts entirely. grep and search already validate first.
90
85
  if (!Value.Check(YoutubeParamsSchema, params)) {
91
86
  metrics.increment('tool_youtube_transcript_calls_total', 1, { status: 'invalid_params' });
92
87
  return {
@@ -105,6 +100,19 @@ export function createYoutubeTranscriptTool(options: {
105
100
  };
106
101
  }
107
102
 
103
+ // Rate-limit enforcement (one call per researcher), now that the call is one that
104
+ // would actually do work.
105
+ if (tracker) {
106
+ const allowed = tracker.recordCall('youtube_transcript');
107
+ if (!allowed) {
108
+ metrics.increment('tool_youtube_transcript_calls_total', 1, { status: 'rate_limited' });
109
+ return {
110
+ content: [{ type: 'text', text: tracker.getLimitMessage('youtube_transcript') }],
111
+ details: { blocked: true, reason: 'limit_reached' },
112
+ };
113
+ }
114
+ }
115
+
108
116
  // Resolve config against the live cwd for timeout/lang. The video cap stays
109
117
  // `maxVideos` (the factory value the schema's maxItems was built from) so the
110
118
  // schema gate and the command-level cap can never disagree. The PoToken
@@ -151,6 +159,12 @@ export function createYoutubeTranscriptTool(options: {
151
159
 
152
160
  return result;
153
161
  } catch (error) {
162
+ // A cancelled batch is not bot-protection — see isCancellation. "Continue
163
+ // with the rest of your research" is the opposite of what a Ctrl-C means.
164
+ if (isCancellation(error, signal)) {
165
+ metrics.increment('tool_youtube_transcript_calls_total', 1, { status: 'cancelled' });
166
+ throw error;
167
+ }
154
168
  const errorMsg = error instanceof Error ? error.message : String(error);
155
169
  logger.error(`[youtube_transcript tool] Failed: ${errorMsg}`);
156
170
  metrics.increment('tool_youtube_transcript_calls_total', 1, { status: 'error' });