@lincoln504/pi-research 1.6.5 → 1.6.7

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 (64) hide show
  1. package/README.md +14 -9
  2. package/agent-skill/pi-research/README.md +5 -4
  3. package/agent-skill/pi-research/SKILL.md +1 -1
  4. package/dist/cli.mjs +616 -153
  5. package/dist/pdf-extract-worker.mjs +1289 -0
  6. package/dist/prompts/research-tool-usage.md +2 -1
  7. package/dist/prompts/researcher.md +1 -1
  8. package/dist/prompts/system-coordinator.md +2 -2
  9. package/dist/prompts/system-lead-router.md +1 -1
  10. package/docs/AGENT-SKILL.md +6 -6
  11. package/docs/ARCHITECTURE.md +24 -22
  12. package/docs/CHANGELOG.md +73 -0
  13. package/docs/CONFIGURATION.md +3 -2
  14. package/docs/SDK.md +7 -12
  15. package/package.json +8 -5
  16. package/scripts/build.cjs +35 -0
  17. package/scripts/setup.cjs +12 -10
  18. package/src/cleanup/research-cleanup.ts +11 -5
  19. package/src/cli.ts +7 -5
  20. package/src/config.ts +9 -3
  21. package/src/core/interfaces/knowledge-interfaces.ts +8 -0
  22. package/src/core/interfaces/orchestration-interfaces.ts +1 -1
  23. package/src/core/interfaces/research-plan-types.ts +6 -0
  24. package/src/core/llm/agentic-repair.ts +40 -3
  25. package/src/core/llm/llm-utils.ts +93 -5
  26. package/src/core/llm/model-registry-factory.ts +2 -15
  27. package/src/core/pi-version.ts +1 -1
  28. package/src/core/planning-service.ts +110 -15
  29. package/src/core/planning-utils.ts +3 -0
  30. package/src/core/service-registry.ts +8 -6
  31. package/src/index.ts +8 -5
  32. package/src/infrastructure/browser/config.ts +6 -7
  33. package/src/infrastructure/browser/ensure-browser.ts +14 -7
  34. package/src/infrastructure/browser/thread-worker-browser.ts +18 -19
  35. package/src/knowledge/chunker.ts +22 -4
  36. package/src/knowledge/embedder-init.ts +41 -1
  37. package/src/knowledge/embedder-utils.ts +7 -1
  38. package/src/knowledge/store.ts +68 -1
  39. package/src/knowledge/transformers-loader.ts +58 -1
  40. package/src/observers/research-observer-impl.ts +4 -1
  41. package/src/orchestration/deep-research-orchestrator.ts +3 -3
  42. package/src/orchestration/headless-observer.ts +33 -0
  43. package/src/orchestration/quick-research-orchestrator.ts +17 -3
  44. package/src/orchestration/research-orchestration-service.ts +27 -4
  45. package/src/orchestration/session-state.ts +36 -7
  46. package/src/prompts/research-tool-usage.md +2 -1
  47. package/src/prompts/researcher.md +1 -1
  48. package/src/prompts/system-coordinator.md +2 -2
  49. package/src/prompts/system-lead-router.md +1 -1
  50. package/src/sdk.ts +12 -4
  51. package/src/tools/research-knowledge-search.ts +23 -7
  52. package/src/tools/research-tool-definition.ts +1 -1
  53. package/src/tools/scrape.ts +12 -9
  54. package/src/tools/search.ts +12 -6
  55. package/src/tools/security.ts +6 -1
  56. package/src/tools/stackexchange.ts +6 -1
  57. package/src/utils/cancellation.ts +34 -1
  58. package/src/utils/llm-usage.ts +8 -0
  59. package/src/utils/log-utils.ts +1 -1
  60. package/src/web-research/browser-search.ts +6 -5
  61. package/src/web-research/pdf-extract-worker.mjs +1289 -0
  62. package/src/web-research/pdf-extract-worker.ts +42 -0
  63. package/src/web-research/pdf-extraction.ts +392 -0
  64. package/src/web-research/web-scraper.ts +3 -111
@@ -88,11 +88,32 @@ function getPiState(piSessionId: string | undefined): PiSessionState {
88
88
  return state;
89
89
  }
90
90
 
