@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
package/dist/cli.mjs CHANGED
@@ -241,6 +241,8 @@ function redactSecrets(message) {
241
241
  }
242
242
  if (out.length > MAX_LOG_MESSAGE_LENGTH) {
243
243
  out = `${out.slice(0, keep)}\u2026[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
244
+ } else if (rawLength > scanEnd) {
245
+ out = `${out}\u2026[truncated ${rawLength - scanEnd} chars]`;
244
246
  }
245
247
  return out;
246
248
  }
@@ -2279,7 +2281,9 @@ import { createRequire as createRequire2 } from "node:module";
2279
2281
  function getConfigDirName() {
2280
2282
  if (_configDirName !== void 0) return _configDirName;
2281
2283
  const override = process.env["PI_RESEARCH_CONFIG_DIR_NAME"];
2282
- if (override) return _configDirName = override;
2284
+ if (override && !override.includes("/") && !override.includes("\\") && override !== "." && override !== "..") {
2285
+ return _configDirName = override;
2286
+ }
2283
2287
  try {
2284
2288
  const require2 = createRequire2(import.meta.url);
2285
2289
  const host = require2("@earendil-works/pi-coding-agent");
@@ -2639,10 +2643,10 @@ function loadEnvFiles(cwd, iface) {
2639
2643
  try {
2640
2644
  logger.warn(`[config] WARNING: .pi-research.env files are deprecated. Settings will be auto-migrated to the centralized registry. Remove ${legacyPath} after migration.`);
2641
2645
  legacyEnv = parseDotEnv(fs5.readFileSync(legacyPath, "utf-8"));
2642
- Object.assign(merged, legacyEnv);
2643
2646
  const legacyLocal = Object.fromEntries(
2644
2647
  Object.entries(legacyEnv).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
2645
2648
  );
2649
+ Object.assign(merged, legacyLocal);
2646
2650
  const existingLocal = Object.fromEntries(
2647
2651
  Object.entries(findRegistryEntry(registry, normalizedCwd) ?? {}).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
2648
2652
  );
@@ -2672,7 +2676,10 @@ function loadEnvFiles(cwd, iface) {
2672
2676
  }
2673
2677
  }
2674
2678
  } else if (Object.keys(merged).length === 0 && !fs5.existsSync(legacyPath) && !fs5.existsSync(globalPath)) {
2675
- 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
+ }
2676
2683
  }
2677
2684
  warnLegacyEnvVarsOnce(merged);
2678
2685
  return merged;
@@ -2961,7 +2968,7 @@ function parseEnvEnum(env, key, allowed, def) {
2961
2968
  logger.warn(`[config] Invalid ${key}="${raw}" (expected one of: ${allowed.join(", ")}). Using default "${def}".`);
2962
2969
  return def;
2963
2970
  }
2964
- 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;
2965
2972
  var init_config = __esm({
2966
2973
  "src/config.ts"() {
2967
2974
  "use strict";
@@ -3031,10 +3038,16 @@ var init_config = __esm({
3031
3038
  SEARCH_TIMEOUT_MS: Type.Number({ minimum: 5e3, maximum: 12e4, default: 45e3 }),
3032
3039
  /** TUI refresh debounce in milliseconds (default: 100ms) */
3033
3040
  TUI_REFRESH_DEBOUNCE_MS: Type.Number({ minimum: 0, maximum: 1e3, default: 100 }),
3034
- /** Queue-wait / overhead margin (ms) added on top of each browser operation's own nav
3035
- * timeout to form its scheduler task-timeout ceiling: a search task times out after
3036
- * SEARCH_TIMEOUT_MS + this, a scrape after SCRAPE_TIMEOUT_MS + this. Sized so a task is
3037
- * never killed before it has used its full nav budget plus time waiting in the queue.
3041
+ /** Overhead margin (ms) added on top of each browser operation's own nav timeout to
3042
+ * form its scheduler task-timeout ceiling: a search task times out after
3043
+ * SEARCH_TIMEOUT_MS + this, a scrape after SCRAPE_TIMEOUT_MS + this — covering
3044
+ * per-task work beyond raw navigation (page setup, result parsing). Queue wait is
3045
+ * NOT part of this margin: the deadline is armed on dispatch, not on enqueue, so
3046
+ * time spent waiting for a free worker never counts against it (see
3047
+ * browser-task-scheduler.ts). Cold browser-launch/context-creation time is also
3048
+ * separate from this margin — COLD_START_ALLOWANCE_MS
3049
+ * (infrastructure/browser/config.ts) covers that unconditionally on top of it,
3050
+ * since it isn't something a user should need to tune.
3038
3051
  * (default: 10000) */
3039
3052
  BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2e3, maximum: 12e4, default: 1e4 }),
3040
3053
  /** Timeout for coordinator/research-lead/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
@@ -3174,6 +3187,7 @@ var init_config = __esm({
3174
3187
  PROXY_URL: "no longer read \u2014 the SearXNG proxy path was removed in v1.0.0 and searches/scrapes connect directly"
3175
3188
  };
3176
3189
  warnedLegacyEnvVars = false;
3190
+ warnedMissingConfigWorkspaces = /* @__PURE__ */ new Set();
3177
3191
  configCache = /* @__PURE__ */ new Map();
3178
3192
  }
3179
3193
  });
@@ -3186,10 +3200,11 @@ function getMaxGatheringCalls(config) {
3186
3200
  return MAX_GATHERING_CALLS;
3187
3201
  }
3188
3202
  }
3189
- function getMaxScrapeBatches(config) {
3203
+ function getMaxScrapeBatches(config, cachingActive = false) {
3190
3204
  try {
3191
3205
  const batches = (config || getConfig()).MAX_SCRAPE_BATCHES;
3192
- return batches === 0 || batches > 99 ? 999999 : batches;
3206
+ if (batches === 0 || batches > 99) return 999999;
3207
+ return cachingActive ? Math.min(batches + 1, 99) : batches;
3193
3208
  } catch {
3194
3209
  return 2;
3195
3210
  }
@@ -4330,12 +4345,12 @@ async function isModelCached(model) {
4330
4345
  const cacheDir = env.cacheDir;
4331
4346
  if (!cacheDir) return false;
4332
4347
  const { access: access3, readdir: readdir6, readFile: readFile7, stat: stat6, rm: rm3 } = await import("node:fs/promises");
4333
- const path19 = await import("node:path");
4334
- const modelDir = path19.default.join(cacheDir, model);
4335
- const onnxDir = path19.default.join(modelDir, "onnx");
4336
- await access3(path19.default.join(onnxDir, "model.onnx"));
4348
+ const path20 = await import("node:path");
4349
+ const modelDir = path20.default.join(cacheDir, model);
4350
+ const onnxDir = path20.default.join(modelDir, "onnx");
4351
+ await access3(path20.default.join(onnxDir, "model.onnx"));
4337
4352
  try {
4338
- const cfgRaw = await readFile7(path19.default.join(modelDir, "config.json"), "utf-8");
4353
+ const cfgRaw = await readFile7(path20.default.join(modelDir, "config.json"), "utf-8");
4339
4354
  JSON.parse(cfgRaw);
4340
4355
  } catch {
4341
4356
  return false;
@@ -4343,7 +4358,7 @@ async function isModelCached(model) {
4343
4358
  const tokenizerFiles = ["tokenizer.json", "tokenizer.model", "vocab.txt"];
4344
4359
  let hasTokenizer = false;
4345
4360
  for (const f of tokenizerFiles) {
4346
- const st = await stat6(path19.default.join(modelDir, f)).catch(() => null);
4361
+ const st = await stat6(path20.default.join(modelDir, f)).catch(() => null);
4347
4362
  if (st && st.size > 0) {
4348
4363
  hasTokenizer = true;
4349
4364
  break;
@@ -4353,7 +4368,7 @@ async function isModelCached(model) {
4353
4368
  const entries = await readdir6(onnxDir).catch(() => []);
4354
4369
  const hasExternalWeights = entries.includes("model.onnx_data");
4355
4370
  if (hasExternalWeights) {
4356
- const dataStat = await stat6(path19.default.join(onnxDir, "model.onnx_data")).catch(() => null);
4371
+ const dataStat = await stat6(path20.default.join(onnxDir, "model.onnx_data")).catch(() => null);
4357
4372
  if (!dataStat || dataStat.size === 0) return false;
4358
4373
  }
4359
4374
  const partialArtifacts = entries.filter(
@@ -4362,7 +4377,7 @@ async function isModelCached(model) {
4362
4377
  if (partialArtifacts.length > 0) {
4363
4378
  if (hasExternalWeights) {
4364
4379
  for (const f of partialArtifacts) {
4365
- await rm3(path19.default.join(onnxDir, f), { force: true }).catch(() => {
4380
+ await rm3(path20.default.join(onnxDir, f), { force: true }).catch(() => {
4366
4381
  });
4367
4382
  }
4368
4383
  } else {
@@ -4384,10 +4399,10 @@ async function purgeModelCache(model) {
4384
4399
  const cacheDir = env.cacheDir;
4385
4400
  if (!cacheDir) return;
4386
4401
  const { rm: rm3 } = await import("node:fs/promises");
4387
- const path19 = await import("node:path");
4388
- const resolvedCache = path19.default.resolve(cacheDir);
4389
- const modelDir = path19.default.resolve(resolvedCache, model);
4390
- if (!model || path19.default.isAbsolute(model) || modelDir === resolvedCache || !modelDir.startsWith(resolvedCache + path19.default.sep)) {
4402
+ const path20 = await import("node:path");
4403
+ const resolvedCache = path20.default.resolve(cacheDir);
4404
+ const modelDir = path20.default.resolve(resolvedCache, model);
4405
+ if (!model || path20.default.isAbsolute(model) || modelDir === resolvedCache || !modelDir.startsWith(resolvedCache + path20.default.sep)) {
4391
4406
  logger.warn(`[embedder] Refusing to purge unsafe model-cache path for model id ${JSON.stringify(model)}`);
4392
4407
  return;
4393
4408
  }
@@ -4470,7 +4485,7 @@ async function handleWebGPULoadError(loadErr, pipeline, stateManager, gpuLockHel
4470
4485
  return { success: false, error };
4471
4486
  }
4472
4487
  }
4473
- async function handleWebGPUWarmupError(warmupErr, pipeline, stateManager, gpuLockHeld, model, initializationTimeoutMs, useCache) {
4488
+ async function handleWebGPUWarmupError(warmupErr, pipeline, stateManager, gpuLockHeld, model, initializationTimeoutMs, useCache, poolingMode = "mean") {
4474
4489
  markWebGpuFallback();
4475
4490
  logger.warn(
4476
4491
  "[embedder] WebGPU error during warmup \u2014 falling back to CPU. The underlying cause (commonly VRAM exhaustion) is in the native Dawn/ONNX output logged just above this line."
@@ -4487,7 +4502,7 @@ async function handleWebGPUWarmupError(warmupErr, pipeline, stateManager, gpuLoc
4487
4502
  try {
4488
4503
  const cpuPipeline = await loadModelOnCPU(model, initializationTimeoutMs, useCache);
4489
4504
  logger.info("[embedder] CPU pipeline loaded successfully");
4490
- const { dummy, success, error: cpuWarmupErr } = await warmupPipeline(cpuPipeline, "mean", useCache);
4505
+ const { dummy, success, error: cpuWarmupErr } = await warmupPipeline(cpuPipeline, poolingMode, useCache);
4491
4506
  if (!success || cpuWarmupErr) {
4492
4507
  logger.error("[embedder] CPU fallback pipeline warmup failed:", cpuWarmupErr);
4493
4508
  if (cpuPipeline) {
@@ -4906,7 +4921,8 @@ var init_embedder = __esm({
4906
4921
  this.gpuLockHeld,
4907
4922
  this.model,
4908
4923
  this.initializationTimeoutMs,
4909
- this.useCache
4924
+ this.useCache,
4925
+ this.poolingMode
4910
4926
  );
4911
4927
  if (!result.success) {
4912
4928
  throw result.error;
@@ -4968,9 +4984,12 @@ var init_embedder = __esm({
4968
4984
  truncateText(text) {
4969
4985
  const maxChars = this.maxTokens * this.charsPerToken;
4970
4986
  if (text.length > maxChars) {
4987
+ let keep = maxChars;
4988
+ const lastKeptUnit = text.charCodeAt(keep - 1);
4989
+ if (lastKeptUnit >= 55296 && lastKeptUnit <= 56319) keep -= 1;
4971
4990
  metrics.increment("embedder_truncations_total", 1, { model: this.model });
4972
- logger.debug(`[embedder] Truncated input for embedding: ${text.length} -> ${maxChars} chars (model=${this.model})`);
4973
- return text.slice(0, maxChars);
4991
+ logger.debug(`[embedder] Truncated input for embedding: ${text.length} -> ${keep} chars (model=${this.model})`);
4992
+ return text.slice(0, keep);
4974
4993
  }
4975
4994
  return text;
4976
4995
  }
@@ -5432,6 +5451,11 @@ var init_embedding_server = __esm({
5432
5451
  hardDeadlineMs;
5433
5452
  onPoison;
5434
5453
  poisoned = null;
5454
+ // The currently-executing task's real work promise (not the soft-timeout race) —
5455
+ // set only while a native call is actually in flight. Lets a caller that needs
5456
+ // to dispose the underlying pipeline (EmbeddingServer.shutdown) wait for true
5457
+ // idleness instead of racing disposal against a still-running inference.
5458
+ currentWork = null;
5435
5459
  constructor(maxDepth = 200, timeoutMs = 12e4, hardDeadlineMs = 6e5, onPoison) {
5436
5460
  this.maxDepth = maxDepth;
5437
5461
  this.timeoutMs = timeoutMs;
@@ -5457,6 +5481,7 @@ var init_embedding_server = __esm({
5457
5481
  let hardTimer;
5458
5482
  try {
5459
5483
  work = fn();
5484
+ this.currentWork = work;
5460
5485
  hardTimer = setTimeout(() => {
5461
5486
  if (settled) return;
5462
5487
  this.poison(new Error(
@@ -5477,6 +5502,7 @@ var init_embedding_server = __esm({
5477
5502
  } finally {
5478
5503
  settled = true;
5479
5504
  if (hardTimer !== void 0) clearTimeout(hardTimer);
5505
+ if (this.currentWork === work) this.currentWork = null;
5480
5506
  }
5481
5507
  }
5482
5508
  };
@@ -5484,6 +5510,40 @@ var init_embedding_server = __esm({
5484
5510
  if (!this.running) void this.pump();
5485
5511
  });
5486
5512
  }
5513
+ /**
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
5522
+ * disposeEmbedder=false handling) — better to leak than risk concurrent
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.
5536
+ */
5537
+ async waitForIdle() {
5538
+ while (this.currentWork || this.running) {
5539
+ if (this.currentWork) {
5540
+ await this.currentWork.catch(() => {
5541
+ });
5542
+ } else {
5543
+ await Promise.resolve();
5544
+ }
5545
+ }
5546
+ }
5487
5547
  poison(err) {
5488
5548
  if (this.poisoned) return;
5489
5549
  this.poisoned = err;
@@ -5665,6 +5725,7 @@ var init_embedding_server = __esm({
5665
5725
  this.server = null;
5666
5726
  }
5667
5727
  if (disposeEmbedder) {
5728
+ await this.queue.waitForIdle();
5668
5729
  await this.embedder.dispose().catch((err) => {
5669
5730
  if (this.isShuttingDown) {
5670
5731
  logger.debug("[EmbeddingServer] Error disposing embedder during shutdown (expected):", err);
@@ -5895,7 +5956,7 @@ var init_embedding_client = __esm({
5895
5956
  }
5896
5957
  }
5897
5958
  // ---- Internal HTTP helpers ----
5898
- getRequest(path19, timeoutMs = 12e4) {
5959
+ getRequest(path20, timeoutMs = 12e4) {
5899
5960
  return new Promise((resolve9, reject) => {
5900
5961
  let resolved = false;
5901
5962
  let req;
@@ -5903,7 +5964,7 @@ var init_embedding_client = __esm({
5903
5964
  if (!resolved) {
5904
5965
  resolved = true;
5905
5966
  req?.destroy();
5906
- reject(new Error(`[EmbeddingClient] GET ${path19} timed out after ${timeoutMs}ms`));
5967
+ reject(new Error(`[EmbeddingClient] GET ${path20} timed out after ${timeoutMs}ms`));
5907
5968
  }
5908
5969
  }, timeoutMs);
5909
5970
  if (timer.unref) timer.unref();
@@ -5911,7 +5972,7 @@ var init_embedding_client = __esm({
5911
5972
  {
5912
5973
  hostname: "127.0.0.1",
5913
5974
  port: this.port,
5914
- path: path19,
5975
+ path: path20,
5915
5976
  method: "GET",
5916
5977
  headers: this.authHeaders()
5917
5978
  },
@@ -5927,19 +5988,19 @@ var init_embedding_client = __esm({
5927
5988
  try {
5928
5989
  const parsed = JSON.parse(collected.toString());
5929
5990
  if (res.statusCode !== 200) {
5930
- reject(new Error(`[EmbeddingClient] GET ${path19} returned HTTP ${res.statusCode}`));
5991
+ reject(new Error(`[EmbeddingClient] GET ${path20} returned HTTP ${res.statusCode}`));
5931
5992
  } else {
5932
5993
  resolve9(parsed);
5933
5994
  }
5934
5995
  } catch (_e) {
5935
- reject(new Error(`[EmbeddingClient] Failed to parse GET ${path19} response: ${collected.toString()}`));
5996
+ reject(new Error(`[EmbeddingClient] Failed to parse GET ${path20} response: ${collected.toString()}`));
5936
5997
  }
5937
5998
  });
5938
5999
  res.on("error", (err) => {
5939
6000
  if (resolved) return;
5940
6001
  resolved = true;
5941
6002
  clearTimeout(timer);
5942
- reject(new Error(`[EmbeddingClient] GET ${path19} response error: ${err.message}`));
6003
+ reject(new Error(`[EmbeddingClient] GET ${path20} response error: ${err.message}`));
5943
6004
  });
5944
6005
  }
5945
6006
  );
@@ -5947,12 +6008,12 @@ var init_embedding_client = __esm({
5947
6008
  if (resolved) return;
5948
6009
  resolved = true;
5949
6010
  clearTimeout(timer);
5950
- reject(new Error(`[EmbeddingClient] GET ${path19} request error: ${err.message}`));
6011
+ reject(new Error(`[EmbeddingClient] GET ${path20} request error: ${err.message}`));
5951
6012
  });
5952
6013
  req.end();
5953
6014
  });
5954
6015
  }
5955
- request(path19, data) {
6016
+ request(path20, data) {
5956
6017
  return new Promise((resolve9, reject) => {
5957
6018
  const timeoutMs = 12e4;
5958
6019
  let resolved = false;
@@ -5961,7 +6022,7 @@ var init_embedding_client = __esm({
5961
6022
  if (!resolved) {
5962
6023
  resolved = true;
5963
6024
  req?.destroy();
5964
- reject(new Error(`[EmbeddingClient] POST ${path19} timed out after ${timeoutMs}ms`));
6025
+ reject(new Error(`[EmbeddingClient] POST ${path20} timed out after ${timeoutMs}ms`));
5965
6026
  }
5966
6027
  }, timeoutMs);
5967
6028
  if (timer.unref) timer.unref();
@@ -5970,7 +6031,7 @@ var init_embedding_client = __esm({
5970
6031
  {
5971
6032
  hostname: "127.0.0.1",
5972
6033
  port: this.port,
5973
- path: path19,
6034
+ path: path20,
5974
6035
  method: "POST",
5975
6036
  headers: {
5976
6037
  "Content-Type": "application/json",
@@ -5996,14 +6057,14 @@ var init_embedding_client = __esm({
5996
6057
  resolve9(parsed);
5997
6058
  }
5998
6059
  } catch (_e) {
5999
- reject(new Error(`[EmbeddingClient] Failed to parse POST ${path19} response: ${collected.toString()}`));
6060
+ reject(new Error(`[EmbeddingClient] Failed to parse POST ${path20} response: ${collected.toString()}`));
6000
6061
  }
6001
6062
  });
6002
6063
  res.on("error", (err) => {
6003
6064
  if (resolved) return;
6004
6065
  resolved = true;
6005
6066
  clearTimeout(timer);
6006
- reject(new Error(`[EmbeddingClient] POST ${path19} response error: ${err.message}`));
6067
+ reject(new Error(`[EmbeddingClient] POST ${path20} response error: ${err.message}`));
6007
6068
  });
6008
6069
  }
6009
6070
  );
@@ -6011,7 +6072,7 @@ var init_embedding_client = __esm({
6011
6072
  if (resolved) return;
6012
6073
  resolved = true;
6013
6074
  clearTimeout(timer);
6014
- reject(new Error(`[EmbeddingClient] POST ${path19} request error: ${err.message}`));
6075
+ reject(new Error(`[EmbeddingClient] POST ${path20} request error: ${err.message}`));
6015
6076
  });
6016
6077
  req.write(body);
6017
6078
  req.end();
@@ -7640,8 +7701,36 @@ var init_store = __esm({
7640
7701
  const canonicalDir = path15.join(this.options.dbDir, `${canonicalName}.lance`);
7641
7702
  await fsPromises.rename(tempDir, canonicalDir);
7642
7703
  this.tableName = canonicalName;
7643
- await this.saveManifest();
7644
- this.table = await this.db.openTable(this.tableName);
7704
+ if (await this.saveManifest()) {
7705
+ this.table = await this.db.openTable(this.tableName);
7706
+ } else {
7707
+ logger.error("[store] Failed to persist canonical table name after rename \u2014 reverting to keep manifest and disk in sync.");
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
+ }
7733
+ }
7645
7734
  } catch (renameErr) {
7646
7735
  logger.warn(`[store] Directory rename failed, persisting temp table name in manifest: ${renameErr}`);
7647
7736
  this.tableName = tempTableName;
@@ -8693,6 +8782,7 @@ var init_chunker = __esm({
8693
8782
 
8694
8783
  // src/knowledge/index.ts
8695
8784
  import * as fs14 from "node:fs";
8785
+ import * as path16 from "node:path";
8696
8786
  function getMigrationStrategy(config) {
8697
8787
  const strategy = config.MIGRATION_STRATEGY;
8698
8788
  if (!strategy) return void 0;
@@ -8754,15 +8844,25 @@ async function createKnowledgeStoreComponents(embedderFactory, reconnectFactory,
8754
8844
  }
8755
8845
  async function forceDeleteKnowledgeStore(config, workspace) {
8756
8846
  const dbDir = getDbDir(config, workspace);
8757
- if (fs14.existsSync(dbDir)) {
8758
- try {
8759
- fs14.rmSync(dbDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
8760
- fs14.mkdirSync(dbDir, { recursive: true });
8761
- logger.info(`[knowledge] Knowledge store at ${dbDir} forcefully deleted and recreated.`);
8762
- } catch (err) {
8763
- logger.error(`[knowledge] Failed to forcefully delete directory ${dbDir}:`, err);
8764
- throw err;
8847
+ if (!fs14.existsSync(dbDir)) return;
8848
+ let tableName = "knowledge";
8849
+ const manifestPath = path16.join(dbDir, "store-manifest.json");
8850
+ try {
8851
+ if (fs14.existsSync(manifestPath)) {
8852
+ const manifest = JSON.parse(fs14.readFileSync(manifestPath, "utf-8"));
8853
+ if (manifest.activeTableName) tableName = manifest.activeTableName;
8765
8854
  }
8855
+ } catch (err) {
8856
+ logger.warn(`[knowledge] Failed to read manifest before forced delete, defaulting to table '${tableName}':`, err);
8857
+ }
8858
+ const tableDir = path16.join(dbDir, `${tableName}.lance`);
8859
+ if (!fs14.existsSync(tableDir)) return;
8860
+ try {
8861
+ fs14.rmSync(tableDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
8862
+ logger.info(`[knowledge] Corrupted table directory ${tableDir} forcefully deleted; will be recreated on next init.`);
8863
+ } catch (err) {
8864
+ logger.error(`[knowledge] Failed to forcefully delete table directory ${tableDir}:`, err);
8865
+ throw err;
8766
8866
  }
8767
8867
  }
8768
8868
  var MAX_INIT_RETRIES, BASE_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS;
@@ -8787,7 +8887,7 @@ var knowledge_store_service_exports = {};
8787
8887
  __export(knowledge_store_service_exports, {
8788
8888
  KnowledgeStoreService: () => KnowledgeStoreService
8789
8889
  });
8790
- import * as path16 from "node:path";
8890
+ import * as path17 from "node:path";
8791
8891
  var KnowledgeStoreService;
8792
8892
  var init_knowledge_store_service = __esm({
8793
8893
  "src/infrastructure/knowledge-store-service.ts"() {
@@ -8881,7 +8981,7 @@ var init_knowledge_store_service = __esm({
8881
8981
  };
8882
8982
  const container = tryGetServiceContainerFromCtx(ctx);
8883
8983
  const pathConfig = await getService(ServiceNames.STATE_PATH_CONFIGURATION, void 0, container);
8884
- const lockPath = path16.join(pathConfig.getLockDirPath(), "knowledge-store-init.lock");
8984
+ const lockPath = path17.join(pathConfig.getLockDirPath(), "knowledge-store-init.lock");
8885
8985
  if (!this._initLock) {
8886
8986
  const processLifecycle = await getService(ServiceNames.PROCESS_LIFECYCLE, void 0, container);
8887
8987
  this._initLock = new FileLockService({
@@ -9235,6 +9335,15 @@ function resolveResearchModel(options) {
9235
9335
  }
9236
9336
  throw new Error(buildNoModelAvailableMessage());
9237
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
+ }
9238
9347
  var init_research_model_resolver = __esm({
9239
9348
  "src/core/llm/research-model-resolver.ts"() {
9240
9349
  "use strict";
@@ -9372,6 +9481,7 @@ __export(research_knowledge_search_exports, {
9372
9481
  isTransientSynthesisError: () => isTransientSynthesisError,
9373
9482
  rebuildDocuments: () => rebuildDocuments,
9374
9483
  runBackgroundExtraction: () => runBackgroundExtraction,
9484
+ splitPromptAtUserTurn: () => splitPromptAtUserTurn,
9375
9485
  triageRelevantUrls: () => triageRelevantUrls
9376
9486
  });
9377
9487
  import { Type as Type10 } from "typebox";
@@ -9515,13 +9625,28 @@ function isTransientSynthesisError(message) {
9515
9625
  // connection surfaces as `TypeError: terminated` (cause "other side closed" / UND_ERR_SOCKET).
9516
9626
  m.includes("terminated") || m.includes("other side closed") || m.includes("socket hang up") || m.includes("und_err");
9517
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
+ }
9518
9638
  async function runBackgroundExtraction(model, auth, conversationHistory, queries, referenceDocuments, llmTimeout, maxTokens, thinkingLevel, signal, sessionId) {
9519
9639
  const promptTemplate = loadPrompt("research-knowledge-search-extractor");
9520
9640
  if (!promptTemplate) {
9521
9641
  throw new Error("research-knowledge-search prompt template not found");
9522
9642
  }
9523
- const systemPrompt = promptTemplate.replace("{{queries}}", () => formatQueriesBlock(queries)).replace("{{conversation_history}}", () => conversationHistory).replace("{{reference_documents}}", () => referenceDocuments);
9524
- 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}`;
9525
9650
  let responseText;
9526
9651
  let attempt = 0;
9527
9652
  for (; ; ) {
@@ -9609,8 +9734,13 @@ async function triageRelevantUrls(model, auth, conversationHistory, queries, can
9609
9734
  return `[${i}] ${c.url}
9610
9735
  ${desc}`;
9611
9736
  }).join("\n\n");
9612
- const systemPrompt = promptTemplate.replace("{{queries}}", () => formatQueriesBlock(queries)).replace("{{conversation_history}}", () => conversationHistory).replace("{{candidates}}", () => candidateBlock);
9613
- 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}`;
9614
9744
  let responseText;
9615
9745
  let attempt = 0;
9616
9746
  for (; ; ) {
@@ -9942,11 +10072,11 @@ __export(skill_installer_exports, {
9942
10072
  import * as crypto10 from "node:crypto";
9943
10073
  import * as fs16 from "node:fs";
9944
10074
  import * as os6 from "node:os";
9945
- import * as path17 from "node:path";
10075
+ import * as path18 from "node:path";
9946
10076
  import { fileURLToPath as fileURLToPath4 } from "node:url";
9947
10077
  function readSkillVersion(sourceDir) {
9948
10078
  try {
9949
- const md = fs16.readFileSync(path17.join(sourceDir, "SKILL.md"), "utf-8");
10079
+ const md = fs16.readFileSync(path18.join(sourceDir, "SKILL.md"), "utf-8");
9950
10080
  return /"version"\s*:\s*"([^"]+)"/.exec(md)?.[1] ?? null;
9951
10081
  } catch {
9952
10082
  return null;
@@ -9961,7 +10091,7 @@ function resolveSkillSourceDir(explicit) {
9961
10091
  if (process.env["PI_RESEARCH_SKILL_DIR"]) candidates.push(process.env["PI_RESEARCH_SKILL_DIR"]);
9962
10092
  const fromHere = (() => {
9963
10093
  try {
9964
- return path17.dirname(fileURLToPath4(import.meta.url));
10094
+ return path18.dirname(fileURLToPath4(import.meta.url));
9965
10095
  } catch {
9966
10096
  return void 0;
9967
10097
  }
@@ -9970,22 +10100,22 @@ function resolveSkillSourceDir(explicit) {
9970
10100
  if (!start) continue;
9971
10101
  let dir = start;
9972
10102
  for (let i = 0; i < 8; i++) {
9973
- candidates.push(path17.join(dir, "skills", SKILL_NAME));
9974
- const parent = path17.dirname(dir);
10103
+ candidates.push(path18.join(dir, "skills", SKILL_NAME));
10104
+ const parent = path18.dirname(dir);
9975
10105
  if (parent === dir) break;
9976
10106
  dir = parent;
9977
10107
  }
9978
10108
  }
9979
10109
  for (const c of candidates) {
9980
10110
  try {
9981
- if (fs16.existsSync(path17.join(c, "SKILL.md"))) return path17.resolve(c);
10111
+ if (fs16.existsSync(path18.join(c, "SKILL.md"))) return path18.resolve(c);
9982
10112
  } catch {
9983
10113
  }
9984
10114
  }
9985
10115
  throw new Error("Could not locate the bundled research skill (skills/pi-research/SKILL.md). Set PI_RESEARCH_SKILL_DIR to its path.");
9986
10116
  }
9987
10117
  function getManifestPath(opts) {
9988
- return path17.join(homeDir(opts), ".pi", "research", "installed-skills.json");
10118
+ return path18.join(homeDir(opts), ".pi", "research", "installed-skills.json");
9989
10119
  }
9990
10120
  function readManifest(opts) {
9991
10121
  const p = getManifestPath(opts);
@@ -10002,7 +10132,7 @@ function readManifest(opts) {
10002
10132
  }
10003
10133
  function writeManifest(manifest, opts) {
10004
10134
  const p = getManifestPath(opts);
10005
- fs16.mkdirSync(path17.dirname(p), { recursive: true });
10135
+ fs16.mkdirSync(path18.dirname(p), { recursive: true });
10006
10136
  const tmp = `${p}.tmp.${process.pid}.${Date.now()}.${manifestTmpSeq++}`;
10007
10137
  fs16.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
10008
10138
  try {
@@ -10024,8 +10154,8 @@ function isOwnedSymlink(targetPath, expectedSource) {
10024
10154
  try {
10025
10155
  const st = fs16.lstatSync(targetPath);
10026
10156
  if (!st.isSymbolicLink()) return false;
10027
- const dest = path17.resolve(path17.dirname(targetPath), fs16.readlinkSync(targetPath));
10028
- if (expectedSource && path17.resolve(expectedSource) === dest) return true;
10157
+ const dest = path18.resolve(path18.dirname(targetPath), fs16.readlinkSync(targetPath));
10158
+ if (expectedSource && path18.resolve(expectedSource) === dest) return true;
10029
10159
  return /[/\\]pi-research[/\\]skills[/\\]pi-research$/.test(dest);
10030
10160
  } catch {
10031
10161
  return false;
@@ -10033,7 +10163,7 @@ function isOwnedSymlink(targetPath, expectedSource) {
10033
10163
  }
10034
10164
  function isOwnedCopy(targetPath) {
10035
10165
  try {
10036
- const md = fs16.readFileSync(path17.join(targetPath, "SKILL.md"), "utf-8");
10166
+ const md = fs16.readFileSync(path18.join(targetPath, "SKILL.md"), "utf-8");
10037
10167
  return md.includes(PACKAGE_NAME);
10038
10168
  } catch {
10039
10169
  return false;
@@ -10042,11 +10172,11 @@ function isOwnedCopy(targetPath) {
10042
10172
  function detectHarnesses(opts) {
10043
10173
  const home = homeDir(opts);
10044
10174
  return HARNESSES.map((h) => {
10045
- const absSkillsDir = path17.join(home, h.skillsDir);
10046
- const absSkillPath = path17.join(absSkillsDir, SKILL_NAME);
10175
+ const absSkillsDir = path18.join(home, h.skillsDir);
10176
+ const absSkillPath = path18.join(absSkillsDir, SKILL_NAME);
10047
10177
  let present = false;
10048
10178
  try {
10049
- present = fs16.existsSync(path17.join(home, h.baseDir));
10179
+ present = fs16.existsSync(path18.join(home, h.baseDir));
10050
10180
  } catch {
10051
10181
  }
10052
10182
  let installed = "none";
@@ -10087,15 +10217,15 @@ function installSkill(toolIds, opts = {}) {
10087
10217
  results.push({ tool: id, path: "", type: "symlink", status: "error", message: `unknown target "${id}"` });
10088
10218
  continue;
10089
10219
  }
10090
- const absSkillsDir = path17.join(home, def.skillsDir);
10091
- const absSkillPath = path17.join(absSkillsDir, SKILL_NAME);
10220
+ const absSkillsDir = path18.join(home, def.skillsDir);
10221
+ const absSkillPath = path18.join(absSkillsDir, SKILL_NAME);
10092
10222
  const type = opts.copy ? "copy" : "symlink";
10093
10223
  if (isOwnedSymlink(absSkillPath, source) || isOwnedCopy(absSkillPath)) {
10094
10224
  try {
10095
10225
  const lst = fs16.lstatSync(absSkillPath);
10096
10226
  if (lst.isSymbolicLink()) {
10097
- const dest = path17.resolve(path17.dirname(absSkillPath), fs16.readlinkSync(absSkillPath));
10098
- if (dest !== path17.resolve(source)) {
10227
+ const dest = path18.resolve(path18.dirname(absSkillPath), fs16.readlinkSync(absSkillPath));
10228
+ if (dest !== path18.resolve(source)) {
10099
10229
  if (opts.dryRun) {
10100
10230
  results.push({ tool: id, path: absSkillPath, type: "symlink", status: "planned", message: "would re-point stale symlink to current source" });
10101
10231
  continue;
@@ -10171,7 +10301,7 @@ function uninstallSkill(toolIds, opts = {}) {
10171
10301
  }
10172
10302
  }
10173
10303
  for (const { tool, path: skillPath } of targets.values()) {
10174
- if (toolIds && toolIds.length && !toolIds.includes(tool)) {
10304
+ if (toolIds !== void 0 && !toolIds.includes(tool)) {
10175
10305
  const e = manifest.entries.find((x) => x.path === skillPath);
10176
10306
  if (e) keep.push(e);
10177
10307
  continue;
@@ -10232,19 +10362,19 @@ function reconcileSkillInstalls(opts = {}) {
10232
10362
  if (e.type !== "symlink") {
10233
10363
  const stale2 = currentVersion !== null && e.version !== currentVersion;
10234
10364
  if (stale2 && source !== null && fs16.existsSync(e.path) && isOwnedCopy(e.path)) {
10235
- const dir2 = path17.dirname(e.path);
10236
- const base2 = path17.basename(e.path);
10365
+ const dir2 = path18.dirname(e.path);
10366
+ const base2 = path18.basename(e.path);
10237
10367
  try {
10238
10368
  for (const name of fs16.readdirSync(dir2)) {
10239
10369
  if (name.startsWith(`.${base2}.new-`) || name.startsWith(`.${base2}.old-`)) {
10240
- fs16.rmSync(path17.join(dir2, name), { recursive: true, force: true });
10370
+ fs16.rmSync(path18.join(dir2, name), { recursive: true, force: true });
10241
10371
  }
10242
10372
  }
10243
10373
  } catch {
10244
10374
  }
10245
10375
  const suffix = crypto10.randomBytes(4).toString("hex");
10246
- const fresh = path17.join(dir2, `.${base2}.new-${suffix}`);
10247
- const retired = path17.join(dir2, `.${base2}.old-${suffix}`);
10376
+ const fresh = path18.join(dir2, `.${base2}.new-${suffix}`);
10377
+ const retired = path18.join(dir2, `.${base2}.old-${suffix}`);
10248
10378
  try {
10249
10379
  copyDir(source, fresh);
10250
10380
  } catch {
@@ -10290,12 +10420,12 @@ function reconcileSkillInstalls(opts = {}) {
10290
10420
  }
10291
10421
  continue;
10292
10422
  }
10293
- const dir = path17.dirname(e.path);
10294
- const base = path17.basename(e.path);
10423
+ const dir = path18.dirname(e.path);
10424
+ const base = path18.basename(e.path);
10295
10425
  try {
10296
10426
  for (const name of fs16.readdirSync(dir)) {
10297
10427
  if (name.startsWith(`.${base}.new-`)) {
10298
- fs16.rmSync(path17.join(dir, name), { recursive: true, force: true });
10428
+ fs16.rmSync(path18.join(dir, name), { recursive: true, force: true });
10299
10429
  }
10300
10430
  }
10301
10431
  } catch {
@@ -10311,10 +10441,10 @@ function reconcileSkillInstalls(opts = {}) {
10311
10441
  const targetLive = fs16.existsSync(e.path);
10312
10442
  let dest = null;
10313
10443
  try {
10314
- dest = path17.resolve(path17.dirname(e.path), fs16.readlinkSync(e.path));
10444
+ dest = path18.resolve(path18.dirname(e.path), fs16.readlinkSync(e.path));
10315
10445
  } catch {
10316
10446
  }
10317
- const stale = source !== null && dest !== null && dest !== path17.resolve(source);
10447
+ const stale = source !== null && dest !== null && dest !== path18.resolve(source);
10318
10448
  if (!targetLive || stale) {
10319
10449
  if (source !== null) {
10320
10450
  try {
@@ -10329,7 +10459,7 @@ function reconcileSkillInstalls(opts = {}) {
10329
10459
  result.repointed.push(e.path);
10330
10460
  } catch {
10331
10461
  const suffix = crypto10.randomBytes(4).toString("hex");
10332
- const fresh = path17.join(dir, `.${base}.new-${suffix}`);
10462
+ const fresh = path18.join(dir, `.${base}.new-${suffix}`);
10333
10463
  try {
10334
10464
  copyDir(source, fresh);
10335
10465
  fs16.renameSync(fresh, e.path);
@@ -10371,7 +10501,7 @@ function copyDir(src, dest) {
10371
10501
  if (st.isDirectory()) {
10372
10502
  fs16.mkdirSync(dest, { recursive: true });
10373
10503
  for (const name of fs16.readdirSync(src)) {
10374
- copyDir(path17.join(src, name), path17.join(dest, name));
10504
+ copyDir(path18.join(src, name), path18.join(dest, name));
10375
10505
  }
10376
10506
  } else {
10377
10507
  fs16.copyFileSync(src, dest);
@@ -10382,12 +10512,12 @@ var init_skill_installer = __esm({
10382
10512
  "src/skill-install/skill-installer.ts"() {
10383
10513
  "use strict";
10384
10514
  HARNESSES = [
10385
- { id: "claude", label: "Claude", baseDir: ".claude", skillsDir: path17.join(".claude", "skills"), confidence: "confirmed" },
10386
- { id: "openclaw", label: "OpenClaw", baseDir: ".openclaw", skillsDir: path17.join(".openclaw", "skills"), confidence: "confirmed", note: "OpenClaw reads ~/.openclaw/skills as a managed skill root and accepts symlinked skill folders (docs.openclaw.ai/tools/skills)." },
10387
- { id: "pi", label: "pi", baseDir: ".pi", skillsDir: path17.join(".pi", "skills"), confidence: "confirmed" },
10388
- { id: "cursor", label: "Cursor", baseDir: ".cursor", skillsDir: path17.join(".cursor", "skills"), confidence: "partial", note: "Cursor project-level skills are documented; the global ~/.cursor/skills path is community-reported." },
10389
- { id: "codex", label: "OpenAI Codex CLI", baseDir: ".codex", skillsDir: path17.join(".codex", "skills"), confidence: "unverified", note: "Codex skills support is emerging; path not confirmed by official docs." },
10390
- { id: "agents", label: "Cross-tool (~/.agents/skills)", baseDir: ".agents", skillsDir: path17.join(".agents", "skills"), confidence: "unverified", note: "Proposed Universal Agents convention; not yet a ratified standard." }
10515
+ { id: "claude", label: "Claude", baseDir: ".claude", skillsDir: path18.join(".claude", "skills"), confidence: "confirmed" },
10516
+ { id: "openclaw", label: "OpenClaw", baseDir: ".openclaw", skillsDir: path18.join(".openclaw", "skills"), confidence: "confirmed", note: "OpenClaw reads ~/.openclaw/skills as a managed skill root and accepts symlinked skill folders (docs.openclaw.ai/tools/skills)." },
10517
+ { id: "pi", label: "pi", baseDir: ".pi", skillsDir: path18.join(".pi", "skills"), confidence: "confirmed" },
10518
+ { id: "cursor", label: "Cursor", baseDir: ".cursor", skillsDir: path18.join(".cursor", "skills"), confidence: "partial", note: "Cursor project-level skills are documented; the global ~/.cursor/skills path is community-reported." },
10519
+ { id: "codex", label: "OpenAI Codex CLI", baseDir: ".codex", skillsDir: path18.join(".codex", "skills"), confidence: "unverified", note: "Codex skills support is emerging; path not confirmed by official docs." },
10520
+ { id: "agents", label: "Cross-tool (~/.agents/skills)", baseDir: ".agents", skillsDir: path18.join(".agents", "skills"), confidence: "unverified", note: "Proposed Universal Agents convention; not yet a ratified standard." }
10391
10521
  ];
10392
10522
  SKILL_NAME = "pi-research";
10393
10523
  PACKAGE_NAME = "@lincoln504/pi-research";
@@ -10400,7 +10530,7 @@ var init_skill_installer = __esm({
10400
10530
  // src/cli.ts
10401
10531
  import * as fs17 from "node:fs";
10402
10532
  import * as os7 from "node:os";
10403
- import * as path18 from "node:path";
10533
+ import * as path19 from "node:path";
10404
10534
  import { fileURLToPath as fileURLToPath5 } from "node:url";
10405
10535
 
10406
10536
  // src/core/service-initialization.ts
@@ -10814,7 +10944,7 @@ function delimiterRegex(word) {
10814
10944
  return new RegExp(`(^|\\n)([ \\t]*(?:#{1,6}[ \\t]*|>[ \\t]*|\\*\\*[ \\t]*)?)${word}\\b`, "i");
10815
10945
  }
10816
10946
  function splitCoverageDigest(report) {
10817
- if (!report) return { digest: "", body: report ?? "" };
10947
+ if (!report) return { digest: "", body: report };
10818
10948
  const headerMatch = delimiterRegex(COVERAGE_DIGEST_HEADER).exec(report);
10819
10949
  if (!headerMatch) return { digest: "", body: report };
10820
10950
  const lineStart = headerMatch.index + headerMatch[1].length;
@@ -11193,7 +11323,12 @@ function isDegradableLlmError(error, signal) {
11193
11323
  function salvageReportText(raw) {
11194
11324
  const trimmed = raw.trim();
11195
11325
  if (trimmed.length <= 50) return "";
11196
- 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)) {
11197
11332
  return "";
11198
11333
  }
11199
11334
  return raw;
@@ -11540,14 +11675,24 @@ ${previousQueries.map((q) => `- ${q}`).join("\n")}
11540
11675
  complexity,
11541
11676
  observer
11542
11677
  };
11543
- const responseText = isRouter ? await this.callLead({
11544
- ...callOptions,
11545
- systemPrompt: populate(""),
11546
- userMessage: buildRouterMessage(...this.buildRouterEvidence(options, round), runContext),
11547
- maxTokens: ROUTER_MAX_TOKENS,
11548
- component: "router",
11549
- label: "router-updatePlanForRound"
11550
- }) : await this.synthesizeCorpus({ ...callOptions, reports, runContext, populate });
11678
+ const responseText = isRouter ? await (() => {
11679
+ const routerMessage = buildRouterMessage(...this.buildRouterEvidence(options, round), runContext);
11680
+ const window = model.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW;
11681
+ const estimatedTokens = routerMessage.length / CHARS_PER_TOKEN + ROUTER_MAX_TOKENS + SYNTHESIS_OVERHEAD_TOKENS;
11682
+ if (estimatedTokens > window) {
11683
+ logger.warn(
11684
+ `[PlanningService] Router request for ${model.id} is an estimated ${Math.round(estimatedTokens)} tokens against a ${window}-token context window (evidence ${routerMessage.length} chars, output ceiling ${ROUTER_MAX_TOKENS} tokens). Unlike the synthesizer, the router has no reduce path for an over-budget round \u2014 sending it anyway, the provider may reject this request.`
11685
+ );
11686
+ }
11687
+ return this.callLead({
11688
+ ...callOptions,
11689
+ systemPrompt: populate(""),
11690
+ userMessage: routerMessage,
11691
+ maxTokens: ROUTER_MAX_TOKENS,
11692
+ component: "router",
11693
+ label: "router-updatePlanForRound"
11694
+ });
11695
+ })() : await this.synthesizeCorpus({ ...callOptions, reports, runContext, populate });
11551
11696
  let plan = null;
11552
11697
  try {
11553
11698
  plan = this.parseJsonPlan(responseText);
@@ -11795,6 +11940,13 @@ ${previousQueries.map((q) => `- ${q}`).join("\n")}
11795
11940
  `[PlanningService] Synthesis corpus is still ${finalSize} chars after ${MAX_SYNTHESIS_REDUCE_PASSES} reduce pass(es), over the ${budgetChars}-char budget for ${args.model.id}. Sending it anyway \u2014 the provider may reject this request.`
11796
11941
  );
11797
11942
  }
