@lincoln504/pi-research 1.4.3 → 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 (41) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +192 -45
  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 +1 -1
  7. package/docs/AGENT-SKILL.md +4 -3
  8. package/docs/CHANGELOG.md +45 -1
  9. package/docs/CONFIGURATION.md +1 -1
  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 +26 -9
  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 +12 -1
  18. package/src/index.ts +3 -1
  19. package/src/infrastructure/browser/thread-worker-messaging.ts +5 -1
  20. package/src/infrastructure/browser/thread-worker.mjs +1 -1
  21. package/src/infrastructure/embedding/embedding-server.ts +29 -9
  22. package/src/knowledge/store.ts +60 -4
  23. package/src/observers/research-observer-impl.ts +14 -6
  24. package/src/orchestration/quick-research-orchestrator.ts +29 -10
  25. package/src/orchestration/research-synthesis-service.ts +32 -0
  26. package/src/orchestration/researcher-executor.ts +31 -16
  27. package/src/orchestration/researcher.ts +2 -2
  28. package/src/orchestration/session-state.ts +19 -4
  29. package/src/prompts/research-knowledge-relevance-triage.md +2 -0
  30. package/src/prompts/research-knowledge-search-extractor.md +2 -0
  31. package/src/prompts/researcher.md +2 -14
  32. package/src/sdk.ts +6 -0
  33. package/src/tools/index.ts +2 -1
  34. package/src/tools/research-knowledge-search.ts +37 -4
  35. package/src/tools/research-tool-definition.ts +27 -16
  36. package/src/tools/scrape.ts +8 -1
  37. package/src/utils/log-utils.ts +5 -2
  38. package/src/utils/metrics-summary.ts +21 -1
  39. package/src/utils/tool-usage-tracker.ts +6 -3
  40. package/src/web-research/web-scraper.ts +9 -0
  41. package/src/youtube/index.ts +11 -1
@@ -19,6 +19,7 @@ import { getConfig, type Config } from '../config.ts';
19
19
  import { createResearcherSession } from './researcher.ts';
20
20
  import { ensureAssistantResponse, parseCitations } from '../utils/text-utils.ts';
21
21
  import { getMaxScrapeBatches, resolveExcludedTools } from '../constants.ts';
22
+ import { resolveResearchModel, isPromptCachingActiveForModel } from '../core/llm/research-model-resolver.ts';
22
23
  import type { ResearchObserver } from '../core/interfaces/observer-interfaces.ts';
23
24
  import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
24
25
  import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
@@ -133,7 +134,15 @@ export class QuickResearchOrchestrator {
133
134
  }
134
135
 
135
136
  const researcherPromptTemplate = loadPrompt('researcher');
136
- const maxScrapeBatches = getMaxScrapeBatches(this.config);
137
+ // Resolve the model here (same deterministic inputs createResearcherSession
138
+ // below will resolve again) purely to learn whether prompt caching will be
139
+ // active, so the batch count told to the model in the prompt below matches
140
+ // the limit ToolUsageTracker actually enforces — resolveResearchModel is a
141
+ // pure, cheap lookup over already-loaded registries, safe to call twice.
142
+ const cachingActiveForBudget = isPromptCachingActiveForModel(
143
+ resolveResearchModel({ modelRegistry: ctx.modelRegistry, config: this.config, hostModel: model, cwd: ctx.cwd })
144
+ );
145
+ const maxScrapeBatches = getMaxScrapeBatches(this.config, cachingActiveForBudget);
137
146
  const maxScrapeBatchesDisplay = maxScrapeBatches > 99 ? 'unlimited' : maxScrapeBatches.toString();
138
147
 
139
148
  const quickEvidenceSection =
@@ -162,19 +171,29 @@ export class QuickResearchOrchestrator {
162
171
  }
163
172
  evidenceLines.push(quickEvidenceSection);
164
173
 
174
+ // The system prompt carries only run-invariant instructions; the goal,
175
+ // knowledge-store results, evidence, and steering go in the initial USER
176
+ // message below. A byte-identical system prompt + tool set is the cacheable
177
+ // prefix providers key on, so back-to-back quick runs (and every follow-up
178
+ // turn within this session) reuse it via prompt-cache reads.
165
179
  const prompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
166
- // Function replacers: insert `$`-bearing dynamic content (scraped evidence,
167
- // store descriptions, the user query) literally, not as a substitution pattern.
168
- .replace('{{goal}}', () => query + steeringSection)
169
- .replace('{{store_section}}', () => storeSection)
170
- .replace('{{evidence_section}}', () => evidenceLines.join('\n\n'))
171
- .replace('{{coordination_section}}', '')
172
180
  .replace('{{extra_tool_guidelines}}', '- `search`: Perform broad web searches (Round 1 only).')