91
+ /**
92
+ * Read-only lookup: returns the session's state WITHOUT creating one for an
93
+ * unknown/ended piSessionId. Read paths must use this — getPiState() on a
94
+ * diagnostic/late read for an already-ended session resurrects a full
95
+ * PiSessionState (four Maps, arrays, timer slots) that only endResearchSession
96
+ * or clearAllSessionState removes: a slow leak in a long-lived host, and a
97
+ * violation of the same peek-vs-create discipline ResearchSessionService and
98
+ * ResearchSynthesisService already enforce ("read/delete paths must never
99
+ * resurrect").
100
+ */
101
+ function peekPiState(piSessionId: string | undefined): PiSessionState | null {
102
+ return piSessions.get(normalizeSessionId(piSessionId)) ?? null;
103
+ }
104
+
91
105
  /**
92
106
  * Add a steering message to a Pi session
93
107
  */
94
108
  export function addSteeringMessage(piSessionId: string | undefined, message: string): void {
95
109
  const sid = normalizeSessionId(piSessionId);
110
+ if (!piSessionId) {
111
+ // Steering is scoped by piSessionId; a falsy one collapses to the shared
112
+ // 'default' sid, where two concurrent runs that both omitted it would
113
+ // consume and inject EACH OTHER's guidance. In-repo callers always pass a
114
+ // real id — make an SDK caller's contract violation loud at the boundary.
115
+ logger.warn('[session-state] addSteeringMessage called without a piSessionId — steering collapsed to the shared "default" scope and may cross-talk between concurrent runs.');
116
+ }
96
117
  const state = getPiState(sid);
97
118
 
98
119
  // Cap per-message length. All active steering is re-sent to the evaluator every
@@ -308,6 +329,10 @@ export function hasQueuedSteeringMessages(piSessionId: string | undefined): bool
308
329
  */
309
330
  export function onSessionOrderChange(piSessionId: string | undefined, callback: () => void): () => void {
310
331
  const sid = normalizeSessionId(piSessionId);
332
+ // Deliberately CREATE-on-read (the one exception to peekPiState): a
333
+ // subscription registered BEFORE the session's first write must survive to
334
+ // see it — peeking here would attach to nothing and silently never fire.
335
+ // The state object this creates is the same one later writers mutate.
311
336
  const state = getPiState(sid);
312
337
  state.subscribers.push(callback);
313
338
  return () => {
@@ -397,7 +422,8 @@ export function hideMasterWidget(piSessionId: string | undefined): void {
397
422
  */
398
423
  export function refreshAllSessions(piSessionId: string | undefined): void {
399
424
  const sid = normalizeSessionId(piSessionId);
400
- const state = getPiState(sid);
425
+ const state = peekPiState(sid);
426
+ if (!state) return; // nothing was ever created for this session — nothing to refresh
401
427
 
402
428
  if (state.refreshTimeout) {
403
429
  clearTimeout(state.refreshTimeout);
@@ -632,8 +658,11 @@ export function recordResearcherFailure(piSessionId: string | undefined, researc
632
658
  */
633
659
  export function getResearcherFailureReasons(piSessionId: string | undefined, researchId: string): Record<string, string> {
634
660
  const sid = normalizeSessionId(piSessionId);
635
- const state = getPiState(sid);
636
- const reasons = state.failureReasons.get(researchId);
661
+ // Peek, never create: this is a read path (getFailedResearchers below is the
662
+ // sibling). A read for an ended/never-created session must not resurrect a
663
+ // PiSessionState keyed by a random sessionId and leak it for process lifetime.
664
+ const state = peekPiState(sid);
665
+ const reasons = state?.failureReasons.get(researchId);
637
666
  return reasons ? Object.fromEntries(reasons) : {};
638
667
  }
639
668
 
@@ -642,8 +671,8 @@ export function getResearcherFailureReasons(piSessionId: string | undefined, res
642
671
  */
643
672
  export function getFailedResearchers(piSessionId: string | undefined, researchId: string): string[] {
644
673
  const sid = normalizeSessionId(piSessionId);
645
- const state = getPiState(sid);
646
- const failures = state.failures.get(researchId) || [];
674
+ const state = peekPiState(sid);
675
+ const failures = state?.failures.get(researchId) || [];
647
676
  return [...new Set(failures)];
648
677
  }
649
678
 
@@ -680,8 +709,8 @@ export function getResearchStopMessage(piSessionId: string | undefined, research
680
709
  // blocking.
681
710
  // Lead with the RECORDED errors when we have them — the actual root cause
682
711
  // (e.g. a provider 429) beats generic guidance every time.
683
- const state = getPiState(sid);
684
- const reasons = state.failureReasons.get(researchId);
712
+ const state = peekPiState(sid);
713
+ const reasons = state?.failureReasons.get(researchId);
685
714
  const recorded = reasons && reasons.size > 0
686
715
  ? ['Recorded errors:', ...[...reasons].map(([id, r]) => `• researcher ${id}: ${r}`), '']
687
716
  : [];
@@ -85,7 +85,8 @@ The coordinator will plan as many researchers as needed (up to the max). You do
85
85
 
86
86
  **Multiple Topics:**
87
87
  - If you need to research multiple unrelated topics of **truly distinct scope**, you may emit multiple `research` calls **simultaneously in a single turn**. Each call gets its own isolated research run with its own coordinator and researchers.
88
- - **Maximum 4 simultaneous calls** — Do NOT make more than 4 `research` calls in a single turn, even if you have more unrelated topics. Exceeding 4 simultaneous research runs can overload the system and degrade performance. If you have more than 4 truly distinct topics, they can likely be consolidated into fewer calls (group related subtopics, use broader queries, or batch across multiple turns).
88
+ - **Maximum 4 simultaneous calls** — Do NOT make more than 4 `research` calls in a single turn, even if you have more unrelated topics. Exceeding 4 simultaneous research runs can overload the system and degrade performance. If you have more than 4 truly distinct topics, they can likely be consolidated into fewer calls (group related subtopics, use broader queries, or batch across multiple turns). Treat the machine-wide run cap (next bullet) as the REAL ceiling: it may be lower than 4, and it always wins.
89
+ - **Simultaneous calls are also bounded by the machine-wide run cap (default 3, `PI_RESEARCH_MAX_CONCURRENT_RUNS`).** A call beyond the cap WAITS for a free slot and fails with a capacity error if the wait expires — so do not count on more than 3 running at once, and never rely on the 4-call maximum being available.
89
90
  - Example: `research("bananas")` and `research("quantum computing")` in the same turn — these have zero overlap.
90
91
  - **Group related topics**: If topics share ANY scope or could inform each other, combine them into a single `research` call. The internal coordinator will decompose them into parallel researcher agents. Do NOT split related sub-topics into separate calls.
91
92
  - Example: `research("Python async performance and Rust async performance comparison")` — NOT separate calls for Python and Rust, since the comparison IS the research goal.
@@ -50,7 +50,7 @@ Write your report immediately after scraping is complete, or as soon as a tool r
50
50
  - `youtube_transcript`: Read the captions/transcript of YouTube videos from your source list — it is the `scrape` for video. ONE call only, batching up to a few of the most relevant YouTube links. Cite these as `Source: YouTube Transcript`.
51
51
  - `stackexchange`: Use ONLY for Stack Overflow or Stack Exchange URLs.
52
52
  - `security_search`: Query NVD, CVE, OSV, CISA databases.
53
- - `read` / `grep`: Read files and search text in the local codebase. Use ONLY if local codebase context is explicitly required.
53
+ - `read`: Read files in the local codebase. Use ONLY if local codebase context is explicitly required.
54
54
  {{extra_tool_guidelines}}
55
55
 
56
56
  - **Specialized Tooling Directive**: Do NOT waste time with auxiliary tools (`stackexchange`, `security_search`) unless they are **specifically necessary** for your assigned goal. They are not for exploratory steps. Your primary workflow is to **scrape** authoritative web sources and **report** findings.
@@ -43,7 +43,7 @@ Before splitting the query into researchers, pin down its scope — exactly what
43
43
 
44
44
  ## Output Format
45
45
 
46
- Return ONLY a JSON block containing your full team plan. Use this general schema:
46
+ Respond with your full team plan by CALLING the `submit_plan` tool when it is offered in the conversation — its arguments ARE the plan (primary, schema-exact channel). Only if you cannot call tools, return a JSON block instead. Use this general schema:
47
47
 
48
48
  ```json
49
49
  {
@@ -72,4 +72,4 @@ Return ONLY a JSON block containing your full team plan. Use this general schema
72
72
  - **Researcher IDs**: Use Round.Index format (e.g. **1.1**, **1.2**).
73
73
  - **Citations**: Each researcher's queries should be designed to uncover sources that can be comprehensively cited. Prioritize queries that lead to authoritative references, data, and documented facts.
74
74
  - **Title**: Set `"title"` to 1-2 words that best name the core topic (e.g. `"iPhone 15"`, `"React Performance"`, `"PostgreSQL Replication"`). Use proper nouns and brand names where applicable. Do NOT use generic words like "research", "overview", or "options".
75
- - **Format**: Valid JSON only. No markdown, no explanation outside the JSON block.
75
+ - **Format**: Call `submit_plan` with the plan as its arguments when the tool is offered; otherwise output valid JSON only. No markdown, no explanation outside the JSON block.
@@ -119,4 +119,4 @@ When delegating, ensure:
119
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
120
  - **Query Budget**: Use the complexity-specific budget ({{query_budget}} per researcher). Fill each researcher's query budget completely.
121
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.
122
+ - **Format**: Call `submit_routing_decision` with the updated plan as its arguments when the tool is offered; otherwise return ONLY valid JSON in a code block. Never return report prose.
package/src/sdk.ts CHANGED
@@ -30,7 +30,7 @@ import type {
30
30
  import type { IPlanningService } from './core/interfaces/planning-interfaces.ts';
31
31
  import type { ResearchObserver, HeadlessObserverOptions } from './core/interfaces/observer-interfaces.ts';
32
32
  import type { ResearchPlan } from './core/interfaces/research-plan-types.ts';
33
- import { HeadlessObserver } from './orchestration/headless-observer.ts';
33
+ import { HeadlessObserver, isHeadlessObserverBag } from './orchestration/headless-observer.ts';
34
34
  import type { Model } from '@earendil-works/pi-ai';
35
35
  import { type ExtensionContext, ModelRegistry } from '@earendil-works/pi-coding-agent';
36
36
  import { logger, createLogger, setLogger } from './logger.ts';
@@ -607,8 +607,10 @@ export async function runDeepResearch(
607
607
  }
608
608
  throw err;
609
609
  } finally {
610
- // Free the per-run PiSessionState that shouldStopResearch()/getFailedResearchers()
611
- // lazily created (keyed by this run's random sessionId). A long-lived SDK/benchmark
610
+ // Free the per-run PiSessionState that startResearchSession()/registerSessionAbort()
611
+ // created for this run (keyed by this run's random sessionId; the read accessors —
612
+ // getFailedResearchers()/getResearcherFailureReasons()/shouldStopResearch() — only
613
+ // peek). A long-lived SDK/benchmark
612
614
  // process doing thousands of runs would otherwise accumulate one never-freed entry
613
615
  // (failures/aborts/panels) per run — only full shutdown reclaimed them before.
614
616
  try { endResearchSession(sessionId, researchId); } catch { /* best-effort */ }
@@ -694,7 +696,7 @@ function wrapObserverForOutcomeCapture(
694
696
  onPlanned: (count: number) => void,
695
697
  ): ResearchObserver {
696
698
  const base: ResearchObserver | undefined =
697
- observer && typeof (observer as HeadlessObserverOptions).onProgress === 'function' && !(observer instanceof HeadlessObserver)
699
+ observer && isHeadlessObserverBag(observer)
698
700
  ? new HeadlessObserver(observer as HeadlessObserverOptions)
699
701
  : (observer as ResearchObserver | undefined);
700
702
 
@@ -890,6 +892,12 @@ export async function searchKnowledge(
890
892
  if (!Array.isArray(queries) || queries.length === 0) {
891
893
  throw new Error('searchKnowledge requires a non-empty array of queries.');
892
894
  }
895
+ if (queries.length > 5) {
896
+ // Fail-fast, matching the CLI's contract ('knowledge accepts at most 5
897
+ // queries.') — the previous silent slice(0, 5) dropped input without telling
898
+ // the caller, the exact advertised-vs-enforced drift this SDK avoids.
899
+ throw new Error('searchKnowledge accepts at most 5 queries.');
900
+ }
893
901
 
894
902
  // Lazily import the tool factory so the SDK bundle stays lean when unused.
895
903
  const { createResearchKnowledgeSearchTool } = await import('./tools/research-knowledge-search.ts');
@@ -34,7 +34,7 @@ import { Value } from 'typebox/value';
34
34
  import { completeSimple } from '../core/llm/pi-ai-completion.ts';
35
35
  import type { Model } from '@earendil-works/pi-ai';
36
36
  import { recordLlmUsage } from '../utils/llm-usage.ts';
37
- import { buildSafeOptions, validateAndExtractText } from '../core/llm/llm-utils.ts';
37
+ import { buildSafeOptions, validateAndExtractText, buildConstrainedSubmitTool, completeSimpleStructured } from '../core/llm/llm-utils.ts';
38
38
  import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
39
39
  import { withTimeout } from '../core/llm/llm-timeout.ts';
40
40
  import { abortableDelay } from '../web-research/retry-utils.ts';
@@ -497,6 +497,15 @@ export async function runBackgroundExtraction(
497
497
  const systemPrompt = user === null ? fill(system) : system;
498
498
  const userMessage = user === null ? finalInstruction : `${fill(user)}\n\n${finalInstruction}`;
499
499
 
500
+ // Structured-output path, same pattern as the planner/router: offer the
501
+ // schema-constrained submit tool (the synthesis response IS a JSON object
502
+ // validated below), fall back to the text pipeline on a prose answer.
503
+ const submitSynthesisTool = buildConstrainedSubmitTool(
504
+ 'submit_knowledge_synthesis',
505
+ 'Submit the extracted answer in the required structured format.',
506
+ ResearchKnowledgeSynthesisResponseSchemaAsTSchema,
507
+ );
508
+
500
509
  // Phase 4a: Stateless LLM call — no AgentSession, no side-effects.
501
510
  // llmTimeout is resolved by the caller from the iface-aware Config so a
502
511
  // per-interface overlay (pi.env / cli.env) is honored here.
@@ -509,26 +518,32 @@ export async function runBackgroundExtraction(
509
518
  for (;;) {
510
519
  attempt++;
511
520
  try {
512
- const response = await withTimeout(
513
- completeSimple(model, {
521
+ const structured = await withTimeout(
522
+ completeSimpleStructured(model, {
514
523
  systemPrompt,
515
524
  messages: [
516
- { role: 'user', content: [{ type: 'text', text: userMessage }], timestamp: Date.now() },
525
+ { role: 'user', content: [{ type: 'text', text: userMessage + `\n\nRespond by calling the ${submitSynthesisTool.name} tool with the extraction as its arguments.` }], timestamp: Date.now() },
517
526
  ],
518
- }, buildSafeOptions(model, {
527
+ }, submitSynthesisTool, buildSafeOptions(model, {
519
528
  apiKey: auth.apiKey,
520
529
  headers: auth.headers,
521
530
  signal,
522
531
  sessionId,
523
- }, maxTokens, thinkingLevel)),
532
+ }, maxTokens, thinkingLevel), 'Knowledge Extraction'),
524
533
  llmTimeout,
525
534
  'knowledge-search-extraction',
526
535
  );
536
+ const response = structured.response;
527
537
 
528
538
  // Track token and cost metrics for the background synthesis call
529
539
  recordLlmUsage(model, (response as any).usage, { component: 'knowledge_search' });
530
540
 
531
- responseText = validateAndExtractText(response, 'Knowledge Extraction');
541
+ // Tool-call arguments are re-serialized so the SAME parse → coerce →
542
+ // validate pipeline below runs for both outcomes; text passes through
543
+ // byte-identical to the previous behavior.
544
+ responseText = structured.kind === 'toolCall'
545
+ ? JSON.stringify(structured.args)
546
+ : structured.text;
532
547
  break;
533
548
  } catch (err) {
534
549
  // A real cancellation propagates immediately — no retry, no fallback.
@@ -582,6 +597,7 @@ export async function runBackgroundExtraction(
582
597
  // at repairJsonWithLlm's smaller default (16384). Mirrors the planning-service repairs.
583
598
  maxTokens,
584
599
  thinkingLevel,
600
+ timeoutMs: llmTimeout,
585
601
  onUsage: (rawUsage) => recordLlmUsage(model, rawUsage, { component: 'knowledge_search' }),
586
602
  },
587
603
  );
@@ -282,7 +282,7 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
282
282
  // replace it (validateAndSanitizeQuery below rejects an empty query anyway;
283
283
  // failing here keeps the message clear and matches the CLI's up-front check).
284
284
  if (!query) {
285
- return { content: [{ type: 'text', text: 'Error: research requires a query; initialLinks seed URLs for a query but cannot replace it.' }], details: {} };
285
+ return { content: [{ type: 'text', text: 'Error: research requires a query; initialLinks seeds URLs for the query but cannot replace it.' }], details: {} };
286
286
  }
287
287
 
288
288
  // Each run gets its own isolated registry; session-level counter is incremented
@@ -78,7 +78,7 @@ export function createScrapeTool(options: {
78
78
  });
79
79
 
80
80
  const ScrapeParamsSchema = Type.Object({
81
- urls: Type.Array(Type.String({ description: 'The URLs to scrape' }), { minItems: 1, maxItems: 20 }),
81
+ urls: Type.Array(Type.String({ description: `The URLs to scrape (only the first ${MAX_SCRAPE_URLS} are fetched per call; extras are reported under "Not Fetched" rather than silently dropped)` }), { minItems: 1, maxItems: 20 }),
82
82
  maxConcurrency: Type.Optional(Type.Number({ default: config.MAX_CONCURRENT_SCRAPES, minimum: 1, maximum: 20 })),
83
83
  });
84
84
 
@@ -110,6 +110,17 @@ export function createScrapeTool(options: {
110
110
  async execute(_callId: string, params: unknown, signal: AbortSignal, _onUpdate: AgentToolUpdateCallback<any>): Promise<AgentToolResult<unknown>> {
111
111
  const callStartTime = Date.now();
112
112
 
113
+ // Validate BEFORE spending the budget slot — the doctrine security.ts /
114
+ // stackexchange.ts / youtube-transcript.ts already follow (a rejected call
115
+ // must not consume a batch). The limit check used to run first, inverting it.
116
+ if (!Value.Check(ScrapeParamsSchema, params)) {
117
+ metrics.increment('tool_scrape_calls_total', 1, { status: 'invalid_params' });
118
+ return {
119
+ content: [{ type: 'text', text: 'Invalid parameters for scrape tool. Expected an array of URLs.' }],
120
+ details: { error: 'invalid_params' },
121
+ };
122
+ }
123
+
113
124
  // Rate-limit enforcement (only when tracker is present)
114
125
  if (options.tracker) {
115
126
  const callCount = options.tracker.getToolCallCount('scrape');
@@ -123,14 +134,6 @@ export function createScrapeTool(options: {
123
134
  }
124
135
  }
125
136
 
126
- if (!Value.Check(ScrapeParamsSchema, params)) {
127
- metrics.increment('tool_scrape_calls_total', 1, { status: 'invalid_params' });
128
- return {
129
- content: [{ type: 'text', text: 'Invalid parameters for scrape tool. Expected an array of URLs.' }],
130
- details: { error: 'invalid_params' },
131
- };
132
- }
133
-
134
137
  const p = params as ScrapeParams;
135
138
  const rawUrls = p.urls.map(u => u.trim()).filter(u => u.length > 0);
136
139
 
@@ -29,7 +29,10 @@ export function createSearchTool(options: {
29
29
  const SearchParamsSchema = Type.Object({
30
30
  queries: Type.Array(Type.String(), {
31
31
  minItems: 1,
32
- maxItems: 50,
32
+ // Honest schema: the runtime cap is 30 (hard-coded below), so the
33
+ // advertised max must be 30 — a host reading maxItems 50 would compose
34
+ // up to 50 queries and silently lose 20 of them at the cap.
35
+ maxItems: 30,
33
36
  description: 'A list of 5-30 search queries to execute (minimum 1).'
34
37
  }),
35
38
  });
@@ -65,12 +68,10 @@ export function createSearchTool(options: {
65
68
  let queries = p.queries;
66
69
  metrics.increment('tool_search_queries_total', queries.length);
67
70
 
68
- if (queries.length < 1) {
69
- metrics.increment('tool_search_calls_total', 1, { status: 'insufficient_queries' });
70
- throw new Error(`Insufficient queries: ${queries.length}. Provide at least 1 highly specific queries.`);
71
- }
72
-
71
+ // (No min-length check needed: Value.Check above enforces minItems: 1, so
72
+ // an under-length call is already rejected as invalid_parameters.)
73
73
  // Hard cap at the documented 30-query maximum
74
+ let submittedQueries = queries.length;
74
75
  if (queries.length > 30) {
75
76
  logger.warn(`[search tool] Capping tool call queries: ${queries.length} → 30`);
76
77
  metrics.increment('tool_search_capped_queries_total', queries.length - 30);
@@ -107,6 +108,11 @@ export function createSearchTool(options: {
107
108
 
108
109
  let markdown = `# Web Search Results (${queries.length} queries)\n\n`;
109
110
  markdown += `**Source: Web Search**\n\n`;
111
+ if (submittedQueries > queries.length) {
112
+ // In-band so the calling agent knows its input was truncated and can
113
+ // re-plan with a follow-up search — mirrors scrape.ts's over-cap note.
114
+ markdown += `> NOTE: ${submittedQueries - queries.length} of ${submittedQueries} submitted queries were dropped by the 30-query cap. Re-run a follow-up search with the remainder if those topics matter.\n\n`;
115
+ }
110
116
  // Untrusted-data boundary: result titles/snippets below are attacker-influenceable (via a
111
117
  // page's own <title>/meta). Remind the model in-band that they are data, not instructions —
112
118
  // mirrors the scrape tool's banner; defense-in-depth alongside the researcher prompt.
@@ -10,6 +10,7 @@ import { Value } from 'typebox/value';
10
10
  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
+ import type { Config } from '../config.ts';
13
14
  import { metrics } from '../utils/metrics.ts';
14
15
  import { isCancellation } from '../utils/cancellation.ts';
15
16
  import type { SecuritySearchParams } from '../security/types.ts';
@@ -26,6 +27,10 @@ function truncateDescription(description: string): string {
26
27
  export function createSecuritySearchTool(options: {
27
28
  ctx: ExtensionContext;
28
29
  tracker: ToolUsageTracker;
30
+ /** The run's resolved config — the gathering-call budget prompt must read the
31
+ * SAME overlay-resolved value the tracker enforces, not the ambient default
32
+ * (scrape.ts fixed this same advertised-vs-enforced drift). */
33
+ config?: Config;
29
34
  }): ToolDefinition {
30
35
 
31
36
  const SecuritySearchParamsSchema = Type.Object({
@@ -67,7 +72,7 @@ export function createSecuritySearchTool(options: {
67
72
  'Available for looking up CVE IDs, package vulnerabilities, or security advisories.',
68
73
  'Supports databases: NVD (340k+ CVEs), CISA KEV (actively exploited), GitHub Advisories (open source), OSV (packages).',
69
74
  'Filter by severity, CVE ID, package name, or include only actively exploited vulnerabilities.',
70
- `CRITICAL: You are allowed a maximum of ${getMaxGatheringCalls()} gathering calls across your web tools (search, security_search, stackexchange). Use them for breadth.`,
75
+ `CRITICAL: You are allowed a maximum of ${getMaxGatheringCalls(options.config)} gathering calls across your web tools (search, security_search, stackexchange). Use them for breadth.`,
71
76
  ],
72
77
  parameters: SecuritySearchParamsSchema,
73
78
  executionMode: 'parallel',
@@ -10,11 +10,16 @@ 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 type { Config } from '../config.ts';
13
14
  import { isCancellation } from '../utils/cancellation.ts';
14
15
 
15
16
  export function createStackexchangeTool(options: {
16
17
  ctx: ExtensionContext;
17
18
  tracker: ToolUsageTracker;
19
+ /** The run's resolved config — the gathering-call budget prompt must read the
20
+ * SAME overlay-resolved value the tracker enforces, not the ambient default
21
+ * (scrape.ts fixed this same advertised-vs-enforced drift). */
22
+ config?: Config;
18
23
  }): ToolDefinition {
19
24
  const { tracker } = options;
20
25
 
@@ -64,7 +69,7 @@ export function createStackexchangeTool(options: {
64
69
  'Anonymous access: 300 requests/day. Set STACKEXCHANGE_API_KEY env var for 10,000/day.',
65
70
  'Use tags to filter by specific topics.',
66
71
  'Use maxPages parameter to control pagination for search results (default: 5 pages).',
67
- `CRITICAL: You are allowed a maximum of ${getMaxGatheringCalls()} gathering calls across your web tools (search, security_search, stackexchange). Use them for breadth.`,
72
+ `CRITICAL: You are allowed a maximum of ${getMaxGatheringCalls(options.config)} gathering calls across your web tools (search, security_search, stackexchange). Use them for breadth.`,
68
73
  ],
69
74
  parameters: StackExchangeParamsSchema,
70
75
  executionMode: 'parallel',
@@ -30,7 +30,40 @@ export function isCancellation(error: unknown, signal?: AbortSignal | null): boo
30
30
  if (signal?.aborted) return true;
31
31
  if (error instanceof Error && error.name === 'AbortError') return true;
32
32
  const message = error instanceof Error ? error.message : String(error);
33
- return /^(aborted|the operation was aborted|research (aborted|cancelled|canceled)|operation cancelled|operation canceled)\.?$/i.test(
33
+ return /^(aborted|(?:the|this) operation was aborted|research (aborted|cancelled|canceled)|operation cancelled|operation canceled)\.?$/i.test(
34
34
  message.trim(),
35
35
  );
36
36
  }
37
+
38
+ /**
39
+ * Race `op` against `signal`, resolving `undefined` when the signal fires
40
+ * first. The LOSER keeps running in the background — this is an abandon, not
41
+ * a cancel: the op must be safe to leave draining (probes with their own
42
+ * timeouts, best-effort telemetry) and its eventual result or rejection is
43
+ * dropped. Callers treating abandonment as "skip the work" keep their abort
44
+ * handling at the next signal-aware await, exactly where the rest of the run's
45
+ * abort plumbing expects it.
46
+ *
47
+ * The listener is hoisted (defined once, removed in `finally`) so a settled
48
+ * race can never leak an `abort` listener onto a long-lived run signal — the
49
+ * same hygiene the orchestrators' capacity-wait races apply.
50
+ */
51
+ export function raceWithSignal<T>(op: Promise<T>, signal?: AbortSignal | null): Promise<T | undefined> {
52
+ if (!signal) return op.then((v) => v);
53
+ if (signal.aborted) {
54
+ // The op was started eagerly and may still REJECT later; without a handler
55
+ // attached that rejection surfaces as an unhandledRejection even though the
56
+ // race already resolved. Swallow it — abandonment drops the outcome by
57
+ // contract (the op must be safe to leave draining).
58
+ void op.catch(() => {});
59
+ return Promise.resolve(undefined);
60
+ }
61
+ let onAbort: () => void;
62
+ const abandoned = new Promise<undefined>((resolve) => {
63
+ onAbort = () => resolve(undefined);
64
+ signal.addEventListener('abort', onAbort, { once: true });
65
+ });
66
+ return Promise.race([op, abandoned]).finally(() => {
67
+ signal.removeEventListener('abort', onAbort!);
68
+ });
69
+ }
@@ -67,6 +67,14 @@ function warnIfUnpriced(model: Model<any>, tokens: number, cost: number): void {
67
67
  * `tokens > 0 || cost > 0`, so a response whose usage is all-zero across the board
68
68
  * emits nothing — harmless in practice because cache fields are summed into `tokens`,
69
69
  * so a reported cache split implies a nonzero total.
70
+ *
71
+ * Accuracy note: for the openai-completions API family (which includes OpenRouter),
72
+ * pi-ai's usage normalization ALWAYS sets cacheRead/cacheWrite, defaulting missing
73
+ * upstream fields to 0 — so on those providers a route without prompt caching shows the
74
+ * counter at 0 rather than omitting it. The 0-vs-absent distinction only holds for APIs
75
+ * that omit the fields outright. A run showing llm_cache_read_tokens_total stuck at 0
76
+ * with nonzero input tokens is therefore most simply read as "this route reports no
77
+ * cached tokens", not "our prompts defeat the cache".
70
78
  */
71
79
  function recordCacheTokens(parsed: Partial<TokenUsage>, labels: Record<string, string>): void {
72
80
  if (typeof parsed.cacheRead === 'number') {
@@ -38,7 +38,7 @@ export function buildDefaultDebugLogPath(): string {
38
38
  * Check whether debug/verbose logging is enabled.
39
39
  *
40
40
  * Reads PI_RESEARCH_DEBUG env var. Config SAVES sync config.DEBUG into this
41
- * env var (see config.ts buildEnvValues); plain config LOADS do not mutate
41
+ * env var (see the DEBUG → PI_RESEARCH_DEBUG bridge in config.ts createConfig); plain config LOADS do not mutate
42
42
  * process.env, so a DEBUG=true line in config.env enables verbose logging for
43
43
  * a process only after a save has run in it — export PI_RESEARCH_DEBUG=true
44
44
  * in the environment to guarantee it from process start.
@@ -331,11 +331,12 @@ export async function performSearch(
331
331
  metrics.increment('browser_search_total_failures_total', 1, { cause: 'native_binding' });
332
332
  throw new Error(
333
333
  `Search completely failed: ${reason}, all with a missing native module. ` +
334
- `This is an incomplete install, not a network problem: the browser engine's ` +
335
- `native dependencies were never built. npm 12 does not run dependency install ` +
336
- `scripts by default — reinstall allowing the script (global install: ` +
337
- `npm install -g @lincoln504/pi-research --allow-scripts=better-sqlite3; ` +
338
- `project install: npm approve-scripts better-sqlite3, then npm rebuild better-sqlite3), then retry.` +
334
+ `This is an incomplete install, not a network problem: better-sqlite3 13 ` +
335
+ `ships prebuilt bindings for every supported platform and loads them at ` +
336
+ `runtime, so a missing binding means an old or broken npm install. ` +
337
+ `Upgrade npm to ≥12 (npm install -g npm@12) and reinstall this package — ` +
338
+ `modern npm skips the needless recompile that fails on toolchain-less ` +
339
+ `machines, and the prebuilt binding then loads as-is.` +
339
340
  (sampleWorkerError ? ` Last worker error: ${sampleWorkerError}` : '')
340
341
  );
341
342
  }