@lincoln504/pi-research 1.4.1 → 1.5.0

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 (58) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +449 -187
  3. package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
  4. package/dist/prompts/research-knowledge-search-extractor.md +2 -0
  5. package/dist/prompts/researcher.md +2 -14
  6. package/dist/thread-worker.mjs +91 -59
  7. package/docs/AGENT-SKILL.md +4 -3
  8. package/docs/CHANGELOG.md +120 -0
  9. package/docs/CONFIGURATION.md +2 -2
  10. package/docs/SDK.md +1 -1
  11. package/package.json +1 -1
  12. package/skills/pi-research/SKILL.md +1 -1
  13. package/src/cli.ts +44 -3
  14. package/src/config.ts +36 -13
  15. package/src/constants.ts +23 -4
  16. package/src/core/llm/research-model-resolver.ts +38 -0
  17. package/src/core/planning-service.ts +67 -9
  18. package/src/index.ts +3 -1
  19. package/src/infrastructure/browser/browser-task-scheduler.ts +16 -9
  20. package/src/infrastructure/browser/config.ts +40 -2
  21. package/src/infrastructure/browser/thread-worker-browser.ts +69 -48
  22. package/src/infrastructure/browser/thread-worker-messaging.ts +65 -4
  23. package/src/infrastructure/browser/thread-worker.mjs +91 -59
  24. package/src/infrastructure/browser/thread-worker.ts +13 -5
  25. package/src/infrastructure/embedding/embedding-server.ts +54 -0
  26. package/src/knowledge/embedder-init.ts +3 -2
  27. package/src/knowledge/embedder.ts +11 -3
  28. package/src/knowledge/index.ts +36 -10
  29. package/src/knowledge/store.ts +85 -5
  30. package/src/observers/research-observer-impl.ts +14 -6
  31. package/src/orchestration/deep-research-orchestrator.ts +3 -1
  32. package/src/orchestration/quick-research-orchestrator.ts +29 -10
  33. package/src/orchestration/research-synthesis-service.ts +32 -0
  34. package/src/orchestration/researcher-executor.ts +31 -16
  35. package/src/orchestration/researcher.ts +2 -2
  36. package/src/orchestration/session-state.ts +35 -4
  37. package/src/prompts/research-knowledge-relevance-triage.md +2 -0
  38. package/src/prompts/research-knowledge-search-extractor.md +2 -0
  39. package/src/prompts/researcher.md +2 -14
  40. package/src/sdk.ts +16 -0
  41. package/src/skill-install/skill-installer.ts +5 -1
  42. package/src/stackexchange/index.ts +3 -0
  43. package/src/stackexchange/rest-client.ts +7 -12
  44. package/src/tools/index.ts +2 -1
  45. package/src/tools/research-knowledge-search.ts +37 -4
  46. package/src/tools/research-tool-definition.ts +54 -12
  47. package/src/tools/scrape.ts +8 -1
  48. package/src/tools/security.ts +13 -10
  49. package/src/utils/coverage-digest.ts +1 -1
  50. package/src/utils/host-config.ts +12 -1
  51. package/src/utils/log-utils.ts +15 -2
  52. package/src/utils/metrics-summary.ts +21 -1
  53. package/src/utils/tool-usage-tracker.ts +6 -3
  54. package/src/web-research/browser-search.ts +11 -4
  55. package/src/web-research/web-scraper.ts +36 -16
  56. package/src/youtube/index.ts +11 -1
  57. package/src/youtube/potoken.ts +8 -1
  58. package/src/youtube/transcript-client.ts +56 -5
@@ -21,7 +21,7 @@ import type { SystemResearchState } from './deep-research-types.ts';
21
21
  import type { Config } from '../config.ts';
22
22
  import { logger } from '../logger.ts';
23
23
 
24
- import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
24
+ import { resolveResearchModel, isPromptCachingActiveForModel } from '../core/llm/research-model-resolver.ts';
25
25
  import { getRuntimeForRegistry } from '../core/llm/model-registry-factory.ts';
26
26
  import { boundSessionAbort } from './abort-utils.ts';
27
27
 
@@ -86,7 +86,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
86
86
  logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
87
87
 