173
181
  // Quick research has no research lead and no next round — see
174
182
  // RESEARCHER_DIGEST_SECTION. Its report IS the deliverable.
175
- .replace('{{digest_section}}', '');
183
+ .replace('{{digest_section}}', '')
184
+ .trim();
176
185
 
177
- logger.debug(`[QuickOrchestrator] System Prompt:\n${prompt}`);
186
+ // `$`-bearing dynamic content (the user query, store descriptions, steering)
187
+ // is plain string concatenation here — no String.replace substitution risk.
188
+ const userMessage = [
189
+ `Goal: ${query}`,
190
+ storeSection.trim(),
191
+ evidenceLines.join('\n\n').trim(),
192
+ steeringSection.trim(),
193
+ 'Perform your research and submit your full report now.',
194
+ ].filter(Boolean).join('\n\n');
195
+
196
+ logger.debug(`[QuickOrchestrator] System Prompt:\n${prompt}\n\nUser Message:\n${userMessage}`);
178
197
 
179
198
  let lastSeenSearchCount = 0;
180
199
  const { session, resolvedModel } = await createResearcherSession({
@@ -287,7 +306,7 @@ export class QuickResearchOrchestrator {
287
306
  // whether the race resolves via session.prompt, timeout, or abort.
288
307
  let abortCleanup: (() => void) | undefined;
289
308
  try {
290
- const promptPromise = session.prompt(query);
309
+ const promptPromise = session.prompt(userMessage);
291
310
  promptPromise.catch((err: Error) => logger.debug(`[QuickOrchestrator] Background session prompt rejection: ${err.message}`));
292
311
  await Promise.race([
293
312
  promptPromise,
@@ -34,6 +34,22 @@ const NO_SOURCES_NOTE =
34
34
  const UNVERIFIED_LINKS_NOTE =
35
35
  '_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented — check each one before relying on it._';
36
36
 
37
+ // Below this many verified sources, a completed report is WEAKLY grounded: it will
38
+ // still read as a fully sourced document, but almost none of its claims can trace to
39
+ // retrieved content — exactly the shape of a run where scrapes mostly failed (bot
40
+ // blocks, resource starvation) or the topic had little real coverage, and the model
41
+ // filled the gaps from its own weights. Observed in production: a depth-1 run whose
42
+ // report cited ONE page shipped with no caveat and fabricated most of its specifics;
43
+ // the calling agent had to detect the degradation itself. The note gives readers
44
+ // (human or agent) the machine-visible signal that was missing.
45
+ const WEAK_GROUNDING_MIN_SOURCES = 3;
46
+
47
+ function weakGroundingNote(count: number): string {
48
+ return `_Grounding notice: only ${count} source${count === 1 ? '' : 's'} could be verified for this report. ` +
49
+ 'Claims not attributable to the cited source' + (count === 1 ? '' : 's') +
50
+ ' above should be treated as unverified — cross-check anything load-bearing before relying on it._';
51
+ }
52
+
37
53
 
38
54
  /**
39
55
  * Redact inline `https?://` tokens in synthesis prose whose URL was NOT retrieved
@@ -382,6 +398,16 @@ export class ResearchSynthesisService implements IService {
382
398
  // fabricated source can still reach the user; inline URLs also violate the
383
399
  // required format. Verified inline references (in `trustedUrls`) are kept.
384
400
  body = redactUnverifiedProseUrls(body, trustedUrls);
401
+ if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
402
+ // The notice goes BEFORE the CITED LINKS header, never after it: anything
403
+ // trailing the last citation entry is absorbed into that entry's
404
+ // Description by parseCitations, and quick mode re-parses this exact
405
+ // string to build knowledge-store entries — a notice placed after the
406
+ // list polluted the stored description of the final source on precisely
407
+ // the degraded runs the notice exists to flag.
408
+ logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} verified source(s) — appending grounding notice.`);
409
+ return `${body}\n\n${weakGroundingNote(globalCitations.length)}\n\n${verifiedLinksSection}`;
410
+ }
385
411
  return `${body}\n\n${verifiedLinksSection}`;
386
412
  }
387
413
  logger.warn('[ResearchSynthesisService] Synthesis missing CITED LINKS - appending verified version');
@@ -393,6 +419,12 @@ export class ResearchSynthesisService implements IService {
393
419
  const validIds = new Set(globalCitations.map((c) => c.id));
394
420
  let prose = rewriteCitationMarkers(synthesis.trim(), new Map(), (n) => !validIds.has(n));
395
421
  prose = redactUnverifiedProseUrls(prose, trustedUrls);
422
+ if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
423
+ // Before the CITED LINKS header for the same parseCitations-absorption
424
+ // reason as the replace path above.
425
+ logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} verified source(s) — appending grounding notice.`);
426
+ return `${prose}\n\n${weakGroundingNote(globalCitations.length)}\n\n${verifiedLinksSection}`;
427
+ }
396
428
  return `${prose}\n\n${verifiedLinksSection}`;
397
429
  }
398
430
 
@@ -180,6 +180,20 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
180
180
  const researcherExecutionStartMs = Date.now();
181
181
  const deliveredSteeringIds = new Set<string>();
182
182
 
183
+ // The system prompt is deliberately invariant across researchers, rounds, AND
184
+ // retry attempts: everything per-researcher (goal, knowledge-store results,
185
+ // initial evidence, sibling coordination, steering) is delivered in the initial
186
+ // USER message instead. A byte-identical system prompt + tool set is the
187
+ // cacheable prefix providers key on, so every researcher after the first gets a
188
+ // prompt-cache read on that prefix rather than a fresh write. Built once, out
189
+ // of the retry loop, because nothing in it varies per attempt.
190
+ const researcherSystemPrompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
191
+ .replace('{{extra_tool_guidelines}}', '')
192
+ // Function replacer so `$`-bearing content is inserted literally, not as a
193
+ // String.replace substitution pattern.
194
+ .replace('{{digest_section}}', () => RESEARCHER_DIGEST_SECTION)
195
+ .trim();
196
+
183
197
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
184
198
  // Launch boundary: never build a session for a run that is already cancelled
185
199
  // or fast-stopped. The abort sentinel keeps the wrapper's clean-cancel
@@ -221,30 +235,31 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
221
235
  }
222
236
  }
223
237
 
224
- // Generate prompt for this attempt, incorporating ALL steering messages delivered so far
238
+ // Build this attempt's initial USER message: the researcher's assignment plus
239
+ // every per-researcher section that used to live in the system prompt (goal,
240
+ // knowledge-store results, initial evidence, sibling coordination), and ALL
241
+ // steering messages delivered so far. Rebuilt per attempt so a retry picks up
242
+ // steering that arrived between attempts; the system prompt above never changes.
225
243
  const allSteering = getSteeringMessages(sessionId);
226
244
  let steeringSection = '';
227
245
  if (allSteering.length > 0) {
228
- steeringSection = '\n\n### ADDITIONAL USER GUIDANCE (Mandatory directional rules for your research)\n' +
246
+ steeringSection = '### ADDITIONAL USER GUIDANCE (Mandatory directional rules for your research)\n' +
229
247
  allSteering.map(m => {
230
248
  deliveredSteeringIds.add(m.id);
231
249
  return `- ${m.text}`;
232
250
  }).join('\n');
233
251
  }
234
252
 
235
- const prompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
236
- // Function replacers so a `$`-bearing dynamic value (scraped evidence, store
237
- // descriptions, or an LLM-authored goal containing $&, $$, $1, …) is inserted
238
- // literally instead of being interpreted as a String.replace substitution pattern.
239
- .replace('{{goal}}', () => researcherConfig.goal)
240
- .replace('{{store_section}}', () => storeSection)
241
- .replace('{{evidence_section}}', () => evidenceSection)
242
- .replace('{{coordination_section}}', () => previousQueriesSection)
243
- .replace('{{extra_tool_guidelines}}', '')
244
- .replace('{{digest_section}}', () => RESEARCHER_DIGEST_SECTION)
245
- .trim() + steeringSection;
253
+ const userMessage = [
254
+ `Topic: ${researcherConfig.name}\nGoal: ${researcherConfig.goal}`,
255
+ storeSection.trim(),
256
+ evidenceSection.trim(),
257
+ previousQueriesSection.trim(),
258
+ steeringSection,
259
+ 'Perform your research and submit your full report now.',
260
+ ].filter(Boolean).join('\n\n');
246
261
 
247
- logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:\n${prompt}`);
262
+ logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:\n${researcherSystemPrompt}\n\nUser Message:\n${userMessage}`);
248
263
 
249
264
  const workerExclude = ['search'];
250
265
  const mergedExclude = [...new Set([...workerExclude, ...(options.excludeTools || [])])];
@@ -275,7 +290,7 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
275
290
  cwd: ctx.cwd,
276
291
  ctxModel: model,
277
292
  modelRegistry: ctx.modelRegistry,
278
- systemPrompt: prompt,
293
+ systemPrompt: researcherSystemPrompt,
279
294
  extensionCtx: ctx,
280
295
  excludeTools: mergedExclude,
281
296
  researcherId: id,
@@ -434,7 +449,7 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
434
449
 
435
450
  let abortCleanup: (() => void) | undefined;
436
451
  try {
437
- const promptPromise = session.prompt(`Topic: ${researcherConfig.name}\nGoal: ${researcherConfig.goal}\n\nPerform your research and submit your full report now.`);
452
+ const promptPromise = session.prompt(userMessage);
438
453
  promptPromise.catch((err: Error) => logger.debug(`[ResearcherExecutor] Background session prompt rejection: ${err.message}`));
439
454
  await Promise.race([
440
455
  promptPromise,
@@ -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
  }
@@ -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
  }
@@ -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;
@@ -29,11 +29,12 @@ import { redactSecrets } from '../utils/log-utils.ts';
29
29
  import { exportResearchReport, appendExportMessage } from '../utils/research-export.ts';
30
30
  import { validateAndSanitizeQuery } from '../utils/input-validation.ts';
31
31
  import { validateInitialLinks, MAX_INITIAL_LINKS, MAX_INITIAL_LINK_CHARS } from '../utils/url-utils.ts';
32
- import { startResearchSession, endResearchSession, registerSessionAbort, clearSteeringMessages, getActiveResearchRunCount } from '../orchestration/session-state.ts';
32
+ import { startResearchSession, endResearchSession, registerSessionAbort } from '../orchestration/session-state.ts';
33
33
  import { createResearchTuiManager, hideWorkingIndicator } from '../tui/research-tui-manager.ts';
34
34
  import { createCleanupFunction } from '../cleanup/research-cleanup.ts';
35
35
  import { createResearchObserver, createObserverState, stopObserverWaveAnimation } from '../observers/research-observer-impl.ts';
36
36
  import { HeadlessObserver } from '../orchestration/headless-observer.ts';
37
+ import { isPromptCachingActiveForModel } from '../core/llm/research-model-resolver.ts';
37
38
 
38
39
  import { ensureFunctionalHealth, createHealthMonitor } from '../tui/research-health.ts';
39
40
  import { ErrorTracker, runWithTracker, type ErrorReport } from '../utils/error-tracker.ts';
@@ -252,19 +253,21 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
252
253
  if (observerState && panelState) {
253
254
  try { stopObserverWaveAnimation(observerState, panelState); } catch (e) { logger.error('[research] Stop wave animation failed:', e); }
254
255
  }
255
- // Steering messages are shared per pi-session. Only reset them when THIS is
256
- // the last active run — otherwise a finishing run wipes a concurrent sibling
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.
262
- try {
263
- if (getActiveResearchRunCount(piSessionId) <= 1) clearSteeringMessages(piSessionId);
264
- } catch (e) { logger.error('[research] Clear steering messages failed:', e); }
265
256
  if (detachAbortListener) {
266
257
  try { detachAbortListener(); } catch { /* best-effort */ } finally { detachAbortListener = undefined; }
267
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.
268
271
  if (cleanup) {
269
272
  try { await cleanup(); } catch (cleanupError) { logger.error('[research] Cleanup failed:', cleanupError); }
270
273
  }
@@ -357,7 +360,16 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
357
360
  // Setup TUI observer
358
361
  observerState = createObserverState();
359
362
  observer = createResearchObserver(
360
- { 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
+ },
361
373
  observerState
362
374
  );
363
375
  } else {
@@ -373,10 +385,9 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
373
385
  // module-level piSessions map keyed by the STABLE pi session id, so
374
386
  // without this every headless run leaked them for the process
375
387
  // lifetime (the TUI branch frees them via createCleanupFunction, the
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.
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.
380
391
  endResearchSession(piSessionId, sessionResearchId);
381
392
  };
382
393
 
@@ -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
@@ -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 {
@@ -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
@@ -195,12 +195,15 @@ export class ToolUsageTracker {
195
195
  }
196
196
 
197
197
  /**
198
- * Create default tool limits for a researcher
198
+ * Create default tool limits for a researcher.
199
+ *
200
+ * @param cachingActive - Whether prompt caching is active for this run's
201
+ * resolved model; see getMaxScrapeBatches in constants.ts.
199
202
  */
200
- export function createDefaultToolLimits(config?: Config): ToolLimits {
203
+ export function createDefaultToolLimits(config?: Config, cachingActive = false): ToolLimits {
201
204
  return {
202
205
  gathering: getMaxGatheringCalls(config),
203
- scrape: getMaxScrapeBatches(config),
206
+ scrape: getMaxScrapeBatches(config, cachingActive),
204
207
  search: 1,
205
208
  youtube_transcript: 1,
206
209
  grep: MAX_GREP_CALLS,