@lincoln504/pi-research 1.4.1 → 1.4.3

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 (34) hide show
  1. package/dist/cli.mjs +271 -156
  2. package/dist/thread-worker.mjs +91 -59
  3. package/docs/CHANGELOG.md +76 -0
  4. package/docs/CONFIGURATION.md +1 -1
  5. package/package.json +1 -1
  6. package/skills/pi-research/SKILL.md +1 -1
  7. package/src/config.ts +10 -4
  8. package/src/core/planning-service.ts +55 -8
  9. package/src/infrastructure/browser/browser-task-scheduler.ts +16 -9
  10. package/src/infrastructure/browser/config.ts +40 -2
  11. package/src/infrastructure/browser/thread-worker-browser.ts +69 -48
  12. package/src/infrastructure/browser/thread-worker-messaging.ts +61 -4
  13. package/src/infrastructure/browser/thread-worker.mjs +91 -59
  14. package/src/infrastructure/browser/thread-worker.ts +13 -5
  15. package/src/infrastructure/embedding/embedding-server.ts +34 -0
  16. package/src/knowledge/embedder-init.ts +3 -2
  17. package/src/knowledge/embedder.ts +11 -3
  18. package/src/knowledge/index.ts +36 -10
  19. package/src/knowledge/store.ts +29 -5
  20. package/src/orchestration/deep-research-orchestrator.ts +3 -1
  21. package/src/orchestration/session-state.ts +16 -0
  22. package/src/sdk.ts +10 -0
  23. package/src/skill-install/skill-installer.ts +5 -1
  24. package/src/stackexchange/index.ts +3 -0
  25. package/src/stackexchange/rest-client.ts +7 -12
  26. package/src/tools/research-tool-definition.ts +38 -7
  27. package/src/tools/security.ts +13 -10
  28. package/src/utils/coverage-digest.ts +1 -1
  29. package/src/utils/host-config.ts +12 -1
  30. package/src/utils/log-utils.ts +10 -0
  31. package/src/web-research/browser-search.ts +11 -4
  32. package/src/web-research/web-scraper.ts +27 -16
  33. package/src/youtube/potoken.ts +8 -1
  34. package/src/youtube/transcript-client.ts +56 -5
@@ -71,6 +71,8 @@ function redactSecrets(message) {
71
71
  }