88
88
  try {
89
- const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
89
+ const tracker = new ToolUsageTracker(createDefaultToolLimits(config, isPromptCachingActiveForModel(modelToUse)));
90
90
 
91
91
  // Use provided closures or fallback to safe dummies
92
92
  const globalLinks = updateGlobalLinks || (() => {});
@@ -518,8 +518,22 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
518
518
 
519
519
  // If this was the last research run in the Pi session, clean up the state
520
520
  // but preserve steering messages if any remain (they might arrive between
521
- // the last research end and the next research start)
522
- if (state.order.length === 0 && state.panels.size === 0) {
521
+ // the last research end and the next research start).
522
+ //
523
+ // `state.aborts` is included alongside `order`/`panels`: those two are
524
+ // populated ONLY by the TUI path (registerSessionPanel), so for a purely
525
+ // headless multi-run session they are always empty — including while
526
+ // OTHER headless runs are still genuinely active. Gating on order/panels
527
+ // alone made this block treat every single headless run's end as "the
528
+ // last one," which both cleared `aborts` for still-running siblings
529
+ // (corrupting getActiveResearchRunCount for any caller relying on it) and,
530
+ // combined with a caller that clears steering right after seeing count 0,
531
+ // could wipe a message this very block had just decided to preserve one
532
+ // statement earlier. `aborts.delete(researchId)` above already removed
533
+ // this run's own entry, so `aborts.size === 0` here means no run of any
534
+ // kind (TUI or headless) remains — the same atomic, single-synchronous-
535
+ // function guarantee `order`/`panels` already had for the TUI-only case.
536
+ if (state.order.length === 0 && state.panels.size === 0 && state.aborts.size === 0) {
523
537
  // Drop steering messages this run already consumed ('active') — and any
524
538
  // 'popped' remnants, which are likewise already resolved — before deciding
525
539
  // whether to keep the session alive. Without this, a message the run that
@@ -540,10 +554,11 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
540
554
  clearPendingRefresh(sid);
541
555
  piSessions.delete(sid);
542
556
  } else {
543
- // Clear research-specific state but preserve the session and steering
557
+ // Clear research-specific state but preserve the session and steering.
558
+ // aborts is already empty (the outer gate above requires it), so no
559
+ // separate clear is needed for it here.
544
560
  state.failures.clear();
545
561
  state.failureReasons.clear();
546
- state.aborts.clear();
547
562
  }
548
563
  }
549
564
  }
@@ -703,6 +718,22 @@ export function getPiActivePanels(piSessionId: string | undefined): ResearchPane
703
718
  return [...state.order].reverse().map(id => state.panels.get(id)!).filter(Boolean);
704
719
  }
705
720
 
