@lincoln504/pi-research 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.mjs +449 -187
- package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
- package/dist/prompts/research-knowledge-search-extractor.md +2 -0
- package/dist/prompts/researcher.md +2 -14
- package/dist/thread-worker.mjs +91 -59
- package/docs/AGENT-SKILL.md +4 -3
- package/docs/CHANGELOG.md +120 -0
- package/docs/CONFIGURATION.md +2 -2
- package/docs/SDK.md +1 -1
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +44 -3
- package/src/config.ts +36 -13
- package/src/constants.ts +23 -4
- package/src/core/llm/research-model-resolver.ts +38 -0
- package/src/core/planning-service.ts +67 -9
- package/src/index.ts +3 -1
- 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 +65 -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 +54 -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 +85 -5
- package/src/observers/research-observer-impl.ts +14 -6
- package/src/orchestration/deep-research-orchestrator.ts +3 -1
- package/src/orchestration/quick-research-orchestrator.ts +29 -10
- package/src/orchestration/research-synthesis-service.ts +32 -0
- package/src/orchestration/researcher-executor.ts +31 -16
- package/src/orchestration/researcher.ts +2 -2
- package/src/orchestration/session-state.ts +35 -4
- package/src/prompts/research-knowledge-relevance-triage.md +2 -0
- package/src/prompts/research-knowledge-search-extractor.md +2 -0
- package/src/prompts/researcher.md +2 -14
- package/src/sdk.ts +16 -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/index.ts +2 -1
- package/src/tools/research-knowledge-search.ts +37 -4
- package/src/tools/research-tool-definition.ts +54 -12
- package/src/tools/scrape.ts +8 -1
- 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 +15 -2
- package/src/utils/metrics-summary.ts +21 -1
- package/src/utils/tool-usage-tracker.ts +6 -3
- package/src/web-research/browser-search.ts +11 -4
- package/src/web-research/web-scraper.ts +36 -16
- package/src/youtube/index.ts +11 -1
- package/src/youtube/potoken.ts +8 -1
- package/src/youtube/transcript-client.ts +56 -5
|
@@ -18,6 +18,8 @@ Your ONLY job is to decide which candidates are genuinely relevant to the user's
|
|
|
18
18
|
Field definitions:
|
|
19
19
|
- `relevant_indices` (array of integers, required): the 0-based indices of the relevant candidates, in any order. Empty array `[]` if none are relevant.
|
|
20
20
|
|
|
21
|
+
<!-- USER_TURN: everything below is delivered as the USER message of the stateless call, not the system prompt. The rules above stay byte-identical across calls so the provider can serve the system prefix from its prompt cache; the sections below vary per call. -->
|
|
22
|
+
|
|
21
23
|
## USER'S SEARCH QUERY
|
|
22
24
|
|
|
23
25
|
Judge each candidate's relevance to this search query. This is the PRIMARY signal for what the user wants; the conversation below is supplementary and may be absent for a direct (non-conversational) search:
|
|
@@ -24,6 +24,8 @@ Field definitions:
|
|
|
24
24
|
- `synthesis` (string, optional): The synthesized answer if found. Use inline citation markers [1], [2], etc. that correspond to the citations array. Present when answer_status is "yes" or "maybe". Omit when answer_status is "no".
|
|
25
25
|
- `citations` (array of strings, required): The source URLs from the reference documents that were used to construct the answer. Empty array if answer_status is "no".
|
|
26
26
|
|
|
27
|
+
<!-- USER_TURN: everything below is delivered as the USER message of the stateless call, not the system prompt. The rules above stay byte-identical across calls so the provider can serve the system prefix from its prompt cache; the sections below vary per call. -->
|
|
28
|
+
|
|
27
29
|
## USER'S SEARCH QUERY
|
|
28
30
|
|
|
29
31
|
Answer this search query from the reference documents. This is the PRIMARY signal for what the user wants; the conversation below is supplementary and may be absent for a direct (non-conversational) search:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- RESEARCHER_AGENT_MARKER -->
|
|
4
4
|
|
|
5
|
-
You are an autonomous research agent. Your goal is to investigate your assigned topic with depth and rigor.
|
|
5
|
+
You are an autonomous research agent. Your goal is to investigate your assigned topic with depth and rigor. Your specific goal, any historical knowledge-store results, your initial evidence/search results, and any sibling-coordination notes are provided in the user message that follows this system prompt — not here, so this system prompt stays byte-identical across every researcher and round (prompt caching).
|
|
6
6
|
|
|
7
7
|
## CORE DIRECTIVES (Strict Enforcement)
|
|
8
8
|
|
|
@@ -23,20 +23,10 @@ Do not treat any source as inherently objective. Justify its inclusion through t
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
## Your Goal
|
|
27
|
-
{{goal}}
|
|
28
|
-
|
|
29
|
-
{{store_section}}
|
|
30
|
-
(Note: when knowledge-store results appear above, they were retrieved automatically for your goal, aggregated from the **Project Knowledge Store** and **User Knowledge Store**. Do NOT try to search the store manually.)
|
|
31
|
-
|
|
32
|
-
{{evidence_section}}
|
|
33
|
-
|
|
34
|
-
---
|
|
35
|
-
|
|
36
26
|
## Workflow
|
|
37
27
|
|
|
38
28
|
### Step 1: Build Your Source List
|
|
39
|
-
Combine historical URLs and search results into a unified pool. Use previous session summaries as a guide for what to expect.
|
|
29
|
+
Combine historical URLs and search results into a unified pool. Use previous session summaries as a guide for what to expect. Any knowledge-store results in your user message were retrieved automatically for your goal from the **Project Knowledge Store** and/or **User Knowledge Store** — cite them with that store name as their `Source:`.
|
|
40
30
|
|
|
41
31
|
### Step 2: Scrape Round 1
|
|
42
32
|
Identify the 4 most promising URLs and scrape them in one batch (the scrape tool accepts up to 6 per call; 4 is your target per round). Prioritize primary sources, authoritative references, and dense documentation; all else being equal, lean toward more recent / current sources and topics.
|
|
@@ -51,8 +41,6 @@ Do NOT scrape URLs directly from the pool — only scrape URLs from your own sou
|
|
|
51
41
|
### Step 4: Synthesize
|
|
52
42
|
Write your report immediately after scraping is complete, or as soon as a tool reports its limit is reached (e.g. "GATHERING LIMIT REACHED", "SEARCH LIMIT REACHED", "SCRAPE PROTOCOL COMPLETE"). Make no further tool calls after beginning synthesis.
|
|
53
43
|
|
|
54
|
-
{{coordination_section}}
|
|
55
|
-
|
|
56
44
|
---
|
|
57
45
|
|
|
58
46
|
## Guidelines
|
package/dist/thread-worker.mjs
CHANGED
|
@@ -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") || String(resp.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
|
}
|
package/docs/AGENT-SKILL.md
CHANGED
|
@@ -77,9 +77,10 @@ One-click (recommended). From the `pi` extension, run `/research-config` →
|
|
|
77
77
|
Install in External Agents. The installer:
|
|
78
78
|
|
|
79
79
|
1. Detects which target agents are present under `$HOME` — currently Claude
|
|
80
|
-
(`~/.claude/skills`),
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
(`~/.claude/skills`), OpenClaw (`~/.openclaw/skills`, OpenClaw's managed
|
|
81
|
+
skill root, which accepts symlinked skill folders), and OpenAI Codex CLI
|
|
82
|
+
(`~/.codex/skills` — unlike the other two, this path is not confirmed by
|
|
83
|
+
Codex's official docs; Codex skills support is still emerging).
|
|
83
84
|
2. Symlinks `skills/pi-research/` into each present agent, never overwriting an
|
|
84
85
|
unrelated skill already in that slot.
|
|
85
86
|
3. Records what it created in a manifest, so Remove from External Agents
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,126 @@ 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
|
+
## [1.5.0] - 2026-08-20
|
|
9
|
+
|
|
10
|
+
Whole-project audit (release integrity, CI/test health, SSRF consistency,
|
|
11
|
+
docs-vs-code accuracy, and an adversarial re-check of every fix claimed in
|
|
12
|
+
the 1.4.1 changelog entry) that found several defects in code the 1.4.1
|
|
13
|
+
audit itself had just "fixed" — proof that a changelog claim needs
|
|
14
|
+
independent re-verification, not just a passing regression test for the
|
|
15
|
+
first failure mode it covers. Plus a prompt-caching workstream: a full verification of how
|
|
16
|
+
prompt caching actually behaves across every provider path pi supports,
|
|
17
|
+
restructuring of every LLM prompt in the codebase for cache-prefix
|
|
18
|
+
invariance, and a caching-aware scrape budget.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Prompt-caching-aware scrape budget.** When prompt caching is known to be active for the run's resolved research model — the Anthropic API (caching is native and always on), or an `openai-completions` provider route explicitly configured with Anthropic-style `cacheControlFormat` — the effective per-researcher scrape-batch maximum is the configured `PI_RESEARCH_MAX_SCRAPE_BATCHES` plus one: the cached system-prompt/tool prefix makes the extra batch's context re-send cheap. The detection is deliberately conservative (`isPromptCachingActiveForModel`): providers whose caching is implicit/server-side (OpenAI Responses, Google, Mistral, Bedrock) report false, because a false positive would raise the budget without the discount that pays for it. One arithmetic source of truth (`getMaxScrapeBatches`) feeds tracker enforcement, the researcher prompt text, the scrape tool's own protocol text, and the TUI progress units, so they can never disagree; the increment is capped so a configured 99 (the documented max) never crosses into the >99 "unlimited" display band (`src/core/llm/research-model-resolver.ts`, `src/constants.ts`, `src/utils/tool-usage-tracker.ts`, `src/tools/scrape.ts`, `src/observers/research-observer-impl.ts`).
|
|
23
|
+
- **Prompt-cache metrics surfaced to users.** The per-run summary's token line now reads e.g. `**150,000** tokens (**90,000** from cache)` whenever the provider reported prompt-cache reads, and `ResearchStats` (SDK `getLastRunStats()`) carries `cacheReadTokens`/`cacheWriteTokens`. Previously the `llm_cache_read/write_tokens_total` counters existed but reached no user-facing surface, so there was no way to see whether caching was working (`src/utils/metrics-summary.ts`).
|
|
24
|
+
- **Weak-grounding notice on completed reports.** A run that completes but verifies fewer than 3 sources now appends an explicit `Grounding notice` after its CITED LINKS list telling the reader (human or calling agent) that unattributed claims are unverified. Driven by a real production run: a depth-1 report whose researchers retrieved almost nothing shipped as a fully sourced-looking document with one citation and fabricated most of its specifics, and the calling agent had to detect the degradation itself. The existing zero-source notes already covered the empty case; this covers the sourced-looking-but-barely-grounded one. The notice sits BEFORE the CITED LINKS header — anything after the last citation entry gets absorbed into that entry's description when quick mode re-parses the report for knowledge-store entries. The CLI additionally prints a stderr `WARNING:` line on completion when a report carries the weak-grounding or no-sources notice, so callers that read stderr (the agent skill does) can distinguish a degraded run from a healthy one without parsing the report (`src/orchestration/research-synthesis-service.ts`, `src/cli.ts`).
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **Every researcher's system prompt is now byte-identical across researchers, rounds, and retry attempts.** The per-researcher goal, knowledge-store results, initial evidence, sibling-coordination notes, and steering messages moved from the system prompt into the researcher's initial user message. A byte-identical system prompt + tool set is the cacheable prefix providers key on, so on caching providers every researcher after the first now gets prompt-cache reads on that prefix instead of a fresh cache write per researcher (previously each researcher's unique system prompt guaranteed zero cross-researcher reuse — with Anthropic's 25% cache-write premium, strictly worse than no caching). Quick mode got the same restructure, which also removed a long-standing redundancy where the query was sent twice (once templated into the system prompt, once as the user message). Regression tests pin the invariance directly (`src/prompts/researcher.md`, `src/orchestration/researcher-executor.ts`, `src/orchestration/quick-research-orchestrator.ts`).
|
|
29
|
+
- **The knowledge-search extractor and relevance-triage prompts split static rules from per-call data.** Both stateless background calls put the queries, conversation history, and reference documents/candidates inside the system prompt, so on caching providers every call was a distinct cache entry — pure cache-write premium with zero reads. The templates now carry a `USER_TURN` marker: rules above it stay the (cacheable, byte-identical) system prompt; the per-call sections below are delivered as the user message. A missing marker falls back to the legacy all-in-system shape rather than corrupting the prompt (`src/prompts/research-knowledge-search-extractor.md`, `src/prompts/research-knowledge-relevance-triage.md`, `src/tools/research-knowledge-search.ts`).
|
|
30
|
+
- **Removed a vestigial instruction from the researcher prompt.** "Do NOT try to search the store manually" told researchers not to use a tool they have never been given (`research_knowledge_search` is a top-level tool, not part of the researcher toolset); the surviving wording just explains where knowledge-store results come from and how to cite them.
|
|
31
|
+
- `docs/SDK.md` now opens by describing the package as a high-level research SDK.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **A markdown-fenced JSON synthesis envelope could ship verbatim as the final report.** `salvageReportText` suppresses broken JSON envelopes when parse and repair both fail, but its check was `startsWith('{')` — a ```` ```json ````-fenced envelope starts with backticks and sailed through (observed in production 2026-08-20: a fenced `{"action":"synthesize",…}` blob, internal protocol fields included, published as a user-facing report). The sniff now strips one leading/trailing code fence before the shape check; prose that merely contains a fenced block is still preserved (`src/core/planning-service.ts`).
|
|
36
|
+
- **The release workflow's credentialed `publish` job interpolated an untrusted job output into its shell.** `npm publish "${{ needs.test-install.outputs.tar_file }}"` splices text originating in the one job that runs untrusted dependency lifecycle scripts directly into the script of the only job holding `id-token: write` — a script-injection channel that re-opened the exact credential/untrusted-code separation the 1.4.2 publish restructure exists to enforce (GitHub's hardening guide lists `needs.*.outputs.*` as an injection source). The publish job now derives the tarball filename deterministically from the release tag (env-indirected), verifies the downloaded artifact contains exactly that file, and the `test-install` job no longer exports the output at all (`.github/workflows/release.yml`).
|
|
37
|
+
- **The browser scrape layer classified PDF-vs-HTML against the pre-redirect URL** — the same bug class 1.4.2 fixed in the fetch layer via `currentUrl`, left unfixed one layer down. A download endpoint without `.pdf` that redirects to the real `.pdf` file (with missing/mislabeled content-type) fell into the HTML branch, serializing Firefox's PDF-viewer chrome instead of the document, and the oversized-PDF early abort never armed for it. Both browser-layer checks now use the response's own post-redirect URL (`src/infrastructure/browser/thread-worker-messaging.ts`).
|
|
38
|
+
- **Unit tests polluted the real shared log file.** The test setup's `PI_RESEARCH_LOG_PATH` redirect ran in the setup module's body — but its own static imports (metrics → logger) are hoisted and evaluate first, so the logger singleton had already bound the user's real log path before the redirect executed. Every error-path unit test then appended real-looking WARN/ERROR entries (6 spurious ERRORs and hundreds of WARNs in one observed day) that materially slowed forensic diagnosis of real runs. The env assignments now live in an import-free module imported first (`test/setup/unit-env.ts`).
|
|
39
|
+
- **The missing-config advisory was the single most common line in the log.** `getConfig` warned "No configuration found for workspace" on every call from an unconfigured directory — 362 entries in one observed day. Now warned once per workspace per process (`src/config.ts`).
|
|
40
|
+
- Singular knowledge-search progress line rendered a bare trailing slash ("1 query/") — same rate-vs-count confusion the search-burst line was already fixed for (`src/cli.ts`).
|
|
41
|
+
- `isVerboseFromEnv`'s doc comment claimed config loads sync `config.DEBUG` into the env; only saves do. The comment now says exactly when a `DEBUG=true` line in config.env takes effect (`src/utils/log-utils.ts`).
|
|
42
|
+
- **Four 1.4.2 fixes shipped without the regression tests their release notes implied.** Backfilled and verified: `security_search` must not charge the shared gathering budget on unknown-database/invalid-severity rejects; `redactSecrets` must still mark the scan-cut tail as truncated when redaction shrinks the prefix under the cap; `uninstallSkill([])` must remove nothing (an explicit empty list is not `--all`); and redirect-aware PDF/HTML classification on the fetch path (both directions, verified failing against the pre-fix code).
|
|
43
|
+
- **Knowledge-store migration could still lose data if BOTH the canonical rename and its own revert failed.** The 1.4.1 fix for the manifest/disk desync bug added a revert-the-rename path for when the post-rename manifest save fails, but the revert's own failure fell through into a catch block written for a different case (the first rename never happening at all) — which pointed the manifest at a directory that no longer existed on disk. Now retries the manifest save against the name that actually matches disk, and logs loudly if that also fails, instead of silently stranding the data; a third-order failure (the recovery reopen itself failing) is also handled — and the manifest repair is deliberately not gated behind the reopen, since it needs only file writes, so a reopen-only fault still gets the manifest fixed for the next restart. Three regression tests cover the compound failures directly (`src/knowledge/store.ts`).
|
|
44
|
+
- **`SIGQUIT` was documented as gracefully handled and wasn't.** `docs/SDK.md` and `SKILL.md` list SIGQUIT alongside SIGINT/SIGTERM/SIGHUP as a signal the CLI catches and shuts down cleanly from — but no listener was ever registered for it, so a real SIGQUIT hit Node's default disposition (immediate kill, no abort, no drain) instead. Added the missing handler to both the CLI and SDK; a real-subprocess test now sends an actual SIGQUIT to the built binary and distinguishes "our handler ran" from "the OS killed it" (`src/cli.ts`, `src/sdk.ts`).
|
|
45
|
+
- **Embedding-server shutdown could dispose the pipeline while a second queued embed was starting.** `SerialQueue.waitForIdle()` only checked the currently-executing task; there's a real gap, verified with a controlled reproduction, between one task clearing that state and the next queued task claiming it, and a caller checking exactly in that gap saw "idle" while a second native call was about to run against the about-to-be-disposed pipeline — the same class of native-GPU-concurrency bug this queue exists to prevent, one layer deeper than the 1.4.1 fix covered. Now also tracks whether the queue is still mid-drain (`src/infrastructure/embedding/embedding-server.ts`).
|
|
46
|
+
- **Two sibling headless research runs finishing at nearly the same moment could both skip clearing shared steering messages.** `endResearchSession`'s "last run in the session" gate only checked TUI-only state (`order`, `panels`), so on every headless run it was trivially true — one headless run ending reset steering while a sibling headless run was still consuming it. The gate now also requires the abort registry (populated by TUI and headless runs alike) to be empty, and because this run's own entry is removed in the same synchronous function, the whichever-sibling-finishes-last decision is atomic; the separate, racy guard the research tool used to run before its cleanup was removed in favor of this one (`src/orchestration/session-state.ts`, `src/tools/research-tool-definition.ts`).
|
|
47
|
+
- **A single YouTube transcript could dominate a researcher's context window.** The `youtube_transcript` tool concatenated raw transcripts into its tool response with no per-video cap — a long video (or several in one batch call) could inject hundreds of thousands of characters. Each transcript is now truncated with the same per-document cap and marker the scrape tool uses (`src/youtube/index.ts`).
|
|
48
|
+
- **A legacy `.pi-research.env` file in the current directory could set user-scoped keys.** The tool runs in arbitrary (including untrusted) repositories; a checked-in legacy env file could override user-level configuration such as API endpoints for anyone who ran research in that directory. CWD-local legacy files are now restricted to the same local-scope key allowlist that already governed project-scoped registry storage; user-scoped keys in them are ignored (`src/config.ts`).
|
|
49
|
+
- **The scrape tool's own protocol text could disagree with the enforced limit.** `scrape.ts` computed its batch count from config directly instead of asking the tracker that actually enforces the limit; with the new caching-aware budget those could differ by one. The tool now reads the tracker's limit, falling back to config only when no tracker is wired (`src/tools/scrape.ts`).
|
|
50
|
+
- **Release workflow's credentialed `publish` job ran an unscoped `npm install`.** `npm install -g npm@11` (needed for the OIDC trusted-publishing exchange) executed in the one job holding `id-token: write`, without `--ignore-scripts` — npm itself ships no install-time lifecycle scripts today, but the job's whole design is to never run installable code there on trust alone. Added `--ignore-scripts`; verified the resulting npm binary still publishes correctly (`.github/workflows/release.yml`).
|
|
51
|
+
- **README overstated the knowledge store as always-on.** "Every finding is written to a local LanceDB knowledge store" implied unconditional, automatic behavior; it's actually conditional on the store being enabled. Reworded to "can be saved."
|
|
52
|
+
- **`AGENT-SKILL.md` presented the Codex skill-install path as equally confirmed as Claude/OpenClaw's.** The installer itself tags the Codex path `confidence: 'unverified'` ("path not confirmed by official docs"); the doc now says so.
|
|
53
|
+
|
|
54
|
+
## [1.4.3] - 2026-08-19
|
|
55
|
+
|
|
56
|
+
v1.4.2 was tagged and pushed but never published: the release workflow's
|
|
57
|
+
`publish` job restructuring below (removing `actions/checkout` entirely, to
|
|
58
|
+
close the OIDC-exposure gap) also removed `.nvmrc`, which `actions/setup-node`
|
|
59
|
+
needs on disk — the job failed at the very first step, before ever reaching
|
|
60
|
+
`npm publish`. No package was published under 1.4.2; this is that fix,
|
|
61
|
+
re-tagged as 1.4.3.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- **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.
|
|
66
|
+
|
|
67
|
+
## [1.4.2] - 2026-08-19 (tagged, never published — see 1.4.3)
|
|
68
|
+
|
|
69
|
+
Five further fresh-eyes audit rounds on top of 1.4.1, covering subsystems the
|
|
70
|
+
1.4.1 audit hadn't reached: scripts/config/CLI, knowledge-store/embedding, and
|
|
71
|
+
browser/network first; then orchestration router/synthesizer, locks/scheduler,
|
|
72
|
+
tools, repo hygiene; then knowledge/embedding, CLI/SDK, security/YouTube
|
|
73
|
+
internals, web-scraper/PDF, and CI/release scripts again from a second pass;
|
|
74
|
+
then a re-audit of the first three rounds' own diffs plus CI/release workflow
|
|
75
|
+
robustness and state/lock lifecycle; then a fifth round covering release/CI
|
|
76
|
+
credential handling end to end, web-scraper redirect classification, tool
|
|
77
|
+
budget/validation/steering-concurrency gaps, embedding-server shutdown vs. its
|
|
78
|
+
own GPU serialization queue, and knowledge-store migration manifest
|
|
79
|
+
persistence.
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
- **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.
|
|
84
|
+
- **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.
|
|
85
|
+
- **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`.
|
|
86
|
+
- **`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.
|
|
87
|
+
- 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.
|
|
88
|
+
- 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.
|
|
89
|
+
- **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.
|
|
90
|
+
- **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.
|
|
91
|
+
- **`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.
|
|
92
|
+
- **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.
|
|
93
|
+
- **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.
|
|
94
|
+
- **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.
|
|
95
|
+
- **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`.
|
|
96
|
+
- **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`.
|
|
97
|
+
- **`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.
|
|
98
|
+
- **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.
|
|
99
|
+
- **`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.
|
|
100
|
+
- **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`.
|
|
101
|
+
- **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.
|
|
102
|
+
- **`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.
|
|
103
|
+
- **`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.
|
|
104
|
+
- **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`.
|
|
105
|
+
- **`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.
|
|
106
|
+
- **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.
|
|
107
|
+
- **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.
|
|
108
|
+
- **`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."
|
|
109
|
+
- **`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.
|
|
110
|
+
- **`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.
|
|
111
|
+
- **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.
|
|
112
|
+
|
|
113
|
+
### Deferred
|
|
114
|
+
|
|
115
|
+
- 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.
|
|
116
|
+
- `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.
|
|
117
|
+
- 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.
|
|
118
|
+
- 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.
|
|
119
|
+
|
|
120
|
+
### Verified
|
|
121
|
+
|
|
122
|
+
- Every fix carries a regression test proven to fail against the pre-fix code and pass after (stash-and-rerun discipline).
|
|
123
|
+
- 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.
|
|
124
|
+
- 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.
|
|
125
|
+
- 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.
|
|
126
|
+
- 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.
|
|
127
|
+
|
|
8
128
|
## [1.4.1] - 2026-08-17
|
|
9
129
|
|
|
10
130
|
v1.4.0 was tagged and pushed but never published: CI failed on every commit from the
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -68,7 +68,7 @@ Research
|
|
|
68
68
|
| `PI_RESEARCH_TIMEOUT_MS` (TUI) | `300000` | 180000–1800000 | Per-researcher timeout (3–30 min). |
|
|
69
69
|
| `PI_RESEARCH_MAX_RESEARCHERS` (TUI) | `3` | 1–5 | Parallel researchers. |
|
|
70
70
|
| `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` (TUI) `[project]` | `1` | 1–3 | Depth for `/research` and the CLI when `--depth` is omitted (1=normal, 2=deep, 3=ultra). |
|
|
71
|
-
| `PI_RESEARCH_MAX_SCRAPE_BATCHES` (TUI) | `2` | 0–99 | Scrape batches per researcher (0 = unlimited). |
|
|
71
|
+
| `PI_RESEARCH_MAX_SCRAPE_BATCHES` (TUI) | `2` | 0–99 | Scrape batches per researcher (0 = unlimited). When prompt caching is known to be active for the resolved research model (Anthropic API models, or a provider route explicitly configured for Anthropic-style cache control), the effective limit is this value plus one — the cached prompt prefix makes the extra batch cheap. |
|
|
72
72
|
| `PI_RESEARCH_MAX_GATHERING_CALLS` | `12` | 1–100 | Shared web-gathering calls per researcher (`search` + `security_search` + `stackexchange` + `youtube_transcript`). |
|
|
73
73
|
| `PI_RESEARCH_MAX_CONCURRENT_SCRAPES` | `3` | 1–20 | Concurrent URLs fetched per scrape batch. |
|
|
74
74
|
| `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
|
|
@@ -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/docs/SDK.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
## SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A high-level research SDK for scripts, CI, and custom tooling. For configuration (the
|
|
4
4
|
layering model, the TUI settings, and every environment variable) see
|
|
5
5
|
[CONFIGURATION.md](CONFIGURATION.md).
|
|
6
6
|
|