72
72
  if (out.length > MAX_LOG_MESSAGE_LENGTH) {
73
73
  out = `${out.slice(0, keep)}\u2026[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
74
+ } else if (rawLength > scanEnd) {
75
+ out = `${out}\u2026[truncated ${rawLength - scanEnd} chars]`;
74
76
  }
75
77
  return out;
76
78
  }
@@ -247,7 +249,7 @@ var init_error_tracker = __esm({
247
249
  * @param error - The error to track (Error object, string message, or unknown)
248
250
  * @param context - Optional context information about where the error occurred
249
251
  */
250
- trackError(error, context2 = {}) {
252
+ trackError(error, context = {}) {
251
253
  let message;
252
254
  if (error instanceof Error) {
253
255
  message = error.message;
@@ -276,15 +278,15 @@ var init_error_tracker = __esm({
276
278
  }
277
279
  pattern.count++;
278
280
  pattern.lastSeen = now;
279
- if (context2.domain) {
280
- const existing = pattern.domainCounts.get(context2.domain);
281
+ if (context.domain) {
282
+ const existing = pattern.domainCounts.get(context.domain);
281
283
  if (existing !== void 0) {
282
- pattern.domainCounts.set(context2.domain, existing + 1);
284
+ pattern.domainCounts.set(context.domain, existing + 1);
283
285
  } else if (pattern.domainCounts.size < this.MAX_DOMAINS_PER_PATTERN) {
284
- pattern.domainCounts.set(context2.domain, 1);
286
+ pattern.domainCounts.set(context.domain, 1);
285
287
  }
286
288
  }
287
- pattern.contexts.push(context2);
289
+ pattern.contexts.push(context);
288
290
  if (pattern.contexts.length > this.MAX_CONTEXTS_PER_PATTERN) {
289
291
  pattern.contexts.shift();
290
292
  }
@@ -1109,8 +1111,8 @@ var init_logger = __esm({
1109
1111
  error(...args) {
1110
1112
  const errArg = args.find((arg) => arg instanceof Error);
1111
1113
  if (errArg) {
1112
- const context2 = getLogContext();
1113
- errorTracker.trackError(errArg, context2);
1114
+ const context = getLogContext();
1115
+ errorTracker.trackError(errArg, context);
1114
1116
  }
1115
1117
  this.emit("ERROR" /* ERROR */, ...args);
1116
1118
  }
@@ -2025,7 +2027,7 @@ var workerId2 = "";
2025
2027
  var pageCreationLock = Promise.resolve();
2026
2028
  var PAGE_CREATION_HARD_RELEASE_MS = 5 * 60 * 1e3;
2027
2029
  var wedgedContext = null;
2028
- async function createPageSafe(context2) {
2030
+ async function createPageSafe(context) {
2029
2031
  let release;
2030
2032
  const nextLock = new Promise((resolve) => {
2031
2033
  release = resolve;
@@ -2033,11 +2035,11 @@ async function createPageSafe(context2) {
2033
2035
  const currentLock = pageCreationLock;
2034
2036
  pageCreationLock = currentLock.then(() => nextLock);
2035
2037
  await currentLock;
2036
- if (context2 === wedgedContext) {
2038
+ if (context === wedgedContext) {
2037
2039
  release();
2038
2040
  throw new Error("Browser context is wedged from a prior page-creation call that never settled; a new browser context is required.");
2039
2041
  }
2040
- const pagePromise = context2.newPage();
2042
+ const pagePromise = context.newPage();
2041
2043
  let hardReleased = false;
2042
2044
  const releaseOnce = () => {
2043
2045
  if (hardReleased) return;
@@ -2047,17 +2049,17 @@ async function createPageSafe(context2) {
2047
2049
  };
2048
2050
  const hardReleaseTimer = setTimeout(() => {
2049
2051
  logToDebugFile2("WARN", "[ThreadWorker] page-creation mutex force-released \u2014 newPage() did not settle within the hard timeout");
2050
- wedgedContext = context2;
2052
+ wedgedContext = context;
2051
2053
  releaseOnce();
2052
2054
  }, PAGE_CREATION_HARD_RELEASE_MS);
2053
2055
  safeUnref(hardReleaseTimer);
2054
2056
  pagePromise.then(
2055
2057
  () => {
2056
- if (wedgedContext === context2) wedgedContext = null;
2058
+ if (wedgedContext === context) wedgedContext = null;
2057
2059
  releaseOnce();
2058
2060
  },
2059
2061
  () => {
2060
- if (wedgedContext === context2) wedgedContext = null;
2062
+ if (wedgedContext === context) wedgedContext = null;
2061
2063
  releaseOnce();
2062
2064
  }
2063
2065
  );
@@ -2119,12 +2121,12 @@ function logToDebugFile2(level, ...args) {
2119
2121
  } catch {
2120
2122
  }
2121
2123
  }
2122
- async function setupMocking(context2) {
2124
+ async function setupMocking(context) {
2123
2125
  const mockSearch = process.env["PI_RESEARCH_MOCK_SEARCH"] === "true";
2124
2126
  const mockScrape = process.env["PI_RESEARCH_MOCK_SCRAPE"] === "true";
2125
2127
  if (!mockSearch && !mockScrape) return;
2126
2128
  logToDebugFile2("INFO", `[Worker-${workerId2}] Setting up browser mocking (Search: ${mockSearch}, Scrape: ${mockScrape})`);
2127
- await context2.route("**", (route, request) => {
2129
+ await context.route("**", (route, request) => {
2128
2130
  const urlStr = request.url();
2129
2131
  let url;
2130
2132
  try {
@@ -2297,6 +2299,7 @@ async function executeScrapeTask(_context, url, signal) {
2297
2299
  }
2298
2300
  let poisonedError = null;
2299
2301
  const pendingAddrChecks = [];
2302
+ let oversizedPdfError = null;
2300
2303
  try {
2301
2304
  logToDebugFile2("DEBUG", `[Worker-${workerId2}] Starting scrape for: ${url}`);
2302
2305
  const { validateUrlForSSRF: validateForWorker, validateUrlForSSRFSync: validateForWorkerSync } = await Promise.resolve().then(() => (init_scraper_utils(), scraper_utils_exports));
@@ -2328,6 +2331,20 @@ async function executeScrapeTask(_context, url, signal) {
2328
2331
  try {
2329
2332
  if (resp.request().resourceType() === "document" && resp.frame() === page.mainFrame()) {
2330
2333
  finalMainFrameAddr = resp.serverAddr().catch(() => null);
2334
+ const check2 = Promise.resolve(resp.headerValue("content-type")).then(async (ct) => {
2335
+ const looksLikePdf = ct?.includes("application/pdf") || url.toLowerCase().endsWith(".pdf");
2336
+ if (!looksLikePdf || oversizedPdfError || poisonedError) return;
2337
+ const declared = parseInt(await resp.headerValue("content-length").catch(() => null) || "", 10);
2338
+ if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
2339
+ const sizeMB = Math.round(declared / 1024 / 1024);
2340
+ oversizedPdfError = new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
2341
+ logToDebugFile2("DEBUG", `[Worker-${workerId2}] Aborting oversized PDF (${sizeMB}MB declared) before download completes: ${url}`);
2342
+ page.close().catch(() => {
2343
+ });
2344
+ }
2345
+ }).catch(() => {
2346
+ });
2347
+ pendingAddrChecks.push(check2);
2331
2348
  }
2332
2349
  const check = Promise.resolve(resp.serverAddr()).then((addr) => {
2333
2350
  const ip = addr?.ipAddress;
@@ -2357,7 +2374,7 @@ async function executeScrapeTask(_context, url, signal) {
2357
2374
  }
2358
2375
  const contentType = await response?.headerValue("content-type") || "";
2359
2376
  const status = response?.status?.() ?? 0;
2360
- if (contentType.includes("application/pdf")) {
2377
+ if (contentType.includes("application/pdf") || url.toLowerCase().endsWith(".pdf")) {
2361
2378
  if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
2362
2379
  if (status >= 400) throw new Error(`HTTP ${status}`);
2363
2380
  const declaredLength = parseInt(await response.headerValue("content-length").catch(() => null) || "", 10);
@@ -2365,6 +2382,7 @@ async function executeScrapeTask(_context, url, signal) {
2365
2382
  const sizeMB = Math.round(declaredLength / 1024 / 1024);
2366
2383
  throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
2367
2384
  }
2385
+ if (oversizedPdfError) throw oversizedPdfError;
2368
2386
  const buffer = await response.body();
2369
2387
  if (buffer.byteLength > MAX_PDF_SIZE) {
2370
2388
  const sizeMB = Math.round(buffer.byteLength / 1024 / 1024);
@@ -2422,6 +2440,7 @@ async function executeScrapeTask(_context, url, signal) {
2422
2440
  return { contentType, html, jitter };
2423
2441
  } catch (error) {
2424
2442
  await page.close().catch((err) => logToDebugFile2("DEBUG", `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
2443
+ if (oversizedPdfError) throw oversizedPdfError;
2425
2444
  if (poisonedError) throw poisonedError;
2426
2445
  throw error;
2427
2446
  } finally {
@@ -2566,10 +2585,16 @@ var ConfigSchema = Type.Object({
2566
2585
  SEARCH_TIMEOUT_MS: Type.Number({ minimum: 5e3, maximum: 12e4, default: 45e3 }),
2567
2586
  /** TUI refresh debounce in milliseconds (default: 100ms) */
2568
2587
  TUI_REFRESH_DEBOUNCE_MS: Type.Number({ minimum: 0, maximum: 1e3, default: 100 }),
2569
- /** Queue-wait / overhead margin (ms) added on top of each browser operation's own nav
2570
- * timeout to form its scheduler task-timeout ceiling: a search task times out after
2571
- * SEARCH_TIMEOUT_MS + this, a scrape after SCRAPE_TIMEOUT_MS + this. Sized so a task is
2572
- * never killed before it has used its full nav budget plus time waiting in the queue.
2588
+ /** Overhead margin (ms) added on top of each browser operation's own nav timeout to
2589
+ * form its scheduler task-timeout ceiling: a search task times out after
2590
+ * SEARCH_TIMEOUT_MS + this, a scrape after SCRAPE_TIMEOUT_MS + this — covering
2591
+ * per-task work beyond raw navigation (page setup, result parsing). Queue wait is
2592
+ * NOT part of this margin: the deadline is armed on dispatch, not on enqueue, so
2593
+ * time spent waiting for a free worker never counts against it (see
2594
+ * browser-task-scheduler.ts). Cold browser-launch/context-creation time is also
2595
+ * separate from this margin — COLD_START_ALLOWANCE_MS
2596
+ * (infrastructure/browser/config.ts) covers that unconditionally on top of it,
2597
+ * since it isn't something a user should need to tune.
2573
2598
  * (default: 10000) */
2574
2599
  BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2e3, maximum: 12e4, default: 1e4 }),
2575
2600
  /** Timeout for coordinator/research-lead/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
@@ -2669,10 +2694,12 @@ function resolveHeadlessMode() {
2669
2694
  return true;
2670
2695
  }
2671
2696
  var HEALTHCHECK_MIN_BUDGET_MS = 45e3 + 6e4;
2697
+ var BROWSER_LAUNCH_TIMEOUT_MS = 9e4;
2698
+ var CONTEXT_CREATION_TIMEOUT_MS = 3e4;
2699
+ var COLD_START_ALLOWANCE_MS = BROWSER_LAUNCH_TIMEOUT_MS + CONTEXT_CREATION_TIMEOUT_MS;
2672
2700
 
2673
2701
  // src/infrastructure/browser/thread-worker-browser.ts
2674
2702
  var browser = null;
2675
- var context = null;
2676
2703
  var initPromise = null;
2677
2704
  var workerId3 = "";
2678
2705
  var activeTaskCount = 0;
@@ -2726,11 +2753,11 @@ function logToDebugFile3(level, ...args) {
2726
2753
  }
2727
2754
  }
2728
2755
  async function initBrowser() {
2729
- if (isBrowserConnected() && context) return;
2756
+ if (isBrowserConnected()) return;
2730
2757
  if (initPromise) return initPromise;
2731
2758
  initPromise = (async () => {
2732
2759
  try {
2733
- if (!isBrowserConnected() || !context) {
2760
+ if (!isBrowserConnected()) {
2734
2761
  logToDebugFile3("INFO", `[Worker-${workerId3}] Initializing browser instance...`);
2735
2762
  let CamoufoxModule;
2736
2763
  try {
@@ -2739,7 +2766,7 @@ async function initBrowser() {
2739
2766
  throw new Error(`[Worker] camoufox-js not found in node_modules. Please run 'npm install'. Original error: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
2740
2767
  }
2741
2768
  const { Camoufox } = CamoufoxModule;
2742
- const launchTimeoutMs = 9e4;
2769
+ const launchTimeoutMs = BROWSER_LAUNCH_TIMEOUT_MS;
2743
2770
  const launchOnce = async (headless) => {
2744
2771
  const launchPromise = Camoufox({
2745
2772
  headless,
@@ -2822,30 +2849,6 @@ async function initBrowser() {
2822
2849
  }
2823
2850
  }
2824
2851
  browser = launchedBrowser;
2825
- const contextPromise = browser.newContext();
2826
- contextPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser context rejection: ${err.message}`));
2827
- let contextTimeoutId;
2828
- let contextRaceLost = false;
2829
- contextPromise.then((c) => {
2830
- if (!contextRaceLost) return;
2831
- logToDebugFile3("WARN", `[Worker-${workerId3}] Browser context creation completed after its deadline; closing the orphaned context.`);
2832
- try {
2833
- c?.close?.();
2834
- } catch {
2835
- }
2836
- }).catch(() => {
2837
- });
2838
- context = await Promise.race([
2839
- contextPromise,
2840
- new Promise((_, reject) => {
2841
- contextTimeoutId = setTimeout(() => {
2842
- contextRaceLost = true;
2843
- reject(new Error("Browser context creation timed out after 30000ms"));
2844
- }, 3e4);
2845
- })
2846
- ]);
2847
- if (contextTimeoutId) clearTimeout(contextTimeoutId);
2848
- await setupMocking(context);
2849
2852
  logToDebugFile3("INFO", `[Worker-${workerId3}] Browser initialized.`);
2850
2853
  }
2851
2854
  } catch (e) {
@@ -2853,7 +2856,6 @@ async function initBrowser() {
2853
2856
  Promise.resolve(browser.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed browser close error during failed init: ${err.message}`));
2854
2857
  }
2855
2858
  browser = null;
2856
- context = null;
2857
2859
  const msg = e instanceof Error ? e.message : String(e);
2858
2860
  if (msg.includes("Camoufox is not installed") || msg.includes("Version information not found")) {
2859
2861
  throw new Error(`[Worker] Browser binaries not found. Please run 'npx camoufox-js fetch' to install them.`, { cause: e });
@@ -2872,13 +2874,39 @@ async function initBrowser() {
2872
2874
  })();
2873
2875
  return initPromise;
2874
2876
  }
2875
- function getContext() {
2876
- return context;
2877
+ async function acquireTaskContext() {
2878
+ const contextPromise = browser.newContext();
2879
+ contextPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser context rejection: ${err.message}`));
2880
+ let contextTimeoutId;
2881
+ let contextRaceLost = false;
2882
+ contextPromise.then((c) => {
2883
+ if (!contextRaceLost) return;
2884
+ logToDebugFile3("WARN", `[Worker-${workerId3}] Browser context creation completed after its deadline; closing the orphaned context.`);
2885
+ try {
2886
+ c?.close?.();
2887
+ } catch {
2888
+ }
2889
+ }).catch(() => {
2890
+ });
2891
+ let newContext;
2892
+ try {
2893
+ newContext = await Promise.race([
2894
+ contextPromise,
2895
+ new Promise((_, reject) => {
2896
+ contextTimeoutId = setTimeout(() => {
2897
+ contextRaceLost = true;
2898
+ reject(new Error(`Browser context creation timed out after ${CONTEXT_CREATION_TIMEOUT_MS}ms`));
2899
+ }, CONTEXT_CREATION_TIMEOUT_MS);
2900
+ })
2901
+ ]);
2902
+ } finally {
2903
+ if (contextTimeoutId) clearTimeout(contextTimeoutId);
2904
+ }
2905
+ await setupMocking(newContext);
2906
+ return newContext;
2877
2907
  }
2878
2908
  function doResetBrowser() {
2879
- if (context) Promise.resolve(context.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed context close error during reset: ${err.message}`));
2880
2909
  if (browser) Promise.resolve(browser.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed browser close error during reset: ${err.message}`));
2881
- context = null;
2882
2910
  browser = null;
2883
2911
  }
2884
2912
  function resetBrowser() {
@@ -2892,7 +2920,6 @@ function resetBrowser() {
2892
2920
  async function cleanupBrowser2() {
2893
2921
  const timeoutMs = 2e3;
2894
2922
  const cleanup = async () => {
2895
- if (context) await Promise.resolve(context.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed context close error during cleanup: ${err.message}`));
2896
2923
  if (browser) await Promise.resolve(browser.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed browser close error during cleanup: ${err.message}`));
2897
2924
  };
2898
2925
  try {
@@ -2902,7 +2929,6 @@ async function cleanupBrowser2() {
2902
2929
  ]);
2903
2930
  } catch {
2904
2931
  } finally {
2905
- context = null;
2906
2932
  browser = null;
2907
2933
  }
2908
2934
  }
@@ -2951,20 +2977,22 @@ async function runTask(data) {
2951
2977
  taskTimer = setTimeout(() => abortController.abort(new Error(`Task timed out after ${taskTimeoutMs}ms`)), taskTimeoutMs);
2952
2978
  }
2953
2979
  taskStarted();
2980
+ let taskContext;
2954
2981
  try {
2955
2982
  await initBrowser();
2956
2983
  const initMs = Date.now() - startTime;
2984
+ taskContext = await acquireTaskContext();
2957
2985
  let result;
2958
2986
  if (type === "search") {
2959
2987
  if (!query) throw new Error("Search task requires a query");
2960
- const searchResult = await executeSearchTask(getContext(), query, abortController.signal);
2988
+ const searchResult = await executeSearchTask(taskContext, query, abortController.signal);
2961
2989
  result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
2962
2990
  } else if (type === "scrape") {
2963
2991
  if (!url) throw new Error("Scrape task requires a URL");
2964
- const scrapeResult = await executeScrapeTask(getContext(), url, abortController.signal);
2992
+ const scrapeResult = await executeScrapeTask(taskContext, url, abortController.signal);
2965
2993
  result = { ...scrapeResult, duration: Date.now() - startTime };
2966
2994
  } else if (type === "healthcheck") {
2967
- const healthResult = await executeHealthCheck(getContext(), initMs, abortController.signal);
2995
+ const healthResult = await executeHealthCheck(taskContext, initMs, abortController.signal);
2968
2996
  result = { ...healthResult, duration: Date.now() - startTime };
2969
2997
  } else {
2970
2998
  result = { error: "Unknown task type", duration: Date.now() - startTime };
@@ -2987,6 +3015,10 @@ async function runTask(data) {
2987
3015
  };
2988
3016
  } finally {
2989
3017
  if (taskTimer !== void 0) clearTimeout(taskTimer);
3018
+ if (taskContext) {
3019
+ Promise.resolve(taskContext.close()).catch(() => {
3020
+ });
3021
+ }
2990
3022
  taskFinished();
2991
3023
  }
2992
3024
  }
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,82 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [1.4.3] - 2026-08-19
11
+
12
+ v1.4.2 was tagged and pushed but never published: the release workflow's
13
+ `publish` job restructuring below (removing `actions/checkout` entirely, to
14
+ close the OIDC-exposure gap) also removed `.nvmrc`, which `actions/setup-node`
15
+ needs on disk — the job failed at the very first step, before ever reaching
16
+ `npm publish`. No package was published under 1.4.2; this is that fix,
17
+ re-tagged as 1.4.3.
18
+
19
+ ### Fixed
20
+
21
+ - **The `publish` job failed immediately: `actions/setup-node`'s `node-version-file: '.nvmrc'` requires that file on disk, and the job no longer checked out the repository at all.** Restored via a minimal sparse checkout of just `.nvmrc` — checkout itself executes no dependency code, so the actual guarantee the OIDC-exposure fix established (no `npm ci`/`install`, hence no untrusted lifecycle scripts, in the credential-holding job) is unaffected.
22
+
23
+ ## [1.4.2] - 2026-08-19 (tagged, never published — see 1.4.3)
24
+
25
+ Five further fresh-eyes audit rounds on top of 1.4.1, covering subsystems the
26
+ 1.4.1 audit hadn't reached: scripts/config/CLI, knowledge-store/embedding, and
27
+ browser/network first; then orchestration router/synthesizer, locks/scheduler,
28
+ tools, repo hygiene; then knowledge/embedding, CLI/SDK, security/YouTube
29
+ internals, web-scraper/PDF, and CI/release scripts again from a second pass;
30
+ then a re-audit of the first three rounds' own diffs plus CI/release workflow
31
+ robustness and state/lock lifecycle; then a fifth round covering release/CI
32
+ credential handling end to end, web-scraper redirect classification, tool
33
+ budget/validation/steering-concurrency gaps, embedding-server shutdown vs. its
34
+ own GPU serialization queue, and knowledge-store migration manifest
35
+ persistence.
36
+
37
+ ### Fixed
38
+
39
+ - **Every browser task dispatched to the same worker process shared one Playwright `BrowserContext`**, letting cookies/storage bleed across concurrent, unrelated research sessions (`WORKER_CONCURRENCY` defaults to 2). Never a deliberate isolation trade-off — just an unexamined resource-sharing default, inconsistent with the rest of the codebase's per-session scoping (circuit breakers, scrape caches). Each task now gets its own fresh context via `acquireTaskContext()`, closed in its own `finally`; the browser process itself stays shared.
40
+ - **Knowledge-store corruption auto-recovery wiped the entire shared `knowledge_db` directory** — every project's data, every global entry, and every `_backup_*` safety net — the instant any single workspace hit a corrupted LanceDB manifest. Scoped the recovery to delete only the active table's own directory.
41
+ - **A WASM PDF document leaked native memory whenever both `toMarkdownAll()` and the per-page fallback failed** — the routine case for a malformed/encrypted PDF, in a browser-server process that stays warm across many sessions. `doc.free()` now runs unconditionally in a `finally`.
42
+ - **`PI_RESEARCH_CONFIG_DIR_NAME` (feeds `config.env`/state/`knowledge_db` paths, all under `HOME`) had no path-traversal guard**, unlike `cleanup.cjs`'s own copy of the same resolution — a value like `../../tmp/evil` could silently relocate where API keys get written. Applied the guard both places now share.
43
+ - CPU-fallback re-warmup after a WebGPU failure hardcoded `'mean'` pooling instead of the model's actual `poolingMode` (wrong for bge-m3/Qwen3-Embedding models) — inconsequential for the dummy warmup inference, but inconsistent with every other `warmupPipeline` call site in the embedder. Fixed to use the real pooling mode.
44
+ - Three CodeQL alerts: a predictable `Date.now()`/`Math.random()` temp-dir name in a test (`js/insecure-temporary-file`) replaced with `mkdtemp`; a dead assignment to `lastAnnouncedRound` with no later read; a dead `?? ''` on an already-non-nullable string parameter.
45
+ - **Browser task deadlines never budgeted for a worker's first real Camoufox launch.** Search/scrape/healthcheck timeouts assumed a warm worker, but eager warmup is deliberately disabled, so a normal cold start (up to 120s combined launch + context creation) on a fresh or just-reset worker could be misreported as "worker likely died mid-task", cascading toward unnecessary pool teardown. Added `COLD_START_ALLOWANCE_MS` as a single source of truth, folded into the scheduler's task ceilings, the healthcheck budget, and `browser-search.ts`'s mirrored per-query timeout.
46
+ - **The synthesizer's and router's overflow warnings only checked the reduced corpus against its own budget.** Below `MIN_SYNTHESIS_CORPUS_CHARS` that budget is a fixed floor, not a value derived from the model's context window, so a large global source list or run context could still overflow the real window with no warning. Both now also check the total against the real window directly.
47
+ - **`stackexchange_quota_low_total`/`_exhausted_total` were incremented as a side effect of `getQuotaInfo()`**, a method called up to three times per tool invocation for display — recording one real event as 2-3x the count. Moved the increments to the single gate check that actually observes each event.
48
+ - **The release workflow granted `id-token: write` (the npm OIDC trusted-publishing credential) to the whole workflow**, not just the job that publishes. Every job runs `npm ci` with full lifecycle scripts before its own lint/audit/test steps execute, so a malicious postinstall/prepare script in any dependency could have requested and exchanged that credential from a job that never legitimately needed it. Scoped to only the `publish` job.
49
+ - **A direct browser navigation to an oversized PDF downloaded the whole body before the size cap ever ran.** Playwright's `page.goto()` does not resolve until the full response finishes downloading, so a multi-hundred-MB PDF was pulled entirely into the worker process — risking an OOM kill that strands sibling tasks — before the existing post-goto size check could reject it. `executeScrapeTask` now inspects the main-frame response's Content-Length as soon as headers arrive and closes the page immediately when it already declares an oversized PDF, cutting the download off in flight rather than letting it finish first. A chunked or dishonestly-labelled response still falls through to the existing post-download byte check. The browser scrape layer also now falls back to a `.pdf` URL extension when content-type is missing or mislabelled (e.g. `application/octet-stream`), matching the fetch layer — previously such a response was serialized as HTML, silently losing the document.
50
+ - **The YouTube timedtext fetch validated only the first-hop URL against its trusted-host allowlist**, then followed redirects with default (`follow`) semantics and no revalidation — a 3xx `Location` header could have carried the PoToken-bearing request to an untrusted host. Now follows redirects manually, re-validating each hop against the same allowlist, mirroring `web-scraper.ts`'s existing SSRF redirect pattern.
51
+ - **The YouTube BotGuard attestation-server response was read with a bare `.json()` call** — no `.ok` check and no size cap on an externally-controlled body. Now checks the response status and reads through the codebase's existing `readJsonCapped`.
52
+ - **The embedder's input-truncation guard could split a UTF-16 surrogate pair at the cut boundary**, emitting an invalid lone high surrogate into the tokenizer for text containing an astral character (emoji, rare CJK) near the cap. Backs off one unit, matching the equivalent guard already in `truncateWithMarker`.
53
+ - **`initResearchSDK()` called while a shutdown was still in flight silently no-op'd, believing the SDK was ready.** `isInitialized` only flips to `false` near the end of `_doShutdown()` (after container disposal), so a caller landing in that window hit the "already initialized" branch and returned immediately while the container was still mid-teardown. `initResearchSDK()` now waits out any in-flight shutdown first, mirroring `shutdownResearchSDK()`'s existing symmetric wait on an in-flight init.
54
+ - **A CI job could hang silently for its full timeout window with no diagnosable cause.** `integration-test (ubuntu-latest)` burned its whole 60-minute budget when `npx playwright install-deps firefox` (apt-based) stalled, with no step-level bound to fail it fast. Added `timeout-minutes` to every bare apt-get/brew/choco/npx-network step across `ci.yml` and `release.yml`. A follow-up pass found the same exposure one level up: 6 jobs (including release.yml's OIDC-privileged `publish` job) had no job-level timeout at all, defaulting to GitHub's 6-hour ceiling — bounded all of them.
55
+ - **`validate` (the 3-OS pack/install/execute job) had no `needs:` gate on `lint`/`type-check`**, unlike its sibling jobs `integration-test` and `dep-graph`, which explicitly gate for the documented reason of not burning multi-OS runner minutes on an already-red build. Added the same gate.
56
+ - **The YouTube timedtext/BotGuard fetches (above) used the plain global `fetch`, with no connect-time SSRF pin** — the host allowlist constrains which hostname a redirect can name, but not what that hostname resolves to at the moment the socket opens, and this codebase already treats a resolver answering differently between validation and connect as a real threat elsewhere (the DNS-rebinding backstop in `thread-worker-messaging.ts`, which applies even to already-trusted hosts). Wired in the same `getSsrfSafeFetcher()` dispatcher-paired fetch already used by `web-scraper.ts`.
57
+ - **The release workflow's `publish` job held the npm OIDC trusted-publishing credential (`id-token: write`) for its entire duration, including its own `npm ci`** — which runs full dependency lifecycle scripts *before* any of that same job's lint/audit-gate/type-check/test steps execute. A compromised transitive dependency's postinstall script could have requested and exchanged that credential before a single check had a chance to fail the build — the exact risk `test-install`'s own comment already reasoned about for *other* jobs, just not the one that actually holds the credential. Restructured: `test-install` now also runs the full check suite (lint/audit-gate/type-check/deps:check/test:unit) and uploads the built, verified tarball as an artifact; `publish` (the only job with `id-token: write`) no longer runs `npm ci`, `npm install`, or any other step that executes a dependency's lifecycle scripts — it only downloads that pre-vetted tarball and runs `npm publish` on the file directly, which triggers no lifecycle scripts at all.
58
+ - **`ci.yml`'s `dep-graph` job (`contents: write`) checked out with default `persist-credentials: true`**, writing the write-scoped `GITHUB_TOKEN` into `.git/config` on disk *before* its own `npm ci` (on any cache miss, on every branch push) ran untrusted lifecycle scripts — a compromised dependency's postinstall could `git push` an arbitrary commit straight to the branch with no review, no OIDC exchange needed since the credential was already materialized on disk. Set `persist-credentials: false` on checkout; the token is now only ever pulled into memory in the final commit-and-push step, via an explicit authenticated remote URL.
59
+ - **`ci.yml`'s `validate` job passed a live `GITHUB_TOKEN` to `npm ci` alongside `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`** — but `setup.cjs` checks that skip flag first and returns before ever reaching the GitHub-releases fetch the token exists to authenticate, so every other dependency's postinstall/prepare script in the tree got a live token for no functional reason. Removed.
60
+ - **The fetch-layer scraper classified a response as PDF-vs-HTML using the pre-redirect URL, not the followed one.** `scrapeWithFetch`'s manual redirect loop tracks the real destination in `currentUrl`, but both the pre-download size-cap check and the actual extraction dispatch checked `url.toLowerCase().endsWith('.pdf')` against the stale original parameter. A legitimate large PDF served from a URL without a `.pdf` suffix (common for document-management/CDN download endpoints) that redirects to its real file could be rejected under the 25MB HTML cap instead of the 100MB PDF cap; the reverse case let a non-PDF landing page bypass the smaller HTML cap. Both checks now use `currentUrl`.
61
+ - **`security_search` spent one of the researcher's shared `MAX_GATHERING_CALLS` slots on a request that did zero work** — the same "malformed call forfeits a real call" bug class 1.4.1 fixed for `youtube_transcript`, left in a later gate within this tool. `options.tracker.recordCall()` ran before the semantic checks that reject unknown database names or an unrecognized severity, both of which return without ever querying a database. Moved the budget-charging call after every semantic-validation reject.
62
+ - **A finishing headless (non-TUI) `research` call could wipe a concurrent sibling headless run's queued/active steering messages.** `teardownUi()`'s own steering-clear guard (`getPiActivePanels(...).length <= 1`) is blind to headless runs — `getPiActivePanels` is populated only by the TUI's `registerSessionPanel`, so for headless sessions it always read 0, making the guard a no-op — and the headless `cleanup()` closure then cleared steering a second time, completely unconditionally. Added `getActiveResearchRunCount()` (backed by the `aborts` map, populated by both the TUI and headless branches) as a mode-agnostic concurrency signal, and removed the unconditional clear.
63
+ - **The top-level `research` tool was the one tool in this codebase that never validated its own parameters against its declared schema** — every sibling tool (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `research_knowledge_search`) explicitly re-checks with `Value.Check` before trusting `params`, since the host does not enforce this. `research-tool-definition.ts` cast `params as ResearchParams` with no runtime check, relying entirely on incidental downstream validation. Added the same gate, using a structural-only variant of the schema for the runtime check so the existing, better-messaged `validateInitialLinks()` bounds errors ("at most 20 URLs...", "at most 2048 characters...") still surface instead of being masked by a generic rejection.
64
+ - **`uninstallSkill([])` (an explicit empty tool-id list) was indistinguishable from `uninstallSkill(undefined)` and silently uninstalled everything**, because the gate was `toolIds && toolIds.length && !toolIds.includes(tool)` — `[].length` is falsy, so the whole condition short-circuited to "keep nothing filtered out." No production call site currently passes `[]`, but it was a live trap for any future caller and inconsistent with this file's other list params, where empty means none. Now `toolIds !== undefined` gates the filter, so `undefined` still means "every entry" and `[]` correctly means "none."
65
+ - **`redactSecrets()` could silently drop the tail of an oversized log message with no truncation marker.** The `REDACT_SCAN_LENGTH` (40,000 char) cut discards everything past that point *before* redaction runs, but the final "…[truncated N chars]" marker was only appended when the *post-redaction* length still exceeded `MAX_LOG_MESSAGE_LENGTH` (10,000). A message whose scanned prefix was dominated by content the redaction patterns collapse heavily (many long hex/JWT runs, each replaced by the much shorter `[REDACTED]`) could end up under that threshold even though real content beyond the scan cut was discarded — reading as complete while silently hiding the tail of a large scraped page or provider error body. Now also appends the marker when the scan cut alone truncated the message, even if redaction's own output stays under the length cap.
66
+ - **`EmbeddingServer.shutdown()` could dispose the embedder pipeline while a legitimately slow (not hung) inference was still running on it.** The `SerialQueue` exists specifically to prevent concurrent native GPU calls (a prior segfault class) and deliberately holds its slot past its own 120s soft timeout for exactly this reason — but `shutdown()` runs on ordinary leadership loss too, not just the poisoned-queue path, and its only guard against an in-flight call was `Embedder.dispose()`'s own 5-second drain, an order of magnitude under what the queue itself considers still-healthy work. A leader serving a large `embedMany` batch that legitimately took 20-30 seconds, racing a leadership-check tick, could have its pipeline disposed out from under the still-running call — the exact concurrent-native-access hazard the queue exists to prevent, just triggered by disposal instead of a second call. Added `SerialQueue.waitForIdle()`, awaited before any `disposeEmbedder` shutdown disposes the pipeline.
67
+ - **A re-embed migration could silently orphan its own freshly-migrated data.** After successfully renaming the temp migration directory to the canonical `knowledge.lance` name, `migrationReEmbed` called `saveManifest()` to persist that new name but discarded its (documented, non-throwing) `false`-on-failure return value — unlike the pre-drop save nineteen lines earlier, which does check it. If that save failed (disk pressure, a killed process), the manifest was left pointing at the now-renamed-away temp name; the next process start would find no such directory, silently create a brand-new EMPTY table under it, and strand every re-embedded document in the unreferenced canonical directory with no recovery path. Now checked: on failure, the rename is reverted (restoring the manifest/disk agreement the earlier successful save already established) rather than thrown as a migration failure — throwing here would have tripped the caller's re-embed-failure fallback to the `backup` strategy, which would have needlessly relocated this perfectly good, fully re-embedded data to a `_backup_*` directory over what is fundamentally a single failed file write.
68
+
69
+ ### Deferred
70
+
71
+ - A CLI SIGINT-during-init race — already closed in practice by the `initResearchSDK()` fix above, since `shutdownResearchSDK()` already awaits an in-flight init before tearing down.
72
+ - `scripts/cleanup.cjs`'s hardcoded `.pi` config-dir fallback (vs. the runtime's `getConfigDirName()`, which additionally consults the host `pi` package) — latent, since it only diverges if pi itself ever renames its config directory, and requiring the host package from an uninstall-time script that must never fail was judged not worth the risk for a currently-inert gap.
73
+ - A knowledge-store migration-vs-concurrent-write race (`addDocuments()` not covered by the same cross-process lock `open()` uses) — architecturally complex, needs a dedicated pass.
74
+ - A low-confidence, unreachable-at-current-call-sites race in `clearEmbeddingInstance()`, flagged by CodeQL but not backed by a concrete interleaving — reviewed and deliberately left alone.
75
+
76
+ ### Verified
77
+
78
+ - Every fix carries a regression test proven to fail against the pre-fix code and pass after (stash-and-rerun discipline).
79
+ - Full suites green across all five rounds: unit tests (2726-2747 depending on round), 57 + 103 integration (real browsers, including a live YouTube network fetch), lint, both type-checks, `deps:check`, the production audit gate, the build, and a live end-to-end research run against the built/published CLI.
80
+ - The fourth round's own changes re-verified after the fact: full unit suite (2740), lint, type-check, and `deps:check` all green; both workflow YAML files parse; the transcript-client test suite (which stubs global `fetch`) needed an accompanying `getSsrfSafeFetcher` mock to keep exercising the stub once the fetch path switched to the dispatcher-paired fetch — added, matching the same pattern already used by `web-research`'s own tests.
81
+ - The fifth round's own changes re-verified: full unit suite (2747, up from 2740 — six new regression tests plus a schema-validation test file), lint, both type-checks, `deps:check`, the production build, and the tarball manifest check all green; both workflow YAML files parse. Also fixed in the same round, discovered by the new schema-validation gate itself: the runtime check initially used the full public parameter schema (bounds included), which caught `initialLinks` count/length violations before the existing, better-messaged `validateInitialLinks()` check could — regressing two of its error-message assertions. Split into a structural-only runtime-check schema so those specific messages still win.
82
+ - A test-quality-only fix alongside the production fixes above: `browser-server.test.ts`'s headers-sent-guard test asserted "no unhandledRejection surfaced" after a fixed 25ms wait, which fails OPEN (silently loses coverage, not a flaky red build) on a slow/contended CI runner. Now flushes several real event-loop turns before a longer (100ms) backstop.
83
+
8
84
  ## [1.4.1] - 2026-08-17
9
85
 
10
86
  v1.4.0 was tagged and pushed but never published: CI failed on every commit from the
@@ -102,7 +102,7 @@ Timeouts
102
102
  | `PI_RESEARCH_LLM_TIMEOUT_MS` | `300000` | 60000–1800000 | Coordinator / research-lead / repair / knowledge LLM call timeout. |
103
103
  | `PI_RESEARCH_SCRAPE_TIMEOUT_MS` | `15000` | 5000–120000 | Per-page scrape (page-load) timeout. |
104
104
  | `PI_RESEARCH_SEARCH_TIMEOUT_MS` | `45000` | 5000–120000 | Browser search page timeout. |
105
- | `PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS` | `10000` | 2000–120000 | Overhead margin added to each browser op's own timeout (a search task ceiling is `SEARCH_TIMEOUT_MS` + this; a scrape is `SCRAPE_TIMEOUT_MS` + this). These ceilings bound **execution**: a task's clock starts when a worker picks it up, so time spent queued behind other work is not charged against it and raising this to cover a busy pool is neither needed nor effective. |
105
+ | `PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS` | `10000` | 2000–120000 | Overhead margin added to each browser op's own timeout (a search task ceiling is `SEARCH_TIMEOUT_MS` + this + a fixed ~120s cold-start allowance; a scrape is `SCRAPE_TIMEOUT_MS` + this + the same allowance). The cold-start allowance covers a worker's first real browser launch + context creation, which is not user-tunable. These ceilings bound **execution**: a task's clock starts when a worker picks it up, so time spent queued behind other work is not charged against it and raising this to cover a busy pool is neither needed nor effective. |
106
106
  | `PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS` | `10000` | 2000–120000 | Pre-flight health-check timeout. |
107
107
 
108
108
  LLM output & reasoning
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Web research for your agents with smart and safe tooling + knowledge store",
5
5
  "type": "module",
6
6
  "private": false,
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.4.1", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.4.3", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/src/config.ts CHANGED
@@ -82,10 +82,16 @@ export const ConfigSchema = Type.Object({
82
82
  SEARCH_TIMEOUT_MS: Type.Number({ minimum: 5000, maximum: 120000, default: 45000 }),
83
83
  /** TUI refresh debounce in milliseconds (default: 100ms) */
84
84
  TUI_REFRESH_DEBOUNCE_MS: Type.Number({ minimum: 0, maximum: 1000, default: 100 }),
85
- /** Queue-wait / overhead margin (ms) added on top of each browser operation's own nav
86
- * timeout to form its scheduler task-timeout ceiling: a search task times out after
87
- * SEARCH_TIMEOUT_MS + this, a scrape after SCRAPE_TIMEOUT_MS + this. Sized so a task is
88
- * never killed before it has used its full nav budget plus time waiting in the queue.
85
+ /** Overhead margin (ms) added on top of each browser operation's own nav timeout to
86
+ * form its scheduler task-timeout ceiling: a search task times out after
87
+ * SEARCH_TIMEOUT_MS + this, a scrape after SCRAPE_TIMEOUT_MS + this — covering
88
+ * per-task work beyond raw navigation (page setup, result parsing). Queue wait is
89
+ * NOT part of this margin: the deadline is armed on dispatch, not on enqueue, so
90
+ * time spent waiting for a free worker never counts against it (see
91
+ * browser-task-scheduler.ts). Cold browser-launch/context-creation time is also
92
+ * separate from this margin — COLD_START_ALLOWANCE_MS
93
+ * (infrastructure/browser/config.ts) covers that unconditionally on top of it,
94
+ * since it isn't something a user should need to tune.
89
95
  * (default: 10000) */
90
96
  BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2000, maximum: 120000, default: 10000 }),