721
+ /**
722
+ * Count of research runs currently active in a Pi session, TUI or headless.
723
+ *
724
+ * Unlike getPiActivePanels (populated only by registerSessionPanel, which the
725
+ * TUI path alone calls), `aborts` is registered by registerSessionAbort on
726
+ * EVERY run — headless included — and removed only by endResearchSession. Use
727
+ * this wherever "am I the last/only active run in this session" must hold for
728
+ * headless callers too (e.g. gating a shared-state clear like steering
729
+ * messages so a finishing run doesn't wipe a concurrent sibling's).
730
+ */
731
+ export function getActiveResearchRunCount(piSessionId: string | undefined): number {
732
+ const sid = normalizeSessionId(piSessionId);
733
+ const state = piSessions.get(sid);
734
+ return state ? state.aborts.size : 0;
735
+ }
736
+
706
737
  /**
707
738
  * Get the ordered list of active research run IDs in a Pi session.
708
739
  * Returns IDs in chronological order (oldest first, newest last).
@@ -18,6 +18,8 @@ Your ONLY job is to decide which candidates are genuinely relevant to the user's
18
18
  Field definitions:
19
19
  - `relevant_indices` (array of integers, required): the 0-based indices of the relevant candidates, in any order. Empty array `[]` if none are relevant.
20
20
 
21
+ <!-- USER_TURN: everything below is delivered as the USER message of the stateless call, not the system prompt. The rules above stay byte-identical across calls so the provider can serve the system prefix from its prompt cache; the sections below vary per call. -->
22
+
21
23
  ## USER'S SEARCH QUERY
22
24
 
23
25
  Judge each candidate's relevance to this search query. This is the PRIMARY signal for what the user wants; the conversation below is supplementary and may be absent for a direct (non-conversational) search:
@@ -24,6 +24,8 @@ Field definitions:
24
24
  - `synthesis` (string, optional): The synthesized answer if found. Use inline citation markers [1], [2], etc. that correspond to the citations array. Present when answer_status is "yes" or "maybe". Omit when answer_status is "no".
25
25
  - `citations` (array of strings, required): The source URLs from the reference documents that were used to construct the answer. Empty array if answer_status is "no".
26
26
 
27
+ <!-- USER_TURN: everything below is delivered as the USER message of the stateless call, not the system prompt. The rules above stay byte-identical across calls so the provider can serve the system prefix from its prompt cache; the sections below vary per call. -->
28
+
27
29
  ## USER'S SEARCH QUERY
28
30
 
29
31
  Answer this search query from the reference documents. This is the PRIMARY signal for what the user wants; the conversation below is supplementary and may be absent for a direct (non-conversational) search:
@@ -2,7 +2,7 @@
2
2
 
3
3
  <!-- RESEARCHER_AGENT_MARKER -->
4
4
 
5
- You are an autonomous research agent. Your goal is to investigate your assigned topic with depth and rigor.
5
+ You are an autonomous research agent. Your goal is to investigate your assigned topic with depth and rigor. Your specific goal, any historical knowledge-store results, your initial evidence/search results, and any sibling-coordination notes are provided in the user message that follows this system prompt — not here, so this system prompt stays byte-identical across every researcher and round (prompt caching).
6
6
 
7
7
  ## CORE DIRECTIVES (Strict Enforcement)
8
8
 
@@ -23,20 +23,10 @@ Do not treat any source as inherently objective. Justify its inclusion through t
23
23
 
24
24
  ---
25
25
 
26
- ## Your Goal
27
- {{goal}}
28
-
29
- {{store_section}}
30
- (Note: when knowledge-store results appear above, they were retrieved automatically for your goal, aggregated from the **Project Knowledge Store** and **User Knowledge Store**. Do NOT try to search the store manually.)
31
-
32
- {{evidence_section}}
33
-
34
- ---
35
-
36
26
  ## Workflow
37
27
 
38
28
  ### Step 1: Build Your Source List
39
- Combine historical URLs and search results into a unified pool. Use previous session summaries as a guide for what to expect.
29
+ Combine historical URLs and search results into a unified pool. Use previous session summaries as a guide for what to expect. Any knowledge-store results in your user message were retrieved automatically for your goal from the **Project Knowledge Store** and/or **User Knowledge Store** — cite them with that store name as their `Source:`.
40
30
 
41
31
  ### Step 2: Scrape Round 1
42
32
  Identify the 4 most promising URLs and scrape them in one batch (the scrape tool accepts up to 6 per call; 4 is your target per round). Prioritize primary sources, authoritative references, and dense documentation; all else being equal, lean toward more recent / current sources and topics.
@@ -51,8 +41,6 @@ Do NOT scrape URLs directly from the pool — only scrape URLs from your own sou
51
41
  ### Step 4: Synthesize
52
42
  Write your report immediately after scraping is complete, or as soon as a tool reports its limit is reached (e.g. "GATHERING LIMIT REACHED", "SEARCH LIMIT REACHED", "SCRAPE PROTOCOL COMPLETE"). Make no further tool calls after beginning synthesis.
53
43
 
54
- {{coordination_section}}
55
-
56
44
  ---
57
45
 
58
46
  ## Guidelines
package/src/sdk.ts CHANGED
@@ -111,6 +111,7 @@ let _onSignal: ((signal: string) => void) | null = null;
111
111
  let _sigintHandler: (() => void) | null = null;
112
112
  let _sigtermHandler: (() => void) | null = null;
113
113
  let _sighupHandler: (() => void) | null = null;
114
+ let _sigquitHandler: (() => void) | null = null;
114
115
  let _sigbreakHandler: (() => void) | null = null;
115
116
  let _shuttingDown = false;
116
117
  // In-flight shutdown guard. A cooperative shutdownResearchSDK() can race a
@@ -147,9 +148,13 @@ function _registerSignalHandlers(): void {
147
148
  _sigintHandler = () => _onSignal?.('SIGINT');
148
149
  _sigtermHandler = () => _onSignal?.('SIGTERM');
149
150
  _sighupHandler = () => _onSignal?.('SIGHUP');
151
+ _sigquitHandler = () => _onSignal?.('SIGQUIT');
150
152
  process.on('SIGINT', _sigintHandler);
151
153
  process.on('SIGTERM', _sigtermHandler);
152
154
  process.on('SIGHUP', _sighupHandler);
155
+ // SIGQUIT has no Windows equivalent; registering the listener there is a
156
+ // documented no-op (Node never delivers it), so no platform guard is needed.
157
+ process.on('SIGQUIT', _sigquitHandler);
153
158
  if (process.platform === 'win32') {
154
159
  _sigbreakHandler = () => _onSignal?.('SIGBREAK');
155
160
  process.on('SIGBREAK', _sigbreakHandler);
@@ -160,6 +165,7 @@ function _removeSignalHandlers(): void {
160
165
  if (_sigintHandler) { process.removeListener('SIGINT', _sigintHandler); _sigintHandler = null; }
161
166
  if (_sigtermHandler) { process.removeListener('SIGTERM', _sigtermHandler); _sigtermHandler = null; }
162
167
  if (_sighupHandler) { process.removeListener('SIGHUP', _sighupHandler); _sighupHandler = null; }
168
+ if (_sigquitHandler) { process.removeListener('SIGQUIT', _sigquitHandler); _sigquitHandler = null; }
163
169
  if (_sigbreakHandler) { process.removeListener('SIGBREAK', _sigbreakHandler); _sigbreakHandler = null; }
164
170
  _onSignal = null;
165
171
  }
@@ -369,6 +375,16 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
369
375
  * Public initialization. Guarantees the SDK is ready for use.
370
376
  */