11943
+ const window = args.model.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW;
11944
+ const estimatedTotalTokens = (finalSize + globalSourceList.length + runContext.length) / CHARS_PER_TOKEN + config.SYNTHESIS_MAX_TOKENS + SYNTHESIS_OVERHEAD_TOKENS;
11945
+ if (estimatedTotalTokens > window) {
11946
+ logger.warn(
11947
+ `[PlanningService] Synthesis request for ${args.model.id} is an estimated ${Math.round(estimatedTotalTokens)} tokens against a ${window}-token context window (corpus ${finalSize} chars, source list ${globalSourceList.length} chars, run context ${runContext.length} chars, output ceiling ${config.SYNTHESIS_MAX_TOKENS} tokens) \u2014 the source list and/or run context are large enough on their own to overflow the window even though the reduced corpus fits its budget. Sending it anyway \u2014 the provider may reject this request.`
11948
+ );
11949
+ }
11798
11950
  return this.callLead({
11799
11951
  ...args,
11800
11952
  systemPrompt: populate(reduced ? MERGE_SYNTHESIS_SECTION : ""),
@@ -12175,10 +12327,13 @@ function isFullMockMode() {
12175
12327
  }
12176
12328
  var HEALTHCHECK_MIN_BUDGET_MS = 45e3 + 6e4;
12177
12329
  var HEALTHCHECK_NAV_ATTEMPTS = 3;
12330
+ var BROWSER_LAUNCH_TIMEOUT_MS = 9e4;
12331
+ var CONTEXT_CREATION_TIMEOUT_MS = 3e4;
12332
+ var COLD_START_ALLOWANCE_MS = BROWSER_LAUNCH_TIMEOUT_MS + CONTEXT_CREATION_TIMEOUT_MS;
12178
12333
  function getHealthCheckBudgetMs(config) {
12179
12334
  const c = config || getConfig();
12180
12335
  const ms = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
12181
- const longestSlotHold = Math.max(ms(c.SEARCH_TIMEOUT_MS, 45e3), ms(c.SCRAPE_TIMEOUT_MS, 15e3)) + ms(c.BROWSER_TASK_TIMEOUT_MS, 1e4);
12336
+ const longestSlotHold = Math.max(ms(c.SEARCH_TIMEOUT_MS, 45e3), ms(c.SCRAPE_TIMEOUT_MS, 15e3)) + ms(c.BROWSER_TASK_TIMEOUT_MS, 1e4) + COLD_START_ALLOWANCE_MS;
12182
12337
  const probeWork = HEALTHCHECK_NAV_ATTEMPTS * Math.max(1e4, ms(c.HEALTH_CHECK_TIMEOUT_MS, 1e4));
12183
12338
  return Math.max(HEALTHCHECK_MIN_BUDGET_MS, longestSlotHold + probeWork);
12184
12339
  }
@@ -12234,9 +12389,9 @@ var BrowserClient = class {
12234
12389
  get targetPort() {
12235
12390
  return this.port;
12236
12391
  }
12237
- async request(path19, data, signal, config) {
12392
+ async request(path20, data, signal, config) {
12238
12393
  const start = Date.now();
12239
- const operation = path19.includes("/search") ? "search" : path19.includes("/scrape") ? "browser-task" : path19.includes("/healthcheck") ? "healthcheck" : "network";
12394
+ const operation = path20.includes("/search") ? "search" : path20.includes("/scrape") ? "browser-task" : path20.includes("/healthcheck") ? "healthcheck" : "network";
12240
12395
  return new Promise((resolve9, reject) => {
12241
12396
  const agent = getClientAgent();
12242
12397
  const timeoutMs = resolveClientRequestTimeoutMs(operation, config);
@@ -12261,7 +12416,7 @@ var BrowserClient = class {
12261
12416
  resolved = true;
12262
12417
  abortCleanup?.();
12263
12418
  controller.abort();
12264
- const error = new Error(`[BrowserClient] Request to ${path19} timed out after ${timeoutMs}ms (Shared queue may be deep)`);
12419
+ const error = new Error(`[BrowserClient] Request to ${path20} timed out after ${timeoutMs}ms (Shared queue may be deep)`);
12265
12420
  errorTracker.trackError(error, {
12266
12421
  component: "browser-manager",
12267
12422
  operation,
@@ -12274,7 +12429,7 @@ var BrowserClient = class {
12274
12429
  const req = http4.request({
12275
12430
  hostname: "127.0.0.1",
12276
12431
  port: this.port,
12277
- path: path19,
12432
+ path: path20,
12278
12433
  method: "POST",
12279
12434
  agent,
12280
12435
  signal: controller.signal,
@@ -12298,7 +12453,7 @@ var BrowserClient = class {
12298
12453
  const error = new Error(
12299
12454
  `Browser pool leader is draining (${parsed.reason || "leadership-lost"}) \u2014 re-elect and retry`
12300
12455
  );
12301
- logger.debug(`[BrowserClient] Leader draining on ${path19}; re-election required.`);
12456
+ logger.debug(`[BrowserClient] Leader draining on ${path20}; re-election required.`);
12302
12457
  errorTracker.trackError(error, {
12303
12458
  component: "browser-manager",
12304
12459
  operation,
@@ -12314,7 +12469,7 @@ var BrowserClient = class {
12314
12469
  });
12315
12470
  reject(error);
12316
12471
  } else {
12317
- logger.debug(`[BrowserClient] Request ${path19} completed in ${duration}ms`);
12472
+ logger.debug(`[BrowserClient] Request ${path20} completed in ${duration}ms`);
12318
12473
  resolve9(parsed);
12319
12474
  }
12320
12475
  } catch (_e) {
@@ -12334,7 +12489,7 @@ var BrowserClient = class {
12334
12489
  resolved = true;
12335
12490
  clearTimeout(timer);
12336
12491
  abortCleanup?.();
12337
- const error = new Error(`[BrowserClient] Response stream error on ${path19}: ${err.message}`);
12492
+ const error = new Error(`[BrowserClient] Response stream error on ${path20}: ${err.message}`);
12338
12493
  errorTracker.trackError(error, {
12339
12494
  component: "browser-manager",
12340
12495
  operation,
@@ -12353,20 +12508,20 @@ var BrowserClient = class {
12353
12508
  let errorMsg;
12354
12509
  let errorType;
12355
12510
  if (nodeErr.code === "ECONNRESET" || nodeErr.code === "EPIPE") {
12356
- errorMsg = `Browser pool socket ${path19} closed (pool likely busy or restarting) - ${err.message}`;
12511
+ errorMsg = `Browser pool socket ${path20} closed (pool likely busy or restarting) - ${err.message}`;
12357
12512
  errorType = "connection_reset";
12358
12513
  } else if (nodeErr.code === "ECONNREFUSED") {
12359
- errorMsg = `Browser pool ${path19} unreachable (server restarting or torn down) - ${err.message}`;
12514
+ errorMsg = `Browser pool ${path20} unreachable (server restarting or torn down) - ${err.message}`;
12360
12515
  errorType = "connection_refused";
12361
12516
  } else if (nodeErr.code === "ETIMEDOUT") {
12362
- errorMsg = `Browser pool ${path19} timed out (slow browser response) - ${err.message}`;
12517
+ errorMsg = `Browser pool ${path20} timed out (slow browser response) - ${err.message}`;
12363
12518
  errorType = "timeout";
12364
12519
  } else {
12365
- errorMsg = `Browser pool ${path19} error: ${err.message}`;
12520
+ errorMsg = `Browser pool ${path20} error: ${err.message}`;
12366
12521
  errorType = "unknown";
12367
12522
  }
12368
12523
  const error = new Error(errorMsg);
12369
- logger.error(`[BrowserClient] Request to http://127.0.0.1:${this.port}${path19} failed:`, errorMsg);
12524
+ logger.error(`[BrowserClient] Request to http://127.0.0.1:${this.port}${path20} failed:`, errorMsg);
12370
12525
  errorTracker.trackError(error, {
12371
12526
  component: "browser-manager",
12372
12527
  operation,
@@ -13246,7 +13401,7 @@ var BrowserTaskScheduler = class {
13246
13401
  const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
13247
13402
  const startTime = Date.now();
13248
13403
  const cfg = config || getConfig();
13249
- const timeoutMs = cfg.SEARCH_TIMEOUT_MS + cfg.BROWSER_TASK_TIMEOUT_MS;
13404
+ const timeoutMs = cfg.SEARCH_TIMEOUT_MS + cfg.BROWSER_TASK_TIMEOUT_MS + COLD_START_ALLOWANCE_MS;
13250
13405
  let timeoutId;
13251
13406
  let dispatchedAt = 0;
13252
13407
  let startDeadline = () => {
@@ -13335,7 +13490,7 @@ var BrowserTaskScheduler = class {
13335
13490
  const startTime = Date.now();
13336
13491
  const cfg = config || getConfig();
13337
13492
  const isMocking = process.env["PI_RESEARCH_MOCK_SCRAPE"] === "true";
13338
- const timeoutMs = cfg.SCRAPE_TIMEOUT_MS + (isMocking ? 5e3 : cfg.BROWSER_TASK_TIMEOUT_MS);
13493
+ const timeoutMs = cfg.SCRAPE_TIMEOUT_MS + (isMocking ? 5e3 : cfg.BROWSER_TASK_TIMEOUT_MS + COLD_START_ALLOWANCE_MS);
13339
13494
  let timeoutId;
13340
13495
  let dispatchedAt = 0;
13341
13496
  let startDeadline = () => {
@@ -15393,8 +15548,8 @@ var StateValidator = class {
15393
15548
  if (!Value4.Check(SingletonStateSchema, coerced)) {
15394
15549
  const errors = [...Value4.Errors(SingletonStateSchema, coerced)];
15395
15550
  const errorMsg = errors.map((e) => {
15396
- const path19 = String(e.path || "root");
15397
- return `${path19}: ${e.message} (${JSON.stringify(e.value)})`;
15551
+ const path20 = String(e.path || "root");
15552
+ return `${path20}: ${e.message} (${JSON.stringify(e.value)})`;
15398
15553
  }).join(", ");
15399
15554
  throw new Error(`Invalid state: ${errorMsg}`);
15400
15555
  }
@@ -17123,7 +17278,7 @@ async function performSearch(queries, config, signal, onProgress, container = ge
17123
17278
  metrics.increment("browser_search_orchestrations_total", 1);
17124
17279
  logger.log(`[Search] Orchestrating ${queries.length} queries across ${maxWorkers} worker processes...`);
17125
17280
  if (onProgress) onProgress(0);
17126
- const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45e3) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 1e4);
17281
+ const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45e3) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 1e4) + COLD_START_ALLOWANCE_MS;
17127
17282
  const filteredQueries = [...new Set(queries.filter((q) => q.trim()))];
17128
17283
  let timeoutCount = 0;
17129
17284
  let errorCount = 0;
@@ -18158,28 +18313,31 @@ async function extractPdfToMarkdown(bytes) {
18158
18313
  }
18159
18314
  try {
18160
18315
  const doc = new WasmPdfDocument(bytes);
18161
- const pageCount = doc.pageCount();
18162
- let markdown = `# PDF Document
18316
+ try {
18317
+ const pageCount = doc.pageCount();
18318
+ let markdown = `# PDF Document
18163
18319
 
18164
18320
  **Pages:** ${pageCount}
18165
18321
 
18166
18322
  `;
18167
- try {
18168
- markdown += doc.toMarkdownAll();
18169
- } catch {
18170
- for (let i = 0; i < pageCount; i++) {
18171
- markdown += `## Page ${i + 1}
18323
+ try {
18324
+ markdown += doc.toMarkdownAll();
18325
+ } catch {
18326
+ for (let i = 0; i < pageCount; i++) {
18327
+ markdown += `## Page ${i + 1}
18172
18328
 
18173
18329
  ${doc.toMarkdown(i)}
18174
18330
 
18175
18331
  `;
18332
+ }
18176
18333
  }
18334
+ const pdfExtractionDuration = Date.now() - pdfExtractionStart;
18335
+ metrics.observe("scrape_pdf_conversion_ms", pdfExtractionDuration);
18336
+ metrics.increment("scrape_pdf_conversions_total", 1, { status: "success", pages: String(pageCount) });
18337
+ return markdown;
18338
+ } finally {
18339
+ doc.free();
18177
18340
  }
18178
- doc.free();
18179
- const pdfExtractionDuration = Date.now() - pdfExtractionStart;
18180
- metrics.observe("scrape_pdf_conversion_ms", pdfExtractionDuration);
18181
- metrics.increment("scrape_pdf_conversions_total", 1, { status: "success", pages: String(pageCount) });
18182
- return markdown;
18183
18341
  } catch (e) {
18184
18342
  const msg2 = e instanceof Error ? e.message : String(e);
18185
18343
  logger.debug(`[Scrapers] PDF extraction failed: ${msg2}`);
@@ -18279,7 +18437,7 @@ async function scrapeWithFetch(url, signal) {
18279
18437
  const contentLength = response.headers.get("content-length");
18280
18438
  if (contentLength) {
18281
18439
  const size = parseInt(contentLength, 10);
18282
- if (contentType.includes("application/pdf") || url.toLowerCase().endsWith(".pdf")) {
18440
+ if (contentType.includes("application/pdf") || currentUrl.toLowerCase().endsWith(".pdf")) {
18283
18441
  if (size > MAX_PDF_SIZE) {
18284
18442
  const sizeMB = Math.round(size / 1024 / 1024);
18285
18443
  logger.warn(`[Scrapers] PDF too large (Content-Length: ${sizeMB}MB, max 100MB), skipping`);
@@ -18301,7 +18459,7 @@ async function scrapeWithFetch(url, signal) {
18301
18459
  throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`);
18302
18460
  }
18303
18461
  }
18304
- if (contentType.includes("application/pdf") || url.toLowerCase().endsWith(".pdf")) {
18462
+ if (contentType.includes("application/pdf") || currentUrl.toLowerCase().endsWith(".pdf")) {
18305
18463
  let pdfBytes;
18306
18464
  try {
18307
18465
  pdfBytes = await readBodyCapped(response, MAX_PDF_SIZE);
@@ -18541,7 +18699,7 @@ init_metrics();
18541
18699
  init_cancellation();
18542
18700
  function createScrapeTool(options) {
18543
18701
  const config = options.config || getConfig(options.ctx.cwd);
18544
- const maxScrapeBatches = getMaxScrapeBatches(config);
18702
+ const maxScrapeBatches = options.tracker?.getToolLimit("scrape") ?? getMaxScrapeBatches(config);
18545
18703
  const container = tryGetServiceContainerFromCtx(options.ctx);
18546
18704
  const fallbackState = {
18547
18705
  version: 1,
@@ -20426,14 +20584,6 @@ function createSecuritySearchTool(options) {
20426
20584
  details: { error: "invalid_parameters" }
20427
20585
  };
20428
20586
  }
20429
- const allowed = options.tracker.recordCall("security_search");
20430
- if (!allowed) {
20431
- metrics.increment("tool_security_search_calls_total", 1, { status: "rate_limited" });
20432
- return {
20433
- content: [{ type: "text", text: options.tracker.getLimitMessage("security_search") }],
20434
- details: { blocked: true, reason: "limit_reached" }
20435
- };
20436
- }
20437
20587
  const p = params;
20438
20588
  const terms = p.terms;
20439
20589
  if (terms.length === 0) {
@@ -20475,6 +20625,14 @@ function createSecuritySearchTool(options) {
20475
20625
  }
20476
20626
  }
20477
20627
  const maxResults = p.maxResults ?? 20;
20628
+ const allowed = options.tracker.recordCall("security_search");
20629
+ if (!allowed) {
20630
+ metrics.increment("tool_security_search_calls_total", 1, { status: "rate_limited" });
20631
+ return {
20632
+ content: [{ type: "text", text: options.tracker.getLimitMessage("security_search") }],
20633
+ details: { blocked: true, reason: "limit_reached" }
20634
+ };
20635
+ }
20478
20636
  metrics.increment("tool_security_search_terms_total", terms.length);
20479
20637
  metrics.increment("tool_security_search_databases_total", databases.length);
20480
20638
  let results;
@@ -20882,20 +21040,19 @@ var StackExchangeClient = class {
20882
21040
  }
20883
21041
  });
20884
21042
  }
21043
+ // Pure accessor — no metrics side effects. `stackexchangeCommand()` calls this
21044
+ // repeatedly per invocation (pre-request gate checks, success footer, error
21045
+ // footer) purely to read current state for display; incrementing counters here
21046
+ // used to record 2-3x the real number of quota-low/exhausted events per call.
21047
+ // The counters are incremented once, at the single gate check that actually
21048
+ // observes each event (see stackexchange/index.ts).
20885
21049
  getQuotaInfo() {
20886
- const info = {
21050
+ return {
20887
21051
  remaining: this.quotaRemaining,
20888
21052
  max: this.quotaMax,
20889
21053
  requestCount: this.requestCount,
20890
21054
  lastBackoff: this.lastBackoff
20891
21055
  };
20892
- if (this.isQuotaExhausted()) {
20893
- metrics.increment("stackexchange_quota_exhausted_total", 1);
20894
- }
20895
- if (this.isQuotaLow()) {
20896
- metrics.increment("stackexchange_quota_low_total", 1);
20897
- }
20898
- return info;
20899
21056
  }
20900
21057
  isQuotaExhausted() {
20901
21058
  return this.quotaExhaustedAt !== null && Date.now() - this.quotaExhaustedAt < QUOTA_PROBE_COOLDOWN_MS;
@@ -21174,6 +21331,7 @@ function formatSitesCompact(sites) {
21174
21331
 
21175
21332
  // src/stackexchange/index.ts
21176
21333
  init_logger();
21334
+ init_metrics();
21177
21335
  function notify(ctx, message, type) {
21178
21336
  ctx.ui?.notify?.(message, type);
21179
21337
  }
@@ -21200,6 +21358,7 @@ async function stackexchangeCommand(options) {
21200
21358
  const client = getClient(config);
21201
21359
  try {
21202
21360
  if (client.isQuotaExhausted()) {
21361
+ metrics.increment("stackexchange_quota_exhausted_total", 1);
21203
21362
  const quota2 = client.getQuotaInfo();
21204
21363
  notify(
21205
21364
  ctx,
@@ -21215,6 +21374,7 @@ async function stackexchangeCommand(options) {
21215
21374
  };
21216
21375
  }
21217
21376
  if (client.isQuotaLow()) {
21377
+ metrics.increment("stackexchange_quota_low_total", 1);
21218
21378
  const quota2 = client.getQuotaInfo();
21219
21379
  notify(
21220
21380
  ctx,
@@ -21663,6 +21823,7 @@ init_http_body();
21663
21823
  init_logger();
21664
21824
  init_safe_unref();
21665
21825
  init_metrics();
21826
+ init_http_body();
21666
21827
  var DEFAULT_REQUEST_KEY = "O43z0dpjhgX20SCx4KAo";
21667
21828
  var BRIDGED_GLOBALS = ["window", "document", "location", "navigator", "origin"];
21668
21829
  function describeError(error) {
@@ -21762,7 +21923,10 @@ async function doMint(identifiers, opts) {
21762
21923
  body: JSON.stringify([requestKey, botguardResponse]),
21763
21924
  signal: opts.signal
21764
21925
  });
21765
- const integrityJson = await integrityResponse.json();
21926
+ if (!integrityResponse.ok) {
21927
+ throw new Error(`HTTP ${integrityResponse.status} from attestation server`);
21928
+ }
21929
+ const integrityJson = await readJsonCapped(integrityResponse);
21766
21930
  const integrityToken = integrityJson?.[0];
21767
21931
  if (!integrityToken) {
21768
21932
  throw new Error("Integrity token rejected by attestation server (likely a non-residential IP)");
@@ -21817,7 +21981,16 @@ async function fetchVideoTranscripts(videoIds, opts = {}) {
21817
21981
  if (log?.setLevel && log.Level) log.setLevel(log.Level.NONE);
21818
21982
  } catch {
21819
21983
  }
21820
- const fetchImpl = (input, init) => fetch(input, init);
21984
+ const ssrfFetcher = await getSsrfSafeFetcher();
21985
+ const fetchImpl = ssrfFetcher ? (input, init) => {
21986
+ if (typeof input === "string" || input instanceof URL) {
21987
+ return ssrfFetcher.fetch(String(input), {
21988
+ ...init ?? {},
21989
+ dispatcher: ssrfFetcher.dispatcher
21990
+ });
21991
+ }
21992
+ return fetch(input, init);
21993
+ } : (input, init) => fetch(input, init);
21821
21994
  let visitorData;
21822
21995
  try {
21823
21996
  const seed = await breaker.execute(() => withTimeout2(
@@ -21900,10 +22073,27 @@ async function fetchOne(session, videoId, contentToken, lang, timeoutMs, signal,
21900
22073
  const sep3 = track.base_url.includes("?") ? "&" : "?";
21901
22074
  const url = `${track.base_url}${sep3}fmt=json3&c=WEB&pot=${encodeURIComponent(contentToken)}`;
21902
22075
  const fetchSignal = createTimeoutSignal(timeoutMs, signal);
21903
- const response = await fetchImpl(url, {
21904
- signal: fetchSignal,
21905
- headers: { "User-Agent": "Mozilla/5.0", "Accept-Language": lang }
21906
- });
22076
+ const MAX_REDIRECTS = 5;
22077
+ let currentUrl = url;
22078
+ let response;
22079
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
22080
+ response = await fetchImpl(currentUrl, {
22081
+ signal: fetchSignal,
22082
+ redirect: "manual",
22083
+ headers: { "User-Agent": "Mozilla/5.0", "Accept-Language": lang }
22084
+ });
22085
+ if (response.status >= 300 && response.status < 400) {
22086
+ const location = response.headers.get("location");
22087
+ if (!location) break;
22088
+ const resolved = new URL(location, currentUrl).href;
22089
+ assertTrustedTimedtextUrl(resolved);
22090
+ void response.body?.cancel()?.catch(() => {
22091
+ });
22092
+ currentUrl = resolved;
22093
+ continue;
22094
+ }
22095
+ break;
22096
+ }
21907
22097
  if (!response.ok) {
21908
22098
  void response.body?.cancel()?.catch(() => {
21909
22099
  });
@@ -22008,6 +22198,8 @@ var PermanentError = class extends Error {
22008
22198
 
22009
22199
  // src/youtube/index.ts
22010
22200
  init_metrics();
22201
+ init_text_utils();
22202
+ init_constants();
22011
22203
  function buildTranscriptDocument(r) {
22012
22204
  const header = [];
22013
22205
  if (r.title) header.push(`# ${r.title}`);
@@ -22085,7 +22277,7 @@ function formatResults(results, rejected) {
22085
22277
  md += `**Cite as:** ${r.url}${r.title ? ` \u2014 '${r.title}'${r.author ? ` by ${r.author}` : ""}` : ""}
22086
22278
  `;
22087
22279
  md += `
22088
- ${r.text}
22280
+ ${truncateWithMarker(r.text ?? "", MAX_SCRAPE_CONTENT_CHARS_PER_DOC)}
22089
22281
 
22090
22282
  ---
22091
22283
 
@@ -22424,10 +22616,10 @@ var ToolUsageTracker = class {
22424
22616
  this.usage.clear();
22425
22617
  }
22426
22618
  };
22427
- function createDefaultToolLimits(config) {
22619
+ function createDefaultToolLimits(config, cachingActive = false) {
22428
22620
  return {
22429
22621
  gathering: getMaxGatheringCalls(config),
22430
- scrape: getMaxScrapeBatches(config),
22622
+ scrape: getMaxScrapeBatches(config, cachingActive),
22431
22623
  search: 1,
22432
22624
  youtube_transcript: 1,
22433
22625
  grep: MAX_GREP_CALLS,
@@ -22491,7 +22683,7 @@ async function createResearcherSession(options) {
22491
22683
  });
22492
22684
  logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
22493
22685
  try {
22494
- const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
22686
+ const tracker = new ToolUsageTracker(createDefaultToolLimits(config, isPromptCachingActiveForModel(modelToUse)));
22495
22687
  const globalLinks = updateGlobalLinks || (() => {
22496
22688
  });
22497
22689
  const customTools = createResearchTools({
@@ -22719,7 +22911,7 @@ function endResearchSession(piSessionId, researchId) {
22719
22911
  state.order.splice(index, 1);
22720
22912
  notifyOrderChange(sid);
22721
22913
  }
22722
- if (state.order.length === 0 && state.panels.size === 0) {
22914
+ if (state.order.length === 0 && state.panels.size === 0 && state.aborts.size === 0) {
22723
22915
  state.steeringMessages = state.steeringMessages.filter((m) => m.status === "queued");
22724
22916
  const hasRemainingSteering = state.steeringMessages.length > 0;
22725
22917
  if (!hasRemainingSteering) {
@@ -22731,7 +22923,6 @@ function endResearchSession(piSessionId, researchId) {
22731
22923
  } else {
22732
22924
  state.failures.clear();
22733
22925
  state.failureReasons.clear();
22734
- state.aborts.clear();
22735
22926
  }
22736
22927
  }
22737
22928
  }
@@ -22895,6 +23086,7 @@ ${effectiveInitialLinks.map((l) => `- ${l}`).join("\n")}`;
22895
23086
  let lastError;
22896
23087
  const researcherExecutionStartMs = Date.now();
22897
23088
  const deliveredSteeringIds = /* @__PURE__ */ new Set();
23089
+ const researcherSystemPrompt = injectCurrentDate(researcherPromptTemplate, "researcher").replace("{{extra_tool_guidelines}}", "").replace("{{digest_section}}", () => RESEARCHER_DIGEST_SECTION).trim();
22898
23090
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
22899
23091
  if (signal?.aborted) throw new Error("Aborted");
22900
23092
  if (stopRequested()) {
@@ -22928,14 +23120,25 @@ ${effectiveInitialLinks.map((l) => `- ${l}`).join("\n")}`;
22928
23120
  const allSteering = getSteeringMessages(sessionId);
22929
23121
  let steeringSection = "";
22930
23122
  if (allSteering.length > 0) {
22931
- 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) => {
22932
23124
  deliveredSteeringIds.add(m.id);
22933
23125
  return `- ${m.text}`;
22934
23126
  }).join("\n");
22935
23127
  }
22936
- 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");
22937
23137
  logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:
22938
- ${prompt}`);
23138
+ ${researcherSystemPrompt}
23139
+
23140
+ User Message:
23141
+ ${userMessage}`);
22939
23142
  const workerExclude = ["search"];
22940
23143
  const mergedExclude = [.../* @__PURE__ */ new Set([...workerExclude, ...options.excludeTools || []])];
22941
23144
  const scrapeEnabled = !mergedExclude.includes("scrape") && process.env["PI_RESEARCH_MOCK_SCRAPE"] !== "true";
@@ -22945,7 +23148,7 @@ ${prompt}`);
22945
23148
  cwd: ctx.cwd,
22946
23149
  ctxModel: model,
22947
23150
  modelRegistry: ctx.modelRegistry,
22948
- systemPrompt: prompt,
23151
+ systemPrompt: researcherSystemPrompt,
22949
23152
  extensionCtx: ctx,
22950
23153
  excludeTools: mergedExclude,
22951
23154
  researcherId: id,
@@ -23060,10 +23263,7 @@ ${prompt}`);
23060
23263
  });
23061
23264
  let abortCleanup;
23062
23265
  try {
23063
- const promptPromise = session.prompt(`Topic: ${researcherConfig.name}
23064
- Goal: ${researcherConfig.goal}
23065
-
23066
- Perform your research and submit your full report now.`);
23266
+ const promptPromise = session.prompt(userMessage);
23067
23267
  promptPromise.catch((err) => logger.debug(`[ResearcherExecutor] Background session prompt rejection: ${err.message}`));
23068
23268
  await Promise.race([
23069
23269
  promptPromise,
@@ -23167,6 +23367,7 @@ init_logger();
23167
23367
  init_config();
23168
23368
  init_text_utils();
23169
23369
  init_constants();
23370
+ init_research_model_resolver();
23170
23371
 
23171
23372
  // src/orchestration/headless-observer.ts
23172
23373
  init_logger();
@@ -23362,7 +23563,10 @@ var QuickResearchOrchestrator = class {
23362
23563
  }
23363
23564
  }
23364
23565
  const researcherPromptTemplate = loadPrompt("researcher");
23365
- 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);
23366
23570
  const maxScrapeBatchesDisplay = maxScrapeBatches > 99 ? "unlimited" : maxScrapeBatches.toString();
23367
23571
  const quickEvidenceSection = `## Search
23368
23572
  You have access to the \`search\` tool. You get EXACTLY ONE search call \u2014 make it count.
@@ -23385,9 +23589,19 @@ Prioritize primary sources and authoritative data.`;
23385
23589
  evidenceLines.push("## Initial Links\nInvestigate these provided URLs first:\n" + this.options.initialLinks.map((l) => `- ${l}`).join("\n"));
23386
23590
  }
23387
23591
  evidenceLines.push(quickEvidenceSection);
23388
- 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");
23389
23600
  logger.debug(`[QuickOrchestrator] System Prompt:
23390
- ${prompt}`);
23601
+ ${prompt}
23602
+
23603
+ User Message:
23604
+ ${userMessage}`);
23391
23605
  let lastSeenSearchCount = 0;
23392
23606
  const { session, resolvedModel } = await createResearcherSession({
23393
23607
  cwd: ctx.cwd,
@@ -23481,7 +23695,7 @@ ${prompt}`);
23481
23695
  });
23482
23696
  let abortCleanup;
23483
23697
  try {
23484
- const promptPromise = session.prompt(query);
23698
+ const promptPromise = session.prompt(userMessage);
23485
23699
  promptPromise.catch((err) => logger.debug(`[QuickOrchestrator] Background session prompt rejection: ${err.message}`));
23486
23700
  await Promise.race([
23487
23701
  promptPromise,
@@ -23898,7 +24112,6 @@ var DeepResearchOrchestrator = class {
23898
24112
  if (!routerChoseSynthesis) {
23899
24113
  if (this.currentRound > lastAnnouncedRound) {
23900
24114
  observer?.onRoundStart?.(this.currentRound);
23901
- lastAnnouncedRound = this.currentRound;
23902
24115
  }
23903
24116
  observer?.onEvaluationStart?.(this.currentRound);
23904
24117
  observer?.onEvaluationProgress?.("evaluating");
@@ -24632,6 +24845,10 @@ init_service_registry();
24632
24845
  init_service_names();
24633
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._";
24634
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
+ }
24635
24852
  function redactUnverifiedProseUrls(text, trusted) {
24636
24853
  if (trusted.size === 0) return text;
24637
24854
  let removed = 0;
@@ -24874,6 +25091,14 @@ Research performed using ${modelId}`;
24874
25091
  body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
24875
25092
  }
24876
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
+ }
24877
25102
  return `${body}
24878
25103
 
24879
25104
  ${verifiedLinksSection}`;
@@ -24882,6 +25107,14 @@ ${verifiedLinksSection}`;
24882
25107
  const validIds = new Set(globalCitations.map((c) => c.id));
24883
25108
  let prose = rewriteCitationMarkers(synthesis.trim(), /* @__PURE__ */ new Map(), (n) => !validIds.has(n));
24884
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
+ }
24885
25118
  return `${prose}
24886
25119
 
24887
25120
  ${verifiedLinksSection}`;
@@ -25116,6 +25349,8 @@ function extractRunStats(snapshot) {
25116
25349
  };
25117
25350
  const errors = sumCounter(counters, "researcher_errors_total") + sumCounter(counters, "llm_api_errors_total");
25118
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");
25119
25354
  const cost = sumCounter(counters, "llm_cost_total");
25120
25355
  let durationMs = 0;
25121
25356
  for (const key of Object.keys(histograms)) {
@@ -25150,6 +25385,8 @@ function extractRunStats(snapshot) {
25150
25385
  urlsFailed,
25151
25386
  errors,
25152
25387
  tokens,
25388
+ cacheReadTokens,
25389
+ cacheWriteTokens,
25153
25390
  cost,
25154
25391
  toolUsage
25155
25392
  };
@@ -25181,6 +25418,7 @@ var _onSignal = null;
25181
25418
  var _sigintHandler = null;
25182
25419
  var _sigtermHandler = null;
25183
25420
  var _sighupHandler = null;
25421
+ var _sigquitHandler = null;
25184
25422
  var _sigbreakHandler = null;
25185
25423
  var _shuttingDown = false;
25186
25424
  var _shutdownPromise = null;
@@ -25201,9 +25439,11 @@ function _registerSignalHandlers() {
25201
25439
  _sigintHandler = () => _onSignal?.("SIGINT");
25202
25440
  _sigtermHandler = () => _onSignal?.("SIGTERM");
25203
25441
  _sighupHandler = () => _onSignal?.("SIGHUP");
25442
+ _sigquitHandler = () => _onSignal?.("SIGQUIT");
25204
25443
  process.on("SIGINT", _sigintHandler);
25205
25444
  process.on("SIGTERM", _sigtermHandler);
25206
25445
  process.on("SIGHUP", _sighupHandler);
25446
+ process.on("SIGQUIT", _sigquitHandler);
25207
25447
  if (process.platform === "win32") {
25208
25448
  _sigbreakHandler = () => _onSignal?.("SIGBREAK");
25209
25449
  process.on("SIGBREAK", _sigbreakHandler);
@@ -25222,6 +25462,10 @@ function _removeSignalHandlers() {
25222
25462
  process.removeListener("SIGHUP", _sighupHandler);
25223
25463
  _sighupHandler = null;
25224
25464
  }
25465
+ if (_sigquitHandler) {
25466
+ process.removeListener("SIGQUIT", _sigquitHandler);
25467
+ _sigquitHandler = null;
25468
+ }
25225
25469
  if (_sigbreakHandler) {
25226
25470
  process.removeListener("SIGBREAK", _sigbreakHandler);
25227
25471
  _sigbreakHandler = null;
@@ -25303,6 +25547,10 @@ async function _doInit(options = {}) {
25303
25547
  }
25304
25548
  }
25305
25549
  async function initResearchSDK(options = {}) {
25550
+ if (_shutdownPromise) {
25551
+ await _shutdownPromise.catch(() => {
25552
+ });
25553
+ }
25306
25554
  if (isInitialized) {
25307
25555
  logger.warn("[SDK] Research SDK already initialized. Call shutdownResearchSDK() first if you want to re-initialize with new options.");
25308
25556
  return;
@@ -25653,7 +25901,7 @@ init_logger();
25653
25901
  init_constants();
25654
25902
  import { promises as fs15 } from "node:fs";
25655
25903
  import { homedir as homedir4, tmpdir as tmpdir4, platform as platform3 } from "node:os";
25656
- import { join as join19, resolve as resolve5, normalize, sep as sep2 } from "node:path";
25904
+ import { join as join20, resolve as resolve5, normalize, sep as sep2 } from "node:path";
25657
25905
  import { randomBytes as randomBytes6 } from "node:crypto";
25658
25906
  var PREFERRED_SUBDIRS = [
25659
25907
  "research",
@@ -25724,7 +25972,7 @@ async function findPreferredSubdir(cwd) {
25724
25972
  for (const target of PREFERRED_SUBDIRS) {
25725
25973
  const match = entries.find((e) => e.toLowerCase() === target);
25726
25974
  if (match === void 0) continue;
25727
- const candidate = join19(cwd, match);
25975
+ const candidate = join20(cwd, match);
25728
25976
  try {
25729
25977
  const stat6 = await fs15.stat(candidate);
25730
25978
  if (stat6.isDirectory()) return candidate;
@@ -25769,7 +26017,7 @@ async function exportResearchReport(query, result, _mode, cwd, explicitDir) {
25769
26017
  for (let attempt = 0; attempt < MAX_EXPORT_RETRIES; attempt++) {
25770
26018
  const hash = generateHash();
25771
26019
  const filename = `${baseFilename}-${hash}.md`;
25772
- const filepath = join19(targetDir, filename);
26020
+ const filepath = join20(targetDir, filename);
25773
26021
  try {
25774
26022
  await fs15.writeFile(filepath, result, { flag: "wx" });
25775
26023
  logger.log(`[export] Research report saved to: ${filepath}`);
@@ -25904,10 +26152,17 @@ function makeProgressObserver() {
25904
26152
  }
25905
26153
  break;
25906
26154
  }
25907
- case "complete":
26155
+ case "complete": {
25908
26156
  toStderr(` \u2022 complete (${data?.result?.length ?? 0} chars)
25909
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
+ }
25910
26164
  break;
26165
+ }
25911
26166
  case "error":
25912
26167
  toStderr(` \u2022 error: ${data?.message}
25913
26168
  `);
@@ -25966,8 +26221,8 @@ function resolvedConfigPaths() {
25966
26221
  configDir,
25967
26222
  cliIfaceEnv: getInterfaceEnvFilePath("cli"),
25968
26223
  piIfaceEnv: getInterfaceEnvFilePath("pi"),
25969
- piAuth: path18.join(agentDir, "auth.json"),
25970
- piModels: path18.join(agentDir, "models.json"),
26224
+ piAuth: path19.join(agentDir, "auth.json"),
26225
+ piModels: path19.join(agentDir, "models.json"),
25971
26226
  piState: getStateDir()
25972
26227
  };
25973
26228
  }
@@ -26223,7 +26478,7 @@ Configure in:
26223
26478
  config: getConfig(process.cwd(), "cli"),
26224
26479
  ignoreGlobalConfig: true
26225
26480
  });
26226
- 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
26227
26482
  `);
26228
26483
  result = await searchKnowledge(queries, abortController.signal);
26229
26484
  } catch (err) {
@@ -26881,9 +27136,15 @@ async function main(argv) {
26881
27136
  process.on("SIGINT", () => onSignal("SIGINT"));
26882
27137
  process.on("SIGTERM", () => onSignal("SIGTERM"));
26883
27138
  process.on("SIGHUP", () => onSignal("SIGHUP"));
27139
+ process.on("SIGQUIT", () => onSignal("SIGQUIT"));
26884
27140
  if (process.platform === "win32") {
26885
27141
  process.on("SIGBREAK", () => onSignal("SIGBREAK"));
26886
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
+ }
26887
27148
  let parsed;
26888
27149
  try {
26889
27150
  parsed = parseArgs(argv);
@@ -26940,8 +27201,8 @@ Error: ${err.message}
26940
27201
  var _isMain = (() => {
26941
27202
  try {
26942
27203
  if (!process.argv[1]) return false;
26943
- const modulePath = fs17.realpathSync(path18.resolve(fileURLToPath5(import.meta.url)));
26944
- const invokedPath = fs17.realpathSync(path18.resolve(process.argv[1]));
27204
+ const modulePath = fs17.realpathSync(path19.resolve(fileURLToPath5(import.meta.url)));
27205
+ const invokedPath = fs17.realpathSync(path19.resolve(process.argv[1]));
26945
27206
  return modulePath === invokedPath;
26946
27207
  } catch {
26947
27208
  return false;
@@ -26968,6 +27229,7 @@ export {
26968
27229
  cmdKnowledgeConfig,
26969
27230
  cmdResearch,
26970
27231
  exitCodeForSignal,
27232
+ makeProgressObserver,
26971
27233
  parseArgs,
26972
27234
  reportError
26973
27235
  };