91
97
  /** Timeout for coordinator/research-lead/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
@@ -717,14 +717,38 @@ export class PlanningService implements IPlanningService {
717
717
  };
718
718
 
719
719
  const responseText = isRouter
720
- ? await this.callLead({
721
- ...callOptions,
722
- systemPrompt: populate(''),
723
- userMessage: buildRouterMessage(...this.buildRouterEvidence(options, round), runContext),
724
- maxTokens: ROUTER_MAX_TOKENS,
725
- component: 'router',
726
- label: 'router-updatePlanForRound',
727
- })
720
+ ? await (() => {
721
+ const routerMessage = buildRouterMessage(...this.buildRouterEvidence(options, round), runContext);
722
+ // The router carries no reduce mechanism the way synthesizeCorpus does — a
723
+ // routing decision made from PARTITIONED evidence isn't naturally mergeable
724
+ // the way synthesis prose is, and this call's output is a structured JSON
725
+ // delegate plan, not free text. Its input is bounded by team size in the
726
+ // common case (digests for earlier rounds are already capped — see
727
+ // MAX_DIGEST_CHARS), but nothing caps an individual researcher's fresh report
728
+ // body, so a round with a few unusually long reports can still overflow the
729
+ // window with no reduce path to fall back on. Warn rather than fail silently;
730
+ // the provider's own rejection is still the actual backstop, same as the
731
+ // synthesizer's over-budget-after-every-pass case above.
732
+ const window = model.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW;
733
+ const estimatedTokens = routerMessage.length / CHARS_PER_TOKEN + ROUTER_MAX_TOKENS + SYNTHESIS_OVERHEAD_TOKENS;
734
+ if (estimatedTokens > window) {
735
+ logger.warn(
736
+ `[PlanningService] Router request for ${model.id} is an estimated ` +
737
+ `${Math.round(estimatedTokens)} tokens against a ${window}-token context window ` +
738
+ `(evidence ${routerMessage.length} chars, output ceiling ${ROUTER_MAX_TOKENS} tokens). ` +
739
+ `Unlike the synthesizer, the router has no reduce path for an over-budget round — ` +
740
+ `sending it anyway, the provider may reject this request.`,
741
+ );
742
+ }
743
+ return this.callLead({
744
+ ...callOptions,
745
+ systemPrompt: populate(''),
746
+ userMessage: routerMessage,
747
+ maxTokens: ROUTER_MAX_TOKENS,
748
+ component: 'router',
749
+ label: 'router-updatePlanForRound',
750
+ });
751
+ })()
728
752
  : await this.synthesizeCorpus({ ...callOptions, reports, runContext, populate });
729
753
 
730
754
  // Extract and validate JSON
@@ -1115,6 +1139,29 @@ export class PlanningService implements IPlanningService {
1115
1139
  );
1116
1140
  }
1117
1141
 
1142
+ // budgetChars alone can UNDER-report overflow risk once it hits MIN_SYNTHESIS_CORPUS_CHARS:
1143
+ // below that point it is a fixed floor, not a value derived from the window, so a reduced
1144
+ // corpus that fits comfortably under the floor can still combine with a large global source
1145
+ // list and/or run context (neither bounded, both already subtracted from budgetChars but not
1146
+ // from THIS check) to overflow the real window — the same failure class the budget exists to
1147
+ // prevent, just triggered by those two inputs growing instead of the corpus. Check the actual
1148
+ // total request against the actual window directly, independent of any floor.
1149
+ const window = args.model.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW;
1150
+ const estimatedTotalTokens =
1151
+ (finalSize + globalSourceList.length + runContext.length) / CHARS_PER_TOKEN +
1152
+ config.SYNTHESIS_MAX_TOKENS + SYNTHESIS_OVERHEAD_TOKENS;
1153
+ if (estimatedTotalTokens > window) {
1154
+ logger.warn(
1155
+ `[PlanningService] Synthesis request for ${args.model.id} is an estimated ` +
1156
+ `${Math.round(estimatedTotalTokens)} tokens against a ${window}-token context window ` +
1157
+ `(corpus ${finalSize} chars, source list ${globalSourceList.length} chars, run context ` +
1158
+ `${runContext.length} chars, output ceiling ${config.SYNTHESIS_MAX_TOKENS} tokens) — the ` +
1159
+ `source list and/or run context are large enough on their own to overflow the window even ` +
1160
+ `though the reduced corpus fits its budget. Sending it anyway — the provider may reject ` +
1161
+ `this request.`,
1162
+ );
1163
+ }
1164
+
1118
1165
  return this.callLead({
1119
1166
  ...args,
1120
1167
  systemPrompt: populate(reduced ? MERGE_SYNTHESIS_SECTION : ''),