371
377
  export async function initResearchSDK(options: ResearchSDKOptions = {}): Promise<void> {
378
+ // A shutdown may be in flight: isInitialized only flips to false near the END
379
+ // of _doShutdown (after container disposal), so without this a caller racing a
380
+ // shutdown would hit the isInitialized branch below, log its "already
381
+ // initialized" warning, and return believing the SDK is ready — while the
382
+ // container it would use is mid-teardown or about to be nulled out from under
383
+ // it. Wait the shutdown out first so this call observes the real post-shutdown
384
+ // state, mirroring shutdownResearchSDK's symmetric wait on _initPromise.
385
+ if (_shutdownPromise) {
386
+ await _shutdownPromise.catch(() => { /* a failed shutdown must not block a fresh init */ });
387
+ }
372
388
  if (isInitialized) {
373
389
  logger.warn('[SDK] Research SDK already initialized. Call shutdownResearchSDK() first if you want to re-initialize with new options.');
374
390
  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 {
@@ -6,7 +6,8 @@
6
6
  *
7
7
  * NOTE: `research_knowledge_search` is NOT included here. It is a top-level tool
8
8
  * registered via pi.registerTool() in src/index.ts. Researcher sub-agents
9
- * already receive knowledge store data via the store_section prompt injection.
9
+ * already receive knowledge-store data in their initial user message (see
10
+ * researcher-executor.ts / quick-research-orchestrator.ts).
10
11
  */
11
12
 
12
13
  import type { ToolDefinition, ExtensionContext } from '@earendil-works/pi-coding-agent';
@@ -427,6 +427,27 @@ export function isTransientSynthesisError(message: string): boolean {
427
427
  );
428
428
  }
429
429
 
430
+ /**
431
+ * Split a knowledge-search prompt template at its USER_TURN marker into the
432
+ * static system-prompt part (the rules, byte-identical across calls so the
433
+ * provider can serve it from its prompt cache) and the per-call user-message
434
+ * part (where the {{...}} placeholders live). If the marker is absent —
435
+ * a shipped-file regression — returns the whole template as `system` and
436
+ * `user: null`, and the caller falls back to the legacy all-in-system shape,
437
+ * which is functionally correct (just uncacheable).
438
+ * Exported for unit testing.
439
+ */
440
+ export function splitPromptAtUserTurn(template: string): { system: string; user: string | null } {
441
+ const open = template.indexOf('<!-- USER_TURN');
442
+ if (open < 0) return { system: template, user: null };
443
+ const close = template.indexOf('-->', open);
444
+ if (close < 0) return { system: template, user: null };
445
+ return {
446
+ system: template.slice(0, open).trimEnd(),
447
+ user: template.slice(close + '-->'.length).trimStart(),
448
+ };
449
+ }
450
+
430
451
  /** Exported for unit testing of the transient-retry loop. */
431
452
  export async function runBackgroundExtraction(
432
453
  model: Model<any>,
@@ -452,13 +473,20 @@ export async function runBackgroundExtraction(
452
473
  // plain string replacement interprets `$&`, `` $` ``, `$'`, `$$` as substitution patterns and
453
474
  // corrupts the extractor prompt (duplicating/eating template text) → wrong yes/maybe/no
454
475
  // classification. A function replacer inserts the value literally. (Same class as commit 78c16f98.)
455
- const systemPrompt = promptTemplate
476
+ //
477
+ // The template splits at USER_TURN: the static rules become the system prompt
478
+ // (byte-identical across calls → prompt-cache reads instead of a fresh cache
479
+ // write per call) and the per-call sections go in the user message.
480
+ const { system, user } = splitPromptAtUserTurn(promptTemplate);
481
+ const fill = (t: string): string => t
456
482
  .replace('{{queries}}', () => formatQueriesBlock(queries))
457
483
  .replace('{{conversation_history}}', () => conversationHistory)
458
484
  .replace('{{reference_documents}}', () => referenceDocuments);
459
485
 
460
- const userMessage =
486
+ const finalInstruction =
461
487
  'Analyze the reference documents above and extract the answer using the required JSON format.';
488
+ const systemPrompt = user === null ? fill(system) : system;
489
+ const userMessage = user === null ? finalInstruction : `${fill(user)}\n\n${finalInstruction}`;
462
490
 
463
491
  // Phase 4a: Stateless LLM call — no AgentSession, no side-effects.
464
492
  // llmTimeout is resolved by the caller from the iface-aware Config so a
@@ -611,12 +639,17 @@ export async function triageRelevantUrls(
611
639
 
612
640
  // FUNCTION replacers so literal `$`/`$&` in queries, descriptions or history are not
613
641
  // treated as regex substitution patterns (same class as the synthesis prompt).
614
- const systemPrompt = promptTemplate
642
+ // Split at USER_TURN like the extractor: static rules stay in the system prompt
643
+ // (cacheable across calls), per-call sections go in the user message.
644
+ const { system, user } = splitPromptAtUserTurn(promptTemplate);
645
+ const fill = (t: string): string => t
615
646
  .replace('{{queries}}', () => formatQueriesBlock(queries))
616
647
  .replace('{{conversation_history}}', () => conversationHistory)
617
648
  .replace('{{candidates}}', () => candidateBlock);
618
649
 
619
- const userMessage = 'Return the JSON object listing the indices of the relevant candidates.';
650
+ const finalInstruction = 'Return the JSON object listing the indices of the relevant candidates.';
651
+ const systemPrompt = user === null ? fill(system) : system;
652
+ const userMessage = user === null ? finalInstruction : `${fill(user)}\n\n${finalInstruction}`;
620
653
 
621
654
  let responseText: string;
622
655
  let attempt = 0;
@@ -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,11 +29,12 @@ 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 } 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';
35
36
  import { HeadlessObserver } from '../orchestration/headless-observer.ts';
37
+ import { isPromptCachingActiveForModel } from '../core/llm/research-model-resolver.ts';
36
38
 
37
39
  import { ensureFunctionalHealth, createHealthMonitor } from '../tui/research-health.ts';
38
40
  import { ErrorTracker, runWithTracker, type ErrorReport } from '../utils/error-tracker.ts';
@@ -118,6 +120,21 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
118
120
 
119
121
  type ResearchParams = Static<typeof parameters>;
120
122
 
123
+ // Same field shapes as `parameters`, but WITHOUT the initialLinks count/length
124
+ // bounds — used only for the runtime execute() gate below. validateInitialLinks
125
+ // already enforces those same two bounds with a specific, friendly message
126
+ // ("at most N URLs...", "at most N characters..."); checking them again here
127
+ // would just produce the generic 'invalid_parameters' rejection first and mask
128
+ // that better message. Structural type-checking (initialLinks must still be an
129
+ // array of strings) is kept, since validateInitialLinks assumes that shape.
130
+ const executeShapeCheck = Type.Object({
131
+ query: Type.String(),
132
+ depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 3 })),
133
+ model: Type.Optional(Type.String()),
134
+ excludeTools: Type.Optional(Type.Array(Type.String())),
135
+ initialLinks: Type.Optional(Type.Array(Type.String())),
136
+ });
137
+
121
138
  return {
122
139
  name: 'research',
123
140
  label: 'Research',
@@ -169,6 +186,17 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
169
186
  };
170
187
  }
