@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
  }
@@ -23,7 +23,7 @@ import {
23
23
  import {
24
24
  setWorkerId,
25
25
  initBrowser,
26
- getContext,
26
+ acquireTaskContext,
27
27
  resetBrowser,
28
28
  cleanupBrowser,
29
29
  taskStarted,
@@ -107,23 +107,28 @@ async function runTask(data: TaskData | undefined): Promise<TaskResult> {
107
107
  // Registered around the whole browser-touching lifetime of this task so
108
108
  // resetBrowser() (below) can tell whether a sibling task dispatched to this
109
109
  // same worker process (WORKER_CONCURRENCY > 1) is still using the shared
110
- // browser/context before tearing it down.
110
+ // browser before tearing it down.
111
111
  taskStarted();
112
+ // Task-owned, not module-owned: acquireTaskContext() below hands back a
113
+ // FRESH BrowserContext for this task alone, closed in the finally below —
114
+ // never shared with a concurrent or later sibling task.
115
+ let taskContext: any;
112
116
  try {
113
117
  await initBrowser();
114
118
  const initMs = Date.now() - startTime;
119
+ taskContext = await acquireTaskContext();
115
120
 
116
121
  let result: any;
117
122
  if (type === 'search') {
118
123
  if (!query) throw new Error('Search task requires a query');
119
- const searchResult = await executeSearchTask(getContext(), query, abortController.signal);
124
+ const searchResult = await executeSearchTask(taskContext, query, abortController.signal);
120
125
  result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
121
126
  } else if (type === 'scrape') {
122
127
  if (!url) throw new Error('Scrape task requires a URL');
123
- const scrapeResult = await executeScrapeTask(getContext(), url, abortController.signal);
128
+ const scrapeResult = await executeScrapeTask(taskContext, url, abortController.signal);
124
129
  result = { ...scrapeResult, duration: Date.now() - startTime };
125
130
  } else if (type === 'healthcheck') {
126
- const healthResult = await executeHealthCheck(getContext(), initMs, abortController.signal);
131
+ const healthResult = await executeHealthCheck(taskContext, initMs, abortController.signal);
127
132
  result = { ...healthResult, duration: Date.now() - startTime };
128
133
  } else {
129
134
  result = { error: 'Unknown task type', duration: Date.now() - startTime };
@@ -156,6 +161,9 @@ async function runTask(data: TaskData | undefined): Promise<TaskResult> {
156
161
  };
157
162
  } finally {
158
163
  if (taskTimer !== undefined) clearTimeout(taskTimer);
164
+ if (taskContext) {
165
+ Promise.resolve(taskContext.close()).catch(() => { /* task is already done; a close failure here is not actionable */ });
166
+ }
159
167
  taskFinished();
160
168
  }
161
169
  }
@@ -80,6 +80,11 @@ class SerialQueue {
80
80
  private readonly hardDeadlineMs: number;
81
81
  private readonly onPoison?: (err: Error) => void;
82
82
  private poisoned: Error | null = null;
83
+ // The currently-executing task's real work promise (not the soft-timeout race) —
84
+ // set only while a native call is actually in flight. Lets a caller that needs
85
+ // to dispose the underlying pipeline (EmbeddingServer.shutdown) wait for true
86
+ // idleness instead of racing disposal against a still-running inference.
87
+ private currentWork: Promise<unknown> | null = null;
83
88
 
84
89
  constructor(
85
90
  maxDepth = 200,
@@ -132,6 +137,7 @@ class SerialQueue {
132
137
  let hardTimer: ReturnType<typeof setTimeout> | undefined;
133
138
  try {
134
139
  work = fn();
140
+ this.currentWork = work;
135
141
  // Arm the hard-deadline watchdog from the moment the real GPU work starts, so
136
142
  // the cap is an honest wall-clock deadline measured from work-start rather than
137
143
  // (soft-timeout + hardDeadlineMs). A single inference still unsettled after
@@ -162,6 +168,7 @@ class SerialQueue {
162
168
  } finally {
163
169
  settled = true;
164
170
  if (hardTimer !== undefined) clearTimeout(hardTimer);
171
+ if (this.currentWork === work) this.currentWork = null;
165
172
  }
166
173
  }
167
174
  };
@@ -170,6 +177,23 @@ class SerialQueue {
170
177
  });
171
178
  }
172
179
 
180
+ /**
181
+ * Resolve once no native call is in flight. Used by EmbeddingServer.shutdown()
182
+ * to wait out a still-running inference before disposing the pipeline —
183
+ * without this, disposal could run concurrently with a legitimately slow (not
184
+ * hung) embed/embedMany call on the same re-entrant-locked GPU context, the
185
+ * exact native-segfault class this queue exists to prevent. No timeout: a
186
+ * promise that truly never settles is already an accepted, pre-existing
187
+ * leak-to-process-exit scenario elsewhere in this class (see the poison path's
188
+ * disposeEmbedder=false handling) — better to leak than risk concurrent
189
+ * native access.
190
+ */
191
+ async waitForIdle(): Promise<void> {
192
+ while (this.currentWork) {
193
+ await this.currentWork.catch(() => {});
194
+ }
195
+ }
196
+
173
197
  private poison(err: Error): void {
174
198
  if (this.poisoned) return;
175
199
  this.poisoned = err;
@@ -414,6 +438,16 @@ export class EmbeddingServer implements IEmbedder {
414
438
  // is enough for another process to re-elect; the leaked native resources die with
415
439
  // eventual process exit.
416
440
  if (disposeEmbedder) {
441
+ // Wait for the SerialQueue to go idle BEFORE disposing. This shutdown() path
442
+ // also runs on ordinary leadership loss — not just the poison path above — and
443
+ // a legitimately slow (not hung) embed/embedMany can still be mid-flight on
444
+ // this same GPU context. Embedder.dispose()'s own drainActiveEmbeddings only
445
+ // waits 5s, an order of magnitude under the queue's own 120s soft timeout, so
446
+ // without this a normal large batch could have its pipeline disposed out from
447
+ // under it — the same concurrent-native-call hazard the queue exists to
448
+ // prevent, just triggered by disposal racing a live call instead of two calls
449
+ // racing each other.
450
+ await this.queue.waitForIdle();
417
451
  await this.embedder.dispose().catch((err) => {
418
452
  if (this.isShuttingDown) {
419
453
  logger.debug('[EmbeddingServer] Error disposing embedder during shutdown (expected):', err);
@@ -417,7 +417,8 @@ export async function handleWebGPUWarmupError(
417
417
  gpuLockHeld: boolean,
418
418
  model: string,
419
419
  initializationTimeoutMs: number,
420
- useCache: boolean
420
+ useCache: boolean,
421
+ poolingMode: 'mean' | 'cls' | 'last_token' = 'mean'
421
422
  ): Promise<{ success: boolean; pipeline?: FeatureExtractionPipeline; error?: Error; dummy?: any }> {
422
423
  markWebGpuFallback();
423
424
 
@@ -451,7 +452,7 @@ export async function handleWebGPUWarmupError(
451
452
  logger.info('[embedder] CPU pipeline loaded successfully');
452
453
 
453
454
  // Warmup CPU pipeline
454
- const { dummy, success, error: cpuWarmupErr } = await warmupPipeline(cpuPipeline, 'mean', useCache);
455
+ const { dummy, success, error: cpuWarmupErr } = await warmupPipeline(cpuPipeline, poolingMode, useCache);
455
456
  if (!success || cpuWarmupErr) {
456
457
  logger.error('[embedder] CPU fallback pipeline warmup failed:', cpuWarmupErr);
457
458
  if (cpuPipeline) {
@@ -331,7 +331,8 @@ export class Embedder {
331
331
  this.gpuLockHeld,
332
332
  this.model,
333
333
  this.initializationTimeoutMs,
334
- this.useCache
334
+ this.useCache,
335
+ this.poolingMode
335
336
  );
336
337
  if (!result.success) {
337
338
  throw result.error;
@@ -404,9 +405,16 @@ export class Embedder {
404
405
  // the VECTOR — the full text is still stored verbatim and covered by FTS — but the
405
406
  // truncation is otherwise silent, so surface it: a spike here means chunks are
406
407
  // arriving larger than the embed cap and vector recall on their tail is degraded.
408
+ let keep = maxChars;
409
+ // slice() counts UTF-16 code units, so the cut can land between the halves of
410
+ // a surrogate pair (emoji, rare CJK) — same guard as truncateWithMarker
411
+ // (utils/text-utils.ts): a lone high surrogate is invalid text some
412
+ // tokenizers/providers choke on. Back off one unit to restore well-formed text.
413
+ const lastKeptUnit = text.charCodeAt(keep - 1);
414
+ if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
407
415
  metrics.increment('embedder_truncations_total', 1, { model: this.model });
408
- logger.debug(`[embedder] Truncated input for embedding: ${text.length} -> ${maxChars} chars (model=${this.model})`);
409
- return text.slice(0, maxChars);
416
+ logger.debug(`[embedder] Truncated input for embedding: ${text.length} -> ${keep} chars (model=${this.model})`);
417
+ return text.slice(0, keep);
410
418
  }
411
419
  return text;
412
420
  }
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
2
3
  import { KnowledgeStore } from './store.ts';
3
4
  import { WriterQueue } from './writer-queue.ts';
4
5
  import { Chunker } from './chunker.ts';
@@ -108,19 +109,44 @@ export async function createKnowledgeStoreComponents(
108
109
  }
109
110
 
110
111
  /**
111
- * Force delete the entire knowledge store directory.
112
- * Used for system-level clearing or recovery from corruption.
112
+ * Force delete the ACTIVE table's own directory, to recover from a corrupted
113
+ * LanceDB manifest. Scoped to that one table, not the whole dbDir: dbDir is a
114
+ * single unified store shared by every workspace and the global scope (rows
115
+ * are scoped by a `workspace` column, not by separate directories), and it
116
+ * also holds `_backup_*`/`_migration_*` sibling directories — the deliberate,
117
+ * user-recoverable safety net the 'backup' migration strategy creates. A
118
+ * directory-wide wipe here previously destroyed every other project's data
119
+ * and every backup the instant ANY workspace's session hit a corrupted
120
+ * manifest, on the one code path whose entire purpose is recovering from
121
+ * exactly that kind of damage.
113
122
  */
114
123
  export async function forceDeleteKnowledgeStore(config?: Config, workspace?: string): Promise<void> {
115
124
  const dbDir = getDbDir(config, workspace);
116
- if (fs.existsSync(dbDir)) {
117
- try {
118
- fs.rmSync(dbDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
119
- fs.mkdirSync(dbDir, { recursive: true });
120
- logger.info(`[knowledge] Knowledge store at ${dbDir} forcefully deleted and recreated.`);
121
- } catch (err) {
122
- logger.error(`[knowledge] Failed to forcefully delete directory ${dbDir}:`, err);
123
- throw err;
125
+ if (!fs.existsSync(dbDir)) return;
126
+
127
+ // Mirrors KnowledgeStore's own manifest read (store.ts): the active table
128
+ // name defaults to 'knowledge' but can point elsewhere after a persisted
129
+ // rename failure. A missing or unreadable manifest is not itself a reason to
130
+ // widen the blast radius, so it falls back to the default rather than
131
+ // escalating to a dbDir-wide delete.
132
+ let tableName = 'knowledge';
133
+ const manifestPath = path.join(dbDir, 'store-manifest.json');
134
+ try {
135
+ if (fs.existsSync(manifestPath)) {
136
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { activeTableName?: string };
137
+ if (manifest.activeTableName) tableName = manifest.activeTableName;
124
138
  }
139
+ } catch (err) {
140
+ logger.warn(`[knowledge] Failed to read manifest before forced delete, defaulting to table '${tableName}':`, err);
141
+ }
142
+
143
+ const tableDir = path.join(dbDir, `${tableName}.lance`);
144
+ if (!fs.existsSync(tableDir)) return;
145
+ try {
146
+ fs.rmSync(tableDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
147
+ logger.info(`[knowledge] Corrupted table directory ${tableDir} forcefully deleted; will be recreated on next init.`);
148
+ } catch (err) {
149
+ logger.error(`[knowledge] Failed to forcefully delete table directory ${tableDir}:`, err);
150
+ throw err;
125
151
  }
126
152
  }
@@ -502,12 +502,36 @@ export class KnowledgeStore implements IKnowledgeStore {
502
502
  // If canonical name was already something else and it still exists,
503
503
  // we might need to handle it. But we just dropped it above.
504
504
  await fsPromises.rename(tempDir, canonicalDir);
505
-
505
+
506
506
  this.tableName = canonicalName;
507
- await this.saveManifest();
508
-
509
- // Reopen the canonical table so this.table reflects the renamed path
510
- this.table = await this.db.openTable(this.tableName);
507
+ if (await this.saveManifest()) {
508
+ // Reopen the canonical table so this.table reflects the renamed path
509
+ this.table = await this.db.openTable(this.tableName);
510
+ } else {
511
+ // The directory rename to the canonical name already happened on disk,
512
+ // but the manifest still says tempTableName (persisted successfully
513
+ // above, before the old table was dropped). Left as-is, the next
514
+ // process start would read that stale name, find no such directory
515
+ // (renamed away), and silently CREATE A NEW EMPTY TABLE under it —
516
+ // stranding every re-embedded document in the now-unreferenced
517
+ // canonical directory with no recovery path (pruneOrphanedMigrationDirs
518
+ // doesn't recognize a bare 'knowledge.lance' as orphaned migration
519
+ // material). Revert the rename so the manifest (still correctly
520
+ // pointing at tempTableName, from the pre-drop save above) and the
521
+ // on-disk layout agree again — the data stays fully intact and
522
+ // accessible under its temp name either way. Deliberately NOT thrown:
523
+ // the migration itself fully succeeded and the in-memory handle is
524
+ // fine, so surfacing this as a migration failure would trip the
525
+ // caller's re-embed-failed fallback to the 'backup' strategy, which
526
+ // would needlessly rename this perfectly good, fully re-embedded data
527
+ // out of the way and start a fresh empty table over one manifest
528
+ // write hiccup.
529
+ logger.error('[store] Failed to persist canonical table name after rename — reverting to keep manifest and disk in sync.');
530
+ await fsPromises.rename(canonicalDir, tempDir);
531
+ this.tableName = tempTableName;
532
+ this.table = await this.db.openTable(this.tableName);
533
+ logger.info(`[store] Migrated data remains safe and accessible as ${tempTableName}; canonical rename deferred to a future successful manifest save.`);
534
+ }
511
535
  } catch (renameErr) {
512
536
  // If rename fails (cross-device, permissions, etc.) we keep the temp name
513
537
  // but CRITICALLY we now persist it in the manifest so future sessions
@@ -517,7 +517,9 @@ export class DeepResearchOrchestrator {
517
517
  // Guarded so a one-round budget cannot announce the same round twice.
518
518
  if (this.currentRound > lastAnnouncedRound) {
519
519
  observer?.onRoundStart?.(this.currentRound);
520
- lastAnnouncedRound = this.currentRound;
520
+ // No assignment to lastAnnouncedRound here: this is the last place in the
521
+ // function that reads it — final synthesis proceeds unconditionally right
522
+ // after, nothing downstream checks it again.
521
523
  }
522
524
  // The round this synthesis actually happens at, not the budget. They coincide on
523
525
  // the round-cap exit but not on the wait-exhaustion one, which breaks out early —
@@ -703,6 +703,22 @@ export function getPiActivePanels(piSessionId: string | undefined): ResearchPane
703
703
  return [...state.order].reverse().map(id => state.panels.get(id)!).filter(Boolean);
704
704
  }
705
705
 
706
+ /**
707
+ * Count of research runs currently active in a Pi session, TUI or headless.
708
+ *
709
+ * Unlike getPiActivePanels (populated only by registerSessionPanel, which the
710
+ * TUI path alone calls), `aborts` is registered by registerSessionAbort on
711
+ * EVERY run — headless included — and removed only by endResearchSession. Use
712
+ * this wherever "am I the last/only active run in this session" must hold for
713
+ * headless callers too (e.g. gating a shared-state clear like steering
714
+ * messages so a finishing run doesn't wipe a concurrent sibling's).
715
+ */
716
+ export function getActiveResearchRunCount(piSessionId: string | undefined): number {
717
+ const sid = normalizeSessionId(piSessionId);
718
+ const state = piSessions.get(sid);
719
+ return state ? state.aborts.size : 0;
720
+ }
721
+
706
722
  /**
707
723
  * Get the ordered list of active research run IDs in a Pi session.
708
724
  * Returns IDs in chronological order (oldest first, newest last).