@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
package/README.md CHANGED
@@ -64,7 +64,7 @@ Three depth levels (normal, deep, ultra) set the team size and number of rounds.
64
64
  ### Why pi-research
65
65
 
66
66
  - **No quota and no monthly fee.** Most AI search providers cap free searches and then charge, with results served from their index on their servers. pi-research searches and scrapes locally through DuckDuckGo in a stealth browser.
67
- - **You own your index.** Every finding is written to a local [LanceDB](https://lancedb.com) knowledge store that seeds future runs, so repeat questions get faster and cheaper answers.
67
+ - **You own your index.** Every finding can be saved to a local [LanceDB](https://lancedb.com) knowledge store that seeds future runs, so repeat questions get faster and cheaper answers.
68
68
  - **Cited reports rather than raw dumps.** You get one sourced Markdown report instead of raw web content pasted into the conversation. Context efficient and easy to verify.
69
69
  - **Read-only by design.** The research agent cannot run shell commands or write, edit, or delete anything. It can read files, so treat it as you would any read-only agent with access to your machine.
70
70
  - **Search a little or a lot.** Depth levels span a quick pass to a large-scale investigation.
package/dist/cli.mjs CHANGED
@@ -2643,10 +2643,10 @@ function loadEnvFiles(cwd, iface) {
2643
2643
  try {
2644
2644
  logger.warn(`[config] WARNING: .pi-research.env files are deprecated. Settings will be auto-migrated to the centralized registry. Remove ${legacyPath} after migration.`);
2645
2645
  legacyEnv = parseDotEnv(fs5.readFileSync(legacyPath, "utf-8"));
2646
- Object.assign(merged, legacyEnv);
2647
2646
  const legacyLocal = Object.fromEntries(
2648
2647
  Object.entries(legacyEnv).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
2649
2648
  );
2649
+ Object.assign(merged, legacyLocal);
2650
2650
  const existingLocal = Object.fromEntries(
2651
2651
  Object.entries(findRegistryEntry(registry, normalizedCwd) ?? {}).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
2652
2652
  );
@@ -2676,7 +2676,10 @@ function loadEnvFiles(cwd, iface) {
2676
2676
  }
2677
2677
  }
2678
2678
  } else if (Object.keys(merged).length === 0 && !fs5.existsSync(legacyPath) && !fs5.existsSync(globalPath)) {
2679
- logger.warn(`[config] No configuration found for workspace: ${cwd}. Using code defaults. Run /research-config to configure.`);
2679
+ if (!warnedMissingConfigWorkspaces.has(normalizedCwd)) {
2680
+ warnedMissingConfigWorkspaces.add(normalizedCwd);
2681
+ logger.warn(`[config] No configuration found for workspace: ${cwd}. Using code defaults. Run /research-config to configure.`);
2682
+ }
2680
2683
  }
2681
2684
  warnLegacyEnvVarsOnce(merged);
2682
2685
  return merged;
@@ -2965,7 +2968,7 @@ function parseEnvEnum(env, key, allowed, def) {
2965
2968
  logger.warn(`[config] Invalid ${key}="${raw}" (expected one of: ${allowed.join(", ")}). Using default "${def}".`);
2966
2969
  return def;
2967
2970
  }
2968
- var ConfigSchema, DEFAULTS, LOCAL_SCOPE_KEYS, USER_MIGRATION_KEYS, RECLAIM_FILE_SWEEP_AGE_MS, LEGACY_ENV_VARS, warnedLegacyEnvVars, configCache;
2971
+ var ConfigSchema, DEFAULTS, LOCAL_SCOPE_KEYS, USER_MIGRATION_KEYS, RECLAIM_FILE_SWEEP_AGE_MS, LEGACY_ENV_VARS, warnedLegacyEnvVars, warnedMissingConfigWorkspaces, configCache;
2969
2972
  var init_config = __esm({
2970
2973
  "src/config.ts"() {
2971
2974
  "use strict";
@@ -3184,6 +3187,7 @@ var init_config = __esm({
3184
3187
  PROXY_URL: "no longer read \u2014 the SearXNG proxy path was removed in v1.0.0 and searches/scrapes connect directly"
3185
3188
  };
3186
3189
  warnedLegacyEnvVars = false;
3190
+ warnedMissingConfigWorkspaces = /* @__PURE__ */ new Set();
3187
3191
  configCache = /* @__PURE__ */ new Map();
3188
3192
  }
3189
3193
  });
@@ -3196,10 +3200,11 @@ function getMaxGatheringCalls(config) {
3196
3200
  return MAX_GATHERING_CALLS;
3197
3201
  }
3198
3202
  }
3199
- function getMaxScrapeBatches(config) {
3203
+ function getMaxScrapeBatches(config, cachingActive = false) {
3200
3204
  try {
3201
3205
  const batches = (config || getConfig()).MAX_SCRAPE_BATCHES;
3202
- return batches === 0 || batches > 99 ? 999999 : batches;
3206
+ if (batches === 0 || batches > 99) return 999999;
3207
+ return cachingActive ? Math.min(batches + 1, 99) : batches;
3203
3208
  } catch {
3204
3209
  return 2;
3205
3210
  }
@@ -5506,20 +5511,37 @@ var init_embedding_server = __esm({
5506
5511
  });
5507
5512
  }
5508
5513
  /**
5509
- * Resolve once no native call is in flight. Used by EmbeddingServer.shutdown()
5510
- * to wait out a still-running inference before disposing the pipeline —
5511
- * without this, disposal could run concurrently with a legitimately slow (not
5512
- * hung) embed/embedMany call on the same re-entrant-locked GPU context, the
5513
- * exact native-segfault class this queue exists to prevent. No timeout: a
5514
- * promise that truly never settles is already an accepted, pre-existing
5515
- * leak-to-process-exit scenario elsewhere in this class (see the poison path's
5514
+ * Resolve once no native call is in flight AND no queued task is still due
5515
+ * to run. Used by EmbeddingServer.shutdown() to wait out a still-running
5516
+ * inference before disposing the pipeline — without this, disposal could
5517
+ * run concurrently with a legitimately slow (not hung) embed/embedMany call
5518
+ * on the same re-entrant-locked GPU context, the exact native-segfault
5519
+ * class this queue exists to prevent. No timeout: a promise that truly
5520
+ * never settles is already an accepted, pre-existing leak-to-process-exit
5521
+ * scenario elsewhere in this class (see the poison path's
5516
5522
  * disposeEmbedder=false handling) — better to leak than risk concurrent
5517
5523
  * native access.
5524
+ *
5525
+ * Checking only `currentWork` is NOT enough with a second task already
5526
+ * queued: a task's `run()` clears `currentWork` to null in its `finally`
5527
+ * (once its native call has settled), and `pump()` only claims it again for
5528
+ * the next task on a LATER microtask tick, once `run()` for the current
5529
+ * task has fully unwound and `await entry.run()` resumes. If a caller's own
5530
+ * check happens to land in that gap, `currentWork` reads as null even
5531
+ * though a second native call is about to start (or a queue in the process
5532
+ * of unwinding to its next task hasn't reached it yet) — exactly the window
5533
+ * disposal must not run in. `running` stays true for the queue's entire
5534
+ * drain, across every task in `tasks`, not just the current one, so it has
5535
+ * no equivalent gap and closes this one.
5518
5536
  */
5519
5537
  async waitForIdle() {
5520
- while (this.currentWork) {
5521
- await this.currentWork.catch(() => {
5522
- });
5538
+ while (this.currentWork || this.running) {
5539
+ if (this.currentWork) {
5540
+ await this.currentWork.catch(() => {
5541
+ });
5542
+ } else {
5543
+ await Promise.resolve();
5544
+ }
5523
5545
  }
5524
5546
  }
5525
5547
  poison(err) {
@@ -7683,10 +7705,31 @@ var init_store = __esm({
7683
7705
  this.table = await this.db.openTable(this.tableName);
7684
7706
  } else {
7685
7707
  logger.error("[store] Failed to persist canonical table name after rename \u2014 reverting to keep manifest and disk in sync.");
7686
- await fsPromises.rename(canonicalDir, tempDir);
7687
- this.tableName = tempTableName;
7688
- this.table = await this.db.openTable(this.tableName);
7689
- logger.info(`[store] Migrated data remains safe and accessible as ${tempTableName}; canonical rename deferred to a future successful manifest save.`);
7708
+ try {
7709
+ await fsPromises.rename(canonicalDir, tempDir);
7710
+ this.tableName = tempTableName;
7711
+ this.table = await this.db.openTable(this.tableName);
7712
+ logger.info(`[store] Migrated data remains safe and accessible as ${tempTableName}; canonical rename deferred to a future successful manifest save.`);
7713
+ } catch (revertErr) {
7714
+ logger.error(`[store] Revert rename also failed; data remains on disk at ${canonicalDir}: ${revertErr}`);
7715
+ this.tableName = canonicalName;
7716
+ let reopened = false;
7717
+ try {
7718
+ this.table = await this.db.openTable(this.tableName);
7719
+ reopened = true;
7720
+ } catch (openErr) {
7721
+ logger.error(`[store] CRITICAL: could not reopen the table at '${canonicalName}' after a failed rename revert (${openErr}). Data should still be intact on disk at ${canonicalDir}, but this process has no working table handle for it. Attempting the manifest repair anyway so a restart can find the data; verify ${canonicalDir} exists if problems persist.`);
7722
+ }
7723
+ try {
7724
+ if (await this.saveManifest()) {
7725
+ logger.warn(`[store] Recovered: manifest now points at '${canonicalName}', matching the data's actual on-disk location${reopened ? "" : " (this process still has no working table handle \u2014 restart to serve it)"}.`);
7726
+ } else {
7727
+ logger.error(`[store] CRITICAL: could not persist a manifest matching disk after a failed rename revert. Data is intact on disk at ${canonicalDir} (table '${canonicalName}'), but the persisted manifest is stale and points at '${tempTableName}', which no longer exists. If this process exits before a future successful manifest save, the next start will not find the data automatically \u2014 manually repair the manifest to point at '${canonicalName}' before restarting.`);
7728
+ }
7729
+ } catch (manifestErr) {
7730
+ logger.error(`[store] CRITICAL: manifest repair threw after a failed rename revert (${manifestErr}). Data is intact on disk at ${canonicalDir} (table '${canonicalName}'), but the persisted manifest is stale and points at '${tempTableName}'. Manually repair the manifest to point at '${canonicalName}' before restarting.`);
7731
+ }
7732
+ }
7690
7733
  }
7691
7734
  } catch (renameErr) {
7692
7735
  logger.warn(`[store] Directory rename failed, persisting temp table name in manifest: ${renameErr}`);
@@ -9292,6 +9335,15 @@ function resolveResearchModel(options) {
9292
9335
  }
9293
9336
  throw new Error(buildNoModelAvailableMessage());
9294
9337
  }
9338
+ function isPromptCachingActiveForModel(model) {
9339
+ if (!model) return false;
9340
+ if (model.api === "anthropic-messages") return true;
9341
+ if (model.api === "openai-completions") {
9342
+ const compat = model.compat;
9343
+ return compat?.cacheControlFormat === "anthropic";
9344
+ }
9345
+ return false;
9346
+ }
9295
9347
  var init_research_model_resolver = __esm({
9296
9348
  "src/core/llm/research-model-resolver.ts"() {
9297
9349
  "use strict";
@@ -9429,6 +9481,7 @@ __export(research_knowledge_search_exports, {
9429
9481
  isTransientSynthesisError: () => isTransientSynthesisError,
9430
9482
  rebuildDocuments: () => rebuildDocuments,
9431
9483
  runBackgroundExtraction: () => runBackgroundExtraction,
9484
+ splitPromptAtUserTurn: () => splitPromptAtUserTurn,
9432
9485
  triageRelevantUrls: () => triageRelevantUrls
9433
9486
  });
9434
9487
  import { Type as Type10 } from "typebox";
@@ -9572,13 +9625,28 @@ function isTransientSynthesisError(message) {
9572
9625
  // connection surfaces as `TypeError: terminated` (cause "other side closed" / UND_ERR_SOCKET).
9573
9626
  m.includes("terminated") || m.includes("other side closed") || m.includes("socket hang up") || m.includes("und_err");
9574
9627
  }
9628
+ function splitPromptAtUserTurn(template) {
9629
+ const open5 = template.indexOf("<!-- USER_TURN");
9630
+ if (open5 < 0) return { system: template, user: null };
9631
+ const close = template.indexOf("-->", open5);
9632
+ if (close < 0) return { system: template, user: null };
9633
+ return {
9634
+ system: template.slice(0, open5).trimEnd(),
9635
+ user: template.slice(close + "-->".length).trimStart()
9636
+ };
9637
+ }
9575
9638
  async function runBackgroundExtraction(model, auth, conversationHistory, queries, referenceDocuments, llmTimeout, maxTokens, thinkingLevel, signal, sessionId) {
9576
9639
  const promptTemplate = loadPrompt("research-knowledge-search-extractor");
9577
9640
  if (!promptTemplate) {
9578
9641
  throw new Error("research-knowledge-search prompt template not found");
9579
9642
  }
9580
- const systemPrompt = promptTemplate.replace("{{queries}}", () => formatQueriesBlock(queries)).replace("{{conversation_history}}", () => conversationHistory).replace("{{reference_documents}}", () => referenceDocuments);
9581
- const userMessage = "Analyze the reference documents above and extract the answer using the required JSON format.";
9643
+ const { system, user } = splitPromptAtUserTurn(promptTemplate);
9644
+ const fill = (t) => t.replace("{{queries}}", () => formatQueriesBlock(queries)).replace("{{conversation_history}}", () => conversationHistory).replace("{{reference_documents}}", () => referenceDocuments);
9645
+ const finalInstruction = "Analyze the reference documents above and extract the answer using the required JSON format.";
9646
+ const systemPrompt = user === null ? fill(system) : system;
9647
+ const userMessage = user === null ? finalInstruction : `${fill(user)}
9648
+
9649
+ ${finalInstruction}`;
9582
9650
  let responseText;
9583
9651
  let attempt = 0;
9584
9652
  for (; ; ) {
@@ -9666,8 +9734,13 @@ async function triageRelevantUrls(model, auth, conversationHistory, queries, can
9666
9734
  return `[${i}] ${c.url}
9667
9735
  ${desc}`;
9668
9736
  }).join("\n\n");
9669
- const systemPrompt = promptTemplate.replace("{{queries}}", () => formatQueriesBlock(queries)).replace("{{conversation_history}}", () => conversationHistory).replace("{{candidates}}", () => candidateBlock);
9670
- const userMessage = "Return the JSON object listing the indices of the relevant candidates.";
9737
+ const { system, user } = splitPromptAtUserTurn(promptTemplate);
9738
+ const fill = (t) => t.replace("{{queries}}", () => formatQueriesBlock(queries)).replace("{{conversation_history}}", () => conversationHistory).replace("{{candidates}}", () => candidateBlock);
9739
+ const finalInstruction = "Return the JSON object listing the indices of the relevant candidates.";
9740
+ const systemPrompt = user === null ? fill(system) : system;
9741
+ const userMessage = user === null ? finalInstruction : `${fill(user)}
9742
+
9743
+ ${finalInstruction}`;
9671
9744
  let responseText;
9672
9745
  let attempt = 0;
9673
9746
  for (; ; ) {
@@ -11250,7 +11323,12 @@ function isDegradableLlmError(error, signal) {
11250
11323
  function salvageReportText(raw) {
11251
11324
  const trimmed = raw.trim();
11252
11325
  if (trimmed.length <= 50) return "";
11253
- if (trimmed.startsWith("{") && /"(?:action|content|researchers)"\s*:/.test(trimmed)) {
11326
+ let sniff = trimmed;
11327
+ const fence = sniff.match(/^```[a-zA-Z0-9_-]*[ \t]*\r?\n/);
11328
+ if (fence) {
11329
+ sniff = sniff.slice(fence[0].length).replace(/\r?\n```\s*$/, "").trim();
11330
+ }
11331
+ if (sniff.startsWith("{") && /"(?:action|content|researchers)"\s*:/.test(sniff)) {
11254
11332
  return "";
11255
11333
  }
11256
11334
  return raw;
@@ -18621,7 +18699,7 @@ init_metrics();
18621
18699
  init_cancellation();
18622
18700
  function createScrapeTool(options) {
18623
18701
  const config = options.config || getConfig(options.ctx.cwd);
18624
- const maxScrapeBatches = getMaxScrapeBatches(config);
18702
+ const maxScrapeBatches = options.tracker?.getToolLimit("scrape") ?? getMaxScrapeBatches(config);
18625
18703
  const container = tryGetServiceContainerFromCtx(options.ctx);
18626
18704
  const fallbackState = {
18627
18705
  version: 1,
@@ -22120,6 +22198,8 @@ var PermanentError = class extends Error {
22120
22198
 
22121
22199
  // src/youtube/index.ts
22122
22200
  init_metrics();
22201
+ init_text_utils();
22202
+ init_constants();
22123
22203
  function buildTranscriptDocument(r) {
22124
22204
  const header = [];
22125
22205
  if (r.title) header.push(`# ${r.title}`);
@@ -22197,7 +22277,7 @@ function formatResults(results, rejected) {
22197
22277
  md += `**Cite as:** ${r.url}${r.title ? ` \u2014 '${r.title}'${r.author ? ` by ${r.author}` : ""}` : ""}
22198
22278
  `;
22199
22279
  md += `
22200
- ${r.text}
22280
+ ${truncateWithMarker(r.text ?? "", MAX_SCRAPE_CONTENT_CHARS_PER_DOC)}
22201
22281
 
22202
22282
  ---
22203
22283
 
@@ -22536,10 +22616,10 @@ var ToolUsageTracker = class {
22536
22616
  this.usage.clear();
22537
22617
  }
22538
22618
  };
22539
- function createDefaultToolLimits(config) {
22619
+ function createDefaultToolLimits(config, cachingActive = false) {
22540
22620
  return {
22541
22621
  gathering: getMaxGatheringCalls(config),
22542
- scrape: getMaxScrapeBatches(config),
22622
+ scrape: getMaxScrapeBatches(config, cachingActive),
22543
22623
  search: 1,
22544
22624
  youtube_transcript: 1,
22545
22625
  grep: MAX_GREP_CALLS,
@@ -22603,7 +22683,7 @@ async function createResearcherSession(options) {
22603
22683
  });
22604
22684
  logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
22605
22685
  try {
22606
- const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
22686
+ const tracker = new ToolUsageTracker(createDefaultToolLimits(config, isPromptCachingActiveForModel(modelToUse)));
22607
22687
  const globalLinks = updateGlobalLinks || (() => {
22608
22688
  });
22609
22689
  const customTools = createResearchTools({
@@ -22831,7 +22911,7 @@ function endResearchSession(piSessionId, researchId) {
22831
22911
  state.order.splice(index, 1);
22832
22912
  notifyOrderChange(sid);
22833
22913
  }
22834
- if (state.order.length === 0 && state.panels.size === 0) {
22914
+ if (state.order.length === 0 && state.panels.size === 0 && state.aborts.size === 0) {
22835
22915
  state.steeringMessages = state.steeringMessages.filter((m) => m.status === "queued");
22836
22916
  const hasRemainingSteering = state.steeringMessages.length > 0;
22837
22917
  if (!hasRemainingSteering) {
@@ -22843,7 +22923,6 @@ function endResearchSession(piSessionId, researchId) {
22843
22923
  } else {
22844
22924
  state.failures.clear();
22845
22925
  state.failureReasons.clear();
22846
- state.aborts.clear();
22847
22926
  }
22848
22927
  }
22849
22928
  }
@@ -23007,6 +23086,7 @@ ${effectiveInitialLinks.map((l) => `- ${l}`).join("\n")}`;
23007
23086
  let lastError;
23008
23087
  const researcherExecutionStartMs = Date.now();
23009
23088
  const deliveredSteeringIds = /* @__PURE__ */ new Set();
23089
+ const researcherSystemPrompt = injectCurrentDate(researcherPromptTemplate, "researcher").replace("{{extra_tool_guidelines}}", "").replace("{{digest_section}}", () => RESEARCHER_DIGEST_SECTION).trim();
23010
23090
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
23011
23091
  if (signal?.aborted) throw new Error("Aborted");
23012
23092
  if (stopRequested()) {
@@ -23040,14 +23120,25 @@ ${effectiveInitialLinks.map((l) => `- ${l}`).join("\n")}`;
23040
23120
  const allSteering = getSteeringMessages(sessionId);
23041
23121
  let steeringSection = "";
23042
23122
  if (allSteering.length > 0) {
23043
- steeringSection = "\n\n### ADDITIONAL USER GUIDANCE (Mandatory directional rules for your research)\n" + allSteering.map((m) => {
23123
+ steeringSection = "### ADDITIONAL USER GUIDANCE (Mandatory directional rules for your research)\n" + allSteering.map((m) => {
23044
23124
  deliveredSteeringIds.add(m.id);
23045
23125
  return `- ${m.text}`;
23046
23126
  }).join("\n");
23047
23127
  }
23048
- const prompt = injectCurrentDate(researcherPromptTemplate, "researcher").replace("{{goal}}", () => researcherConfig.goal).replace("{{store_section}}", () => storeSection).replace("{{evidence_section}}", () => evidenceSection).replace("{{coordination_section}}", () => previousQueriesSection).replace("{{extra_tool_guidelines}}", "").replace("{{digest_section}}", () => RESEARCHER_DIGEST_SECTION).trim() + steeringSection;
23128
+ const userMessage = [
23129
+ `Topic: ${researcherConfig.name}
23130
+ Goal: ${researcherConfig.goal}`,
23131
+ storeSection.trim(),
23132
+ evidenceSection.trim(),
23133
+ previousQueriesSection.trim(),
23134
+ steeringSection,
23135
+ "Perform your research and submit your full report now."
23136
+ ].filter(Boolean).join("\n\n");
23049
23137
  logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:
23050
- ${prompt}`);
23138
+ ${researcherSystemPrompt}
23139
+
23140
+ User Message:
23141
+ ${userMessage}`);
23051
23142
  const workerExclude = ["search"];
23052
23143
  const mergedExclude = [.../* @__PURE__ */ new Set([...workerExclude, ...options.excludeTools || []])];
23053
23144
  const scrapeEnabled = !mergedExclude.includes("scrape") && process.env["PI_RESEARCH_MOCK_SCRAPE"] !== "true";
@@ -23057,7 +23148,7 @@ ${prompt}`);
23057
23148
  cwd: ctx.cwd,
23058
23149
  ctxModel: model,
23059
23150
  modelRegistry: ctx.modelRegistry,
23060
- systemPrompt: prompt,
23151
+ systemPrompt: researcherSystemPrompt,
23061
23152
  extensionCtx: ctx,
23062
23153
  excludeTools: mergedExclude,
23063
23154
  researcherId: id,
@@ -23172,10 +23263,7 @@ ${prompt}`);
23172
23263
  });
23173
23264
  let abortCleanup;
23174
23265
  try {
23175
- const promptPromise = session.prompt(`Topic: ${researcherConfig.name}
23176
- Goal: ${researcherConfig.goal}
23177
-
23178
- Perform your research and submit your full report now.`);
23266
+ const promptPromise = session.prompt(userMessage);
23179
23267
  promptPromise.catch((err) => logger.debug(`[ResearcherExecutor] Background session prompt rejection: ${err.message}`));
23180
23268
  await Promise.race([
23181
23269
  promptPromise,
@@ -23279,6 +23367,7 @@ init_logger();
23279
23367
  init_config();
23280
23368
  init_text_utils();
23281
23369
  init_constants();
23370
+ init_research_model_resolver();
23282
23371
 
23283
23372
  // src/orchestration/headless-observer.ts
23284
23373
  init_logger();
@@ -23474,7 +23563,10 @@ var QuickResearchOrchestrator = class {
23474
23563
  }
23475
23564
  }
23476
23565
  const researcherPromptTemplate = loadPrompt("researcher");
23477
- const maxScrapeBatches = getMaxScrapeBatches(this.config);
23566
+ const cachingActiveForBudget = isPromptCachingActiveForModel(
23567
+ resolveResearchModel({ modelRegistry: ctx.modelRegistry, config: this.config, hostModel: model, cwd: ctx.cwd })
23568
+ );
23569
+ const maxScrapeBatches = getMaxScrapeBatches(this.config, cachingActiveForBudget);
23478
23570
  const maxScrapeBatchesDisplay = maxScrapeBatches > 99 ? "unlimited" : maxScrapeBatches.toString();
23479
23571
  const quickEvidenceSection = `## Search
23480
23572
  You have access to the \`search\` tool. You get EXACTLY ONE search call \u2014 make it count.
@@ -23497,9 +23589,19 @@ Prioritize primary sources and authoritative data.`;
23497
23589
  evidenceLines.push("## Initial Links\nInvestigate these provided URLs first:\n" + this.options.initialLinks.map((l) => `- ${l}`).join("\n"));
23498
23590
  }
23499
23591
  evidenceLines.push(quickEvidenceSection);
23500
- const prompt = injectCurrentDate(researcherPromptTemplate, "researcher").replace("{{goal}}", () => query + steeringSection).replace("{{store_section}}", () => storeSection).replace("{{evidence_section}}", () => evidenceLines.join("\n\n")).replace("{{coordination_section}}", "").replace("{{extra_tool_guidelines}}", "- `search`: Perform broad web searches (Round 1 only).").replace("{{digest_section}}", "");
23592
+ const prompt = injectCurrentDate(researcherPromptTemplate, "researcher").replace("{{extra_tool_guidelines}}", "- `search`: Perform broad web searches (Round 1 only).").replace("{{digest_section}}", "").trim();
23593
+ const userMessage = [
23594
+ `Goal: ${query}`,
23595
+ storeSection.trim(),
23596
+ evidenceLines.join("\n\n").trim(),
23597
+ steeringSection.trim(),
23598
+ "Perform your research and submit your full report now."
23599
+ ].filter(Boolean).join("\n\n");
23501
23600
  logger.debug(`[QuickOrchestrator] System Prompt:
23502
- ${prompt}`);
23601
+ ${prompt}
23602
+
23603
+ User Message:
23604
+ ${userMessage}`);
23503
23605
  let lastSeenSearchCount = 0;
23504
23606
  const { session, resolvedModel } = await createResearcherSession({
23505
23607
  cwd: ctx.cwd,
@@ -23593,7 +23695,7 @@ ${prompt}`);
23593
23695
  });
23594
23696
  let abortCleanup;
23595
23697
  try {
23596
- const promptPromise = session.prompt(query);
23698
+ const promptPromise = session.prompt(userMessage);
23597
23699
  promptPromise.catch((err) => logger.debug(`[QuickOrchestrator] Background session prompt rejection: ${err.message}`));
23598
23700
  await Promise.race([
23599
23701
  promptPromise,
@@ -24743,6 +24845,10 @@ init_service_registry();
24743
24845
  init_service_names();
24744
24846
  var NO_SOURCES_NOTE = "CITED LINKS\n_No web sources were successfully retrieved for this report \u2014 searches or page fetches may have been blocked (for example by site bot-protection) or returned no usable results. Treat the findings above as unverified._";
24745
24847
  var UNVERIFIED_LINKS_NOTE = "_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 \u2014 check each one before relying on it._";
24848
+ var WEAK_GROUNDING_MIN_SOURCES = 3;
24849
+ function weakGroundingNote(count) {
24850
+ return `_Grounding notice: only ${count} source${count === 1 ? "" : "s"} could be verified for this report. Claims not attributable to the cited source` + (count === 1 ? "" : "s") + " above should be treated as unverified \u2014 cross-check anything load-bearing before relying on it._";
24851
+ }
24746
24852
  function redactUnverifiedProseUrls(text, trusted) {
24747
24853
  if (trusted.size === 0) return text;
24748
24854
  let removed = 0;
@@ -24985,6 +25091,14 @@ Research performed using ${modelId}`;
24985
25091
  body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
24986
25092
  }
24987
25093
  body = redactUnverifiedProseUrls(body, trustedUrls);
25094
+ if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
25095
+ logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} verified source(s) \u2014 appending grounding notice.`);
25096
+ return `${body}
25097
+
25098
+ ${weakGroundingNote(globalCitations.length)}
25099
+
25100
+ ${verifiedLinksSection}`;
25101
+ }
24988
25102
  return `${body}
24989
25103
 
24990
25104
  ${verifiedLinksSection}`;
@@ -24993,6 +25107,14 @@ ${verifiedLinksSection}`;
24993
25107
  const validIds = new Set(globalCitations.map((c) => c.id));
24994
25108
  let prose = rewriteCitationMarkers(synthesis.trim(), /* @__PURE__ */ new Map(), (n) => !validIds.has(n));
24995
25109
  prose = redactUnverifiedProseUrls(prose, trustedUrls);
25110
+ if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
25111
+ logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} verified source(s) \u2014 appending grounding notice.`);
25112
+ return `${prose}
25113
+
25114
+ ${weakGroundingNote(globalCitations.length)}
25115
+
25116
+ ${verifiedLinksSection}`;
25117
+ }
24996
25118
  return `${prose}
24997
25119
 
24998
25120
  ${verifiedLinksSection}`;
@@ -25227,6 +25349,8 @@ function extractRunStats(snapshot) {
25227
25349
  };
25228
25350
  const errors = sumCounter(counters, "researcher_errors_total") + sumCounter(counters, "llm_api_errors_total");
25229
25351
  const tokens = sumCounter(counters, "llm_tokens_total");
25352
+ const cacheReadTokens = sumCounter(counters, "llm_cache_read_tokens_total");
25353
+ const cacheWriteTokens = sumCounter(counters, "llm_cache_write_tokens_total");
25230
25354
  const cost = sumCounter(counters, "llm_cost_total");
25231
25355
  let durationMs = 0;
25232
25356
  for (const key of Object.keys(histograms)) {
@@ -25261,6 +25385,8 @@ function extractRunStats(snapshot) {
25261
25385
  urlsFailed,
25262
25386
  errors,
25263
25387
  tokens,
25388
+ cacheReadTokens,
25389
+ cacheWriteTokens,
25264
25390
  cost,
25265
25391
  toolUsage
25266
25392
  };
@@ -25292,6 +25418,7 @@ var _onSignal = null;
25292
25418
  var _sigintHandler = null;
25293
25419
  var _sigtermHandler = null;
25294
25420
  var _sighupHandler = null;
25421
+ var _sigquitHandler = null;
25295
25422
  var _sigbreakHandler = null;
25296
25423
  var _shuttingDown = false;
25297
25424
  var _shutdownPromise = null;
@@ -25312,9 +25439,11 @@ function _registerSignalHandlers() {
25312
25439
  _sigintHandler = () => _onSignal?.("SIGINT");
25313
25440
  _sigtermHandler = () => _onSignal?.("SIGTERM");
25314
25441
  _sighupHandler = () => _onSignal?.("SIGHUP");
25442
+ _sigquitHandler = () => _onSignal?.("SIGQUIT");
25315
25443
  process.on("SIGINT", _sigintHandler);
25316
25444
  process.on("SIGTERM", _sigtermHandler);
25317
25445
  process.on("SIGHUP", _sighupHandler);
25446
+ process.on("SIGQUIT", _sigquitHandler);
25318
25447
  if (process.platform === "win32") {
25319
25448
  _sigbreakHandler = () => _onSignal?.("SIGBREAK");
25320
25449
  process.on("SIGBREAK", _sigbreakHandler);
@@ -25333,6 +25462,10 @@ function _removeSignalHandlers() {
25333
25462
  process.removeListener("SIGHUP", _sighupHandler);
25334
25463
  _sighupHandler = null;
25335
25464
  }
25465
+ if (_sigquitHandler) {
25466
+ process.removeListener("SIGQUIT", _sigquitHandler);
25467
+ _sigquitHandler = null;
25468
+ }
25336
25469
  if (_sigbreakHandler) {
25337
25470
  process.removeListener("SIGBREAK", _sigbreakHandler);
25338
25471
  _sigbreakHandler = null;
@@ -26019,10 +26152,17 @@ function makeProgressObserver() {
26019
26152
  }
26020
26153
  break;
26021
26154
  }
26022
- case "complete":
26155
+ case "complete": {
26023
26156
  toStderr(` \u2022 complete (${data?.result?.length ?? 0} chars)
26024
26157
  `);
26158
+ const reportText = typeof data?.result === "string" ? data.result : "";
26159
+ if (reportText.includes("No web sources were successfully retrieved") || reportText.includes("None of the links above could be verified")) {
26160
+ toStderr(" \u2022 WARNING: no web sources could be retrieved \u2014 the report is unverified\n");
26161
+ } else if (reportText.includes("Grounding notice: only")) {
26162
+ toStderr(" \u2022 WARNING: weakly grounded report (fewer than 3 verified sources) \u2014 treat unattributed claims as unverified\n");
26163
+ }
26025
26164
  break;
26165
+ }
26026
26166
  case "error":
26027
26167
  toStderr(` \u2022 error: ${data?.message}
26028
26168
  `);
@@ -26338,7 +26478,7 @@ Configure in:
26338
26478
  config: getConfig(process.cwd(), "cli"),
26339
26479
  ignoreGlobalConfig: true
26340
26480
  });
26341
- toStderr(`[pi-research] searching knowledge store (${queries.length} query/${queries.length === 1 ? "" : "s"})\u2026
26481
+ toStderr(`[pi-research] searching knowledge store (${queries.length} ${queries.length === 1 ? "query" : "queries"})\u2026
26342
26482
  `);
26343
26483
  result = await searchKnowledge(queries, abortController.signal);
26344
26484
  } catch (err) {
@@ -26996,9 +27136,15 @@ async function main(argv) {
26996
27136
  process.on("SIGINT", () => onSignal("SIGINT"));
26997
27137
  process.on("SIGTERM", () => onSignal("SIGTERM"));
26998
27138
  process.on("SIGHUP", () => onSignal("SIGHUP"));
27139
+ process.on("SIGQUIT", () => onSignal("SIGQUIT"));
26999
27140
  if (process.platform === "win32") {
27000
27141
  process.on("SIGBREAK", () => onSignal("SIGBREAK"));
27001
27142
  }
27143
+ if (process.env["PI_RESEARCH_TEST_HANG_FOR_SIGNAL"] === "1") {
27144
+ process.stdout.write("PI_RESEARCH_TEST_READY\n");
27145
+ await new Promise(() => setInterval(() => {
27146
+ }, 1e3));
27147
+ }
27002
27148
  let parsed;
27003
27149
  try {
27004
27150
  parsed = parseArgs(argv);
@@ -27083,6 +27229,7 @@ export {
27083
27229
  cmdKnowledgeConfig,
27084
27230
  cmdResearch,
27085
27231
  exitCodeForSignal,
27232
+ makeProgressObserver,
27086
27233
  parseArgs,
27087
27234
  reportError
27088
27235
  };
@@ -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
@@ -2332,7 +2332,7 @@ async function executeScrapeTask(_context, url, signal) {
2332
2332
  if (resp.request().resourceType() === "document" && resp.frame() === page.mainFrame()) {
2333
2333
  finalMainFrameAddr = resp.serverAddr().catch(() => null);
2334
2334
  const check2 = Promise.resolve(resp.headerValue("content-type")).then(async (ct) => {
2335
- const looksLikePdf = ct?.includes("application/pdf") || url.toLowerCase().endsWith(".pdf");
2335
+ const looksLikePdf = ct?.includes("application/pdf") || String(resp.url()).toLowerCase().endsWith(".pdf");
2336
2336
  if (!looksLikePdf || oversizedPdfError || poisonedError) return;
2337
2337
  const declared = parseInt(await resp.headerValue("content-length").catch(() => null) || "", 10);
2338
2338
  if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
@@ -77,9 +77,10 @@ One-click (recommended). From the `pi` extension, run `/research-config` →
77
77
  Install in External Agents. The installer:
78
78
 
79
79
  1. Detects which target agents are present under `$HOME` — currently Claude
80
- (`~/.claude/skills`), OpenAI Codex CLI (`~/.codex/skills`), and OpenClaw
81
- (`~/.openclaw/skills`, OpenClaw's managed skill root, which accepts symlinked
82
- skill folders).
80
+ (`~/.claude/skills`), OpenClaw (`~/.openclaw/skills`, OpenClaw's managed
81
+ skill root, which accepts symlinked skill folders), and OpenAI Codex CLI
82
+ (`~/.codex/skills` — unlike the other two, this path is not confirmed by
83
+ Codex's official docs; Codex skills support is still emerging).
83
84
  2. Symlinks `skills/pi-research/` into each present agent, never overwriting an
84
85
  unrelated skill already in that slot.
85
86
  3. Records what it created in a manifest, so Remove from External Agents