171
188
 
189
+ // Every other tool in this codebase re-validates params against its own
190
+ // schema before trusting it (the host does not enforce this) — this was the
191
+ // one exception, relying entirely on incidental downstream checks (a
192
+ // typeof on query, a warn-gate on excludeTools) for fields that skip both.
193
+ if (!Value.Check(executeShapeCheck, params)) {
194
+ return {
195
+ content: [{ type: 'text', text: 'Error: invalid parameters for research tool.' }],
196
+ details: { error: 'invalid_parameters' },
197
+ };
198
+ }
199
+
172
200
  const { query, depth: rawDepth, model: modelId, excludeTools: paramExcludeTools, initialLinks } = params as ResearchParams;
173
201
 
174
202
  // Enforce the same bounds the CLI applies to --initial-links: these links are
@@ -225,17 +253,21 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
225
253
  if (observerState && panelState) {
226
254
  try { stopObserverWaveAnimation(observerState, panelState); } catch (e) { logger.error('[research] Stop wave animation failed:', e); }
227
255
  }
228
- // Steering messages are shared per pi-session. Only reset them when THIS is
229
- // 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.
233
- try {
234
- if (getPiActivePanels(piSessionId).length <= 1) clearSteeringMessages(piSessionId);
235
- } catch (e) { logger.error('[research] Clear steering messages failed:', e); }
236
256
  if (detachAbortListener) {
237
257
  try { detachAbortListener(); } catch { /* best-effort */ } finally { detachAbortListener = undefined; }
238
258
  }
