@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.
- package/dist/cli.mjs +271 -156
- package/dist/thread-worker.mjs +91 -59
- package/docs/CHANGELOG.md +76 -0
- package/docs/CONFIGURATION.md +1 -1
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/config.ts +10 -4
- package/src/core/planning-service.ts +55 -8
- package/src/infrastructure/browser/browser-task-scheduler.ts +16 -9
- package/src/infrastructure/browser/config.ts +40 -2
- package/src/infrastructure/browser/thread-worker-browser.ts +69 -48
- package/src/infrastructure/browser/thread-worker-messaging.ts +61 -4
- package/src/infrastructure/browser/thread-worker.mjs +91 -59
- package/src/infrastructure/browser/thread-worker.ts +13 -5
- package/src/infrastructure/embedding/embedding-server.ts +34 -0
- package/src/knowledge/embedder-init.ts +3 -2
- package/src/knowledge/embedder.ts +11 -3
- package/src/knowledge/index.ts +36 -10
- package/src/knowledge/store.ts +29 -5
- package/src/orchestration/deep-research-orchestrator.ts +3 -1
- package/src/orchestration/session-state.ts +16 -0
- package/src/sdk.ts +10 -0
- package/src/skill-install/skill-installer.ts +5 -1
- package/src/stackexchange/index.ts +3 -0
- package/src/stackexchange/rest-client.ts +7 -12
- package/src/tools/research-tool-definition.ts +38 -7
- package/src/tools/security.ts +13 -10
- package/src/utils/coverage-digest.ts +1 -1
- package/src/utils/host-config.ts +12 -1
- package/src/utils/log-utils.ts +10 -0
- package/src/web-research/browser-search.ts +11 -4
- package/src/web-research/web-scraper.ts +27 -16
- package/src/youtube/potoken.ts +8 -1
- 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,
|
|
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 (
|
|
280
|
-
const existing = pattern.domainCounts.get(
|
|
281
|
+
if (context.domain) {
|
|
282
|
+
const existing = pattern.domainCounts.get(context.domain);
|
|
281
283
|
if (existing !== void 0) {
|
|
282
|
-
pattern.domainCounts.set(
|
|
284
|
+
pattern.domainCounts.set(context.domain, existing + 1);
|
|
283
285
|
} else if (pattern.domainCounts.size < this.MAX_DOMAINS_PER_PATTERN) {
|
|
284
|
-
pattern.domainCounts.set(
|
|
286
|
+
pattern.domainCounts.set(context.domain, 1);
|
|
285
287
|
}
|
|
286
288
|
}
|
|
287
|
-
pattern.contexts.push(
|
|
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
|
|
1113
|
-
errorTracker.trackError(errArg,
|
|
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(
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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 ===
|
|
2058
|
+
if (wedgedContext === context) wedgedContext = null;
|
|
2057
2059
|
releaseOnce();
|
|
2058
2060
|
},
|
|
2059
2061
|
() => {
|
|
2060
|
-
if (wedgedContext ===
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
2570
|
-
*
|
|
2571
|
-
* SEARCH_TIMEOUT_MS + this, a scrape after SCRAPE_TIMEOUT_MS + this
|
|
2572
|
-
*
|
|
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()
|
|
2756
|
+
if (isBrowserConnected()) return;
|
|
2730
2757
|
if (initPromise) return initPromise;
|
|
2731
2758
|
initPromise = (async () => {
|
|
2732
2759
|
try {
|
|
2733
|
-
if (!isBrowserConnected()
|
|
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 =
|
|
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
|
|
2876
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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} -> ${
|
|
409
|
-
return text.slice(0,
|
|
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
|
}
|
package/src/knowledge/index.ts
CHANGED
|
@@ -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
|
|
112
|
-
*
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
}
|
package/src/knowledge/store.ts
CHANGED
|
@@ -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
|
-
|
|
510
|
-
|
|
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
|
|
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).
|