259
+ // cleanup() calls endResearchSession (directly in the headless closure,
260
+ // or via createCleanupFunction for TUI), which deregisters this run and,
261
+ // in the same synchronous call, decides whether it was the last active
262
+ // run (TUI or headless) in the pi-session and — only then — resets
263
+ // steering messages (preserving any still-genuinely-queued ones for the
264
+ // next run, dropping only ones this run already consumed). That single
265
+ // synchronous function is the right place for this decision: a separate
266
+ // check-then-clear here, even ordered after cleanup(), raced ahead of or
267
+ // fought with endResearchSession's own last-run/preserve logic — either
268
+ // reading stale counts before two racing siblings had both deregistered,
269
+ // or unconditionally wiping a message endResearchSession had just decided
270
+ // to preserve. See endResearchSession in session-state.ts.
239
271
  if (cleanup) {
240
272
  try { await cleanup(); } catch (cleanupError) { logger.error('[research] Cleanup failed:', cleanupError); }
241
273
  }
@@ -328,7 +360,16 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
328
360
  // Setup TUI observer
329
361
  observerState = createObserverState();
330
362
  observer = createResearchObserver(
331
- { panelState, debouncedRefresh: () => tuiManager?.debouncedRefresh(), renderImmediate: () => tuiManager?.renderImmediate(), researchComplexity: depth ?? 1 },
363
+ {
364
+ panelState,
365
+ debouncedRefresh: () => tuiManager?.debouncedRefresh(),
366
+ renderImmediate: () => tuiManager?.renderImmediate(),
367
+ researchComplexity: depth ?? 1,
368
+ // Progress units are relative to the EFFECTIVE scrape-batch count,
369
+ // which is one higher when prompt caching is active for the run's
370
+ // resolved model — same value the researchers' trackers enforce.
371
+ cachingActive: isPromptCachingActiveForModel(selectedModel),
372
+ },
332
373
  observerState
333
374
  );
334
375
  } else {
@@ -344,9 +385,10 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
344
385
  // module-level piSessions map keyed by the STABLE pi session id, so
345
386
  // without this every headless run leaked them for the process
346
387
  // lifetime (the TUI branch frees them via createCleanupFunction, the
347
- // SDK in its own finally; this branch freed only steering).
388
+ // SDK in its own finally). endResearchSession itself decides whether
389
+ // this was the last active run (TUI or headless) and, only then,
390
+ // resets steering messages — see its own comment in session-state.ts.
348
391
  endResearchSession(piSessionId, sessionResearchId);
349
- clearSteeringMessages(piSessionId);
350
392
  };
351
393
 
352
394
  // Minimal stub — only stopObserverWaveAnimation touches panelState in the
@@ -49,7 +49,14 @@ export function createScrapeTool(options: {
49
49
  config?: Config;
50
50
  }): ToolDefinition {
51
51
  const config = options.config || getConfig(options.ctx.cwd);
52
- const maxScrapeBatches = getMaxScrapeBatches(config);
52
+ // Prefer the tracker's own scrape limit when one is present: it was built via
53
+ // createDefaultToolLimits(config, cachingActive) and so already reflects any
54
+ // prompt-caching batch bonus (see getMaxScrapeBatches in constants.ts). Without
55
+ // this, the tool's own description/promptSnippet/protocol text (below) told the
56
+ // model a lower, non-caching-aware batch count than the tracker actually
57
+ // enforced — a real, if harmless, missed-opportunity: the model would stop one
58
+ // batch short of what it was actually allowed to use.
59
+ const maxScrapeBatches = options.tracker?.getToolLimit('scrape') ?? getMaxScrapeBatches(config);
53
60
  const container = tryGetServiceContainerFromCtx(options.ctx);
54
61
 
55
62
  // Fallback global state when no orchestration context is provided
@@ -98,16 +98,6 @@ export function createSecuritySearchTool(options: {
98
98
  };
99
99
  }
100
100
 
101
- // Record call in tracker - returns false if limit reached
102
- const allowed = options.tracker.recordCall('security_search');
103
- if (!allowed) {
104
- metrics.increment('tool_security_search_calls_total', 1, { status: 'rate_limited' });
105
- return {
106
- content: [{ type: 'text', text: options.tracker.getLimitMessage('security_search') }],
107
- details: { blocked: true, reason: 'limit_reached' },
108
- };
109
- }
110
-
111
101
  const p = params as SecuritySearchParamsType;
112
102
 
113
103
  const terms = p.terms;
@@ -160,6 +150,19 @@ export function createSecuritySearchTool(options: {
160
150
  }
161
151
  const maxResults = p.maxResults ?? 20;
162
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
+
163
166
  metrics.increment('tool_security_search_terms_total', terms.length);
164
167
  metrics.increment('tool_security_search_databases_total', databases.length);
165
168
 
@@ -109,7 +109,7 @@ export interface SplitReport {
109
109
  * better than one that reaches it short.
110
110
  */
111
111
  export function splitCoverageDigest(report: string): SplitReport {
112
- if (!report) return { digest: '', body: report ?? '' };
112
+ if (!report) return { digest: '', body: report };
113
113
 
114
114
  const headerMatch = delimiterRegex(COVERAGE_DIGEST_HEADER).exec(report);
115
115
  if (!headerMatch) return { digest: '', body: report };
@@ -27,11 +27,22 @@ let _configDirName: string | undefined;
27
27
  * Resolution order: explicit env override (`PI_RESEARCH_CONFIG_DIR_NAME`) →
28
28
  * the host `@earendil-works/pi-coding-agent` package (only where it is actually
29
29
  * resolvable, i.e. the main pi process) → the stable default `.pi`.
30
+ *
31
+ * The override is a directory NAME, not a path: it is joined onto
32
+ * `os.homedir()` by every caller (getGlobalConfigDir() in config.ts), which in
33
+ * turn is the base for config.env (API keys, chmod 0600), the state dir
34
+ * (locks, run semaphores), and the knowledge_db. A value containing a path
35
+ * separator or `..` traverses out of HOME entirely — scripts/cleanup.cjs
36
+ * already rejects exactly that in its own copy of this resolution (see its
37
+ * `rawDirName`/`dirName` guard); this must reject it the same way, since this
38
+ * is the copy every actual run resolves secrets and state through.
30
39
  */
31
40
  export function getConfigDirName(): string {
32
41
  if (_configDirName !== undefined) return _configDirName;
33
42
  const override = process.env['PI_RESEARCH_CONFIG_DIR_NAME'];
34
- if (override) return (_configDirName = override);
43
+ if (override && !override.includes('/') && !override.includes('\\') && override !== '.' && override !== '..') {
44
+ return (_configDirName = override);
45
+ }
35
46
  try {
36
47
  const require = createRequire(import.meta.url);
37
48
  const host = require('@earendil-works/pi-coding-agent') as { CONFIG_DIR_NAME?: string };
@@ -37,8 +37,11 @@ export function buildDefaultDebugLogPath(): string {
37
37
  /**
38
38
  * Check whether debug/verbose logging is enabled.
39
39
  *
40
- * Reads PI_RESEARCH_DEBUG env var. This env var is kept in sync with
41
- * config.DEBUG whenever the config is loaded or saved (see config.ts).
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
42
+ * process.env, so a DEBUG=true line in config.env enables verbose logging for
43
+ * a process only after a save has run in it — export PI_RESEARCH_DEBUG=true
44
+ * in the environment to guarantee it from process start.
42
45
  * Set to "true" to enable INFO+DEBUG logs.
43
46
  */
44
47
  export function isVerboseFromEnv(): boolean {
@@ -255,6 +258,16 @@ export function redactSecrets(message: string): string {
255
258
  // never negative; it still prefers the larger rawLength when the
256
259
  // REDACT_SCAN_LENGTH cut discarded raw content redaction then shrank.
257
260
  out = `${out.slice(0, keep)}…[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
261
+ } else if (rawLength > scanEnd) {
262
+ // The scanEnd cut (line 220) already dropped `rawLength - scanEnd` raw
263
+ // chars before redaction ever ran. If redaction then net-shrinks the
264
+ // scanned prefix enough that `out.length` no longer exceeds
265
+ // MAX_LOG_MESSAGE_LENGTH (e.g. many long hex/JWT runs each collapsed to
266
+ // the short literal '[REDACTED]'), the branch above never fires and this
267
+ // message would otherwise read as complete — silently hiding the tail of
268
+ // a large scraped page or provider error body with no indication
269
+ // anything was cut.
270
+ out = `${out}…[truncated ${rawLength - scanEnd} chars]`;
258
271
  }
259
272
  return out;
260
273
  }
@@ -48,6 +48,12 @@ export interface ResearchStats {
48
48
  errors: number;
49
49
  /** Total LLM tokens consumed. */
50
50
  tokens: number;
51
+ /** Prompt-cache READ tokens (prompt prefix served from the provider's cache at the
52
+ * discounted rate). 0 when the provider reported none or does not report caching. */
53
+ cacheReadTokens: number;
54
+ /** Prompt-cache WRITE tokens (prefix stored into the provider's cache, billed at a
55
+ * premium). Reads pinned at 0 while writes climb = a caching regression. */
56
+ cacheWriteTokens: number;
51
57
  /** Total cost in USD across all LLM calls. */
52
58
  cost: number;
53
59
  /** Tool usage counts. */
@@ -193,6 +199,12 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
193
199
  // Tokens — sum all label variants
194
200
  const tokens = sumCounter(counters, 'llm_tokens_total');
195
201
 
202
+ // Prompt-cache split (recordLlmUsage emits these only when the provider reports
203
+ // the fields, so 0 here covers both "cache off" and "not reported" — the raw
204
+ // counters keep those distinguishable for anyone reading the snapshot directly).
205
+ const cacheReadTokens = sumCounter(counters, 'llm_cache_read_tokens_total');
206
+ const cacheWriteTokens = sumCounter(counters, 'llm_cache_write_tokens_total');
207
+
196
208
  // Cost — sum all label variants
197
209
  const cost = sumCounter(counters, 'llm_cost_total');
198
210
 
@@ -251,6 +263,8 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
251
263
  urlsFailed,
252
264
  errors,
253
265
  tokens,
266
+ cacheReadTokens,
267
+ cacheWriteTokens,
254
268
  cost,
255
269
  toolUsage,
256
270
  };
@@ -315,7 +329,13 @@ export function buildResearchSummary(stats: ResearchStats): string {
315
329
  // --- Resource line: tokens, cost, duration ---
316
330
  const resourceParts: string[] = [];
317
331
  if (stats.tokens > 0) {
318
- resourceParts.push(`**${formatTokens(stats.tokens)}** tokens`);
332
+ // When prompt caching did real work, say so inline: cache reads are prompt
333
+ // tokens billed at the provider's discounted cached rate, and this is the
334
+ // only user-visible confirmation that caching is active for their model.
335
+ const cached = stats.cacheReadTokens > 0
336
+ ? ` (**${formatTokens(stats.cacheReadTokens)}** from cache)`
337
+ : '';
338
+ resourceParts.push(`**${formatTokens(stats.tokens)}** tokens${cached}`);
319
339
  }
320
340
  // Render the cost whenever tokens were actually billed-through, INCLUDING when it
321
341
  // is exactly zero. Gating on `cost > 0` hid a real defect for weeks: a model whose