@lincoln504/pi-research 1.6.10 → 1.6.11
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 +4 -1
- package/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +48 -12
- package/dist/thread-worker.mjs +100 -22
- package/docs/CHANGELOG.md +33 -0
- package/docs/CONFIGURATION.md +7 -2
- package/docs/PI-EXTENSION.md +2 -1
- package/package.json +21 -19
- package/src/config.ts +10 -4
- package/src/core/pi-version.ts +2 -2
- package/src/core/planning-utils.ts +34 -2
- package/src/infrastructure/browser/config.ts +3 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +29 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +116 -19
- package/src/infrastructure/browser/thread-worker.mjs +100 -22
- package/src/infrastructure/browser/worker-pool-manager.ts +10 -1
- package/src/orchestration/deep-research-orchestrator.ts +12 -10
- package/src/sdk.ts +38 -11
- package/src/tools/research-knowledge-search.ts +1 -1
- package/src/tui/tui-controller.ts +57 -15
- package/src/web-research/retry-utils.ts +11 -1
package/README.md
CHANGED
|
@@ -18,11 +18,14 @@
|
|
|
18
18
|
<a href="docs/CONFIGURATION.md">Configuration</a>
|
|
19
19
|
·
|
|
20
20
|
<a href="docs/ARCHITECTURE.md">Architecture</a>
|
|
21
|
+
·
|
|
22
|
+
<a href="docs/DOCUMENTATION-ES.md">ES</a>
|
|
23
|
+
·
|
|
24
|
+
<a href="docs/DOCUMENTATION-ZH.md">中文研究</a>
|
|
21
25
|
</p>
|
|
22
26
|
|
|
23
27
|
---
|
|
24
28
|
|
|
25
|
-
<a href="https://github.com/Lincoln504/pi-research/actions/workflows/ci.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/Lincoln504/pi-research/ci.yml?style=flat-square&branch=main" /></a><br />
|
|
26
29
|
<a href="https://www.npmjs.com/package/@lincoln504/pi-research"><img alt="npm version" src="https://img.shields.io/npm/v/@lincoln504/pi-research.svg?style=flat-square" /></a><br />
|
|
27
30
|
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-97ca00?style=flat-square" /></a><br />
|
|
28
31
|
<a href="https://github.com/Lincoln504/pi-research"><img alt="GitHub repository" src="https://img.shields.io/badge/GitHub-Lincoln504%2Fpi--research-181717?style=flat-square&logo=github&logoColor=white" /></a>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: pi-research
|
|
3
3
|
description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
|
|
4
4
|
license: MIT
|
|
5
|
-
metadata: { "author": "Lincoln Deen", "version": "1.6.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.6.11", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
|
|
6
6
|
allowed-tools: Bash(node:*)
|
|
7
7
|
---
|
|
8
8
|
|
package/dist/cli.mjs
CHANGED
|
@@ -2797,6 +2797,7 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
|
|
|
2797
2797
|
PI_RESEARCH_AVG_TOKENS_PER_SCRAPE: String(config.AVG_TOKENS_PER_SCRAPE),
|
|
2798
2798
|
PI_RESEARCH_MAX_CONCURRENT_SCRAPES: String(config.MAX_CONCURRENT_SCRAPES),
|
|
2799
2799
|
PI_RESEARCH_MAX_SCRAPE_URLS: String(config.MAX_SCRAPE_URLS),
|
|
2800
|
+
PI_RESEARCH_SCRAPE_SECOND_PAGE: String(config.SCRAPE_SECOND_PAGE),
|
|
2800
2801
|
PI_RESEARCH_YOUTUBE_TRANSCRIPT_MAX_VIDEOS: String(config.YOUTUBE_TRANSCRIPT_MAX_VIDEOS),
|
|
2801
2802
|
PI_RESEARCH_YOUTUBE_TRANSCRIPT_TIMEOUT_MS: String(config.YOUTUBE_TRANSCRIPT_TIMEOUT_MS),
|
|
2802
2803
|
PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG: config.YOUTUBE_TRANSCRIPT_LANG,
|
|
@@ -2959,6 +2960,7 @@ function createConfig(env, processEnv) {
|
|
|
2959
2960
|
AVG_TOKENS_PER_SCRAPE: parseEnvNumber(e, "PI_RESEARCH_AVG_TOKENS_PER_SCRAPE", DEFAULTS.AVG_TOKENS_PER_SCRAPE, 500, 1e4),
|
|
2960
2961
|
MAX_CONCURRENT_SCRAPES: parseEnvNumber(e, "PI_RESEARCH_MAX_CONCURRENT_SCRAPES", DEFAULTS.MAX_CONCURRENT_SCRAPES, 1, 20, true),
|
|
2961
2962
|
MAX_SCRAPE_URLS: parseEnvNumber(e, "PI_RESEARCH_MAX_SCRAPE_URLS", DEFAULTS.MAX_SCRAPE_URLS, 1, 20, true),
|
|
2963
|
+
SCRAPE_SECOND_PAGE: parseEnvBool(e, "PI_RESEARCH_SCRAPE_SECOND_PAGE", DEFAULTS.SCRAPE_SECOND_PAGE),
|
|
2962
2964
|
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: parseEnvNumber(e, "PI_RESEARCH_YOUTUBE_TRANSCRIPT_MAX_VIDEOS", DEFAULTS.YOUTUBE_TRANSCRIPT_MAX_VIDEOS, 1, 5, true),
|
|
2963
2965
|
YOUTUBE_TRANSCRIPT_TIMEOUT_MS: parseEnvNumber(e, "PI_RESEARCH_YOUTUBE_TRANSCRIPT_TIMEOUT_MS", DEFAULTS.YOUTUBE_TRANSCRIPT_TIMEOUT_MS, 5e3, 12e4),
|
|
2964
2966
|
YOUTUBE_TRANSCRIPT_LANG: parseEnvString(e, "PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG", DEFAULTS.YOUTUBE_TRANSCRIPT_LANG),
|
|
@@ -3105,6 +3107,8 @@ var init_config = __esm({
|
|
|
3105
3107
|
MAX_CONCURRENT_SCRAPES: Type.Number({ minimum: 1, maximum: 20, default: 3 }),
|
|
3106
3108
|
/** Maximum URLs fetched per scrape batch; extras are listed under "Not Fetched — Over Batch Cap" (default: 8, range: 1-20) */
|
|
3107
3109
|
MAX_SCRAPE_URLS: Type.Number({ minimum: 1, maximum: 20, default: 8 }),
|
|
3110
|
+
/** Also fetch DuckDuckGo results page 2 for every search query (default: false). Configured via env/config file; not surfaced in the config TUI. */
|
|
3111
|
+
SCRAPE_SECOND_PAGE: Type.Boolean({ default: false }),
|
|
3108
3112
|
/** Max YouTube videos transcribed per youtube_transcript call (default: 3, range 1-5) */
|
|
3109
3113
|
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: Type.Number({ minimum: 1, maximum: 5, default: 3 }),
|
|
3110
3114
|
/** Per-video timeout for the youtube_transcript tool in ms (default: 20000, range 5-120s) */
|
|
@@ -3171,11 +3175,12 @@ var init_config = __esm({
|
|
|
3171
3175
|
* the model's real maxTokens at call time.
|
|
3172
3176
|
* Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
|
|
3173
3177
|
SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
|
|
3174
|
-
/** LLM Model override for researcher sub-agents
|
|
3178
|
+
/** LLM Model override for the evaluator/synthesis rounds and researcher sub-agents.
|
|
3175
3179
|
* Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
|
|
3176
|
-
* When set, this
|
|
3177
|
-
* and the knowledge synthesis background LLM.
|
|
3178
|
-
*
|
|
3180
|
+
* When set, this governs the evaluator/synthesis rounds and the researcher
|
|
3181
|
+
* sub-agents (both deep and quick) and the knowledge synthesis background LLM.
|
|
3182
|
+
* The initial coordinator (round 1 planning) uses the parent chat's model (ctx.model)
|
|
3183
|
+
* for prompt-cache continuity with the conversation that triggered research.
|
|
3179
3184
|
*/
|
|
3180
3185
|
RESEARCH_MODEL: Type.Optional(Type.String()),
|
|
3181
3186
|
/** Explicit directory for the knowledge store database (overrides default) */
|
|
@@ -3242,6 +3247,7 @@ var init_config = __esm({
|
|
|
3242
3247
|
"PI_RESEARCH_AVG_TOKENS_PER_SCRAPE",
|
|
3243
3248
|
"PI_RESEARCH_MAX_CONCURRENT_SCRAPES",
|
|
3244
3249
|
"PI_RESEARCH_MAX_SCRAPE_URLS",
|
|
3250
|
+
"PI_RESEARCH_SCRAPE_SECOND_PAGE",
|
|
3245
3251
|
"PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS",
|
|
3246
3252
|
"PI_RESEARCH_SEARCH_TIMEOUT_MS",
|
|
3247
3253
|
"PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS",
|
|
@@ -4069,7 +4075,7 @@ function withTimeout2(promise, timeoutMs, label, signal) {
|
|
|
4069
4075
|
}
|
|
4070
4076
|
function messageIsTransient(message) {
|
|
4071
4077
|
const m = message.toLowerCase();
|
|
4072
|
-
if (m.includes("econnrefused") || m.includes("enotfound") || m.includes("timeout") || m.includes("etimedout") || m.includes("econnreset") || m.includes("fetch failed") || m.includes("terminated") || m.includes("other side closed") || m.includes("socket hang up") || m.includes("und_err")) {
|
|
4078
|
+
if (m.includes("econnrefused") || m.includes("enotfound") || m.includes("timeout") || m.includes("etimedout") || m.includes("econnreset") || m.includes("fetch failed") || m.includes("terminated") || m.includes("other side closed") || m.includes("socket hang up") || m.includes("und_err") || m.includes("connection error")) {
|
|
4073
4079
|
return true;
|
|
4074
4080
|
}
|
|
4075
4081
|
if (/\b429\b/.test(m) || /\brate\b/.test(m) || m.includes("quota")) {
|
|
@@ -11510,13 +11516,29 @@ You are in the late phase of research. Set a higher threshold for delegation:
|
|
|
11510
11516
|
}
|
|
11511
11517
|
}
|
|
11512
11518
|
function parseJsonPlan(text) {
|
|
11513
|
-
|
|
11519
|
+
let normalized = text.trim();
|
|
11520
|
+
if (normalized.startsWith('"')) {
|
|
11521
|
+
try {
|
|
11522
|
+
const unwrapped = JSON.parse(normalized);
|
|
11523
|
+
if (typeof unwrapped === "string") normalized = unwrapped.trim();
|
|
11524
|
+
} catch {
|
|
11525
|
+
}
|
|
11526
|
+
}
|
|
11527
|
+
const result = extractJson(normalized, "object");
|
|
11514
11528
|
if (!result.success || !result.value) {
|
|
11515
11529
|
const preview = text.length > 100 ? text.slice(0, 100) + "..." : text;
|
|
11516
11530
|
throw new Error(`Failed to extract valid JSON plan: ${result.error}. Raw response preview: "${preview}"`);
|
|
11517
11531
|
}
|
|
11532
|
+
const candidate = result.value;
|
|
11533
|
+
if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
|
|
11534
|
+
for (const key of ["researchers", "allQueries", "content", "title", "action", "fallback"]) {
|
|
11535
|
+
if (candidate[key] === null) {
|
|
11536
|
+
delete candidate[key];
|
|
11537
|
+
}
|
|
11538
|
+
}
|
|
11539
|
+
}
|
|
11518
11540
|
try {
|
|
11519
|
-
const coerced = Value3.Convert(ResearchPlanSchema,
|
|
11541
|
+
const coerced = Value3.Convert(ResearchPlanSchema, candidate);
|
|
11520
11542
|
if (!Value3.Check(ResearchPlanSchema, coerced)) {
|
|
11521
11543
|
const errors = [...Value3.Errors(ResearchPlanSchema, coerced)];
|
|
11522
11544
|
const errorMsg = errors.map((e) => `${e.path}: ${e.message}`).join(", ");
|
|
@@ -12676,6 +12698,7 @@ function getBrowserEnv(config) {
|
|
|
12676
12698
|
const c = config || getConfig();
|
|
12677
12699
|
env["PI_RESEARCH_SCRAPE_TIMEOUT_MS"] = String(c.SCRAPE_TIMEOUT_MS);
|
|
12678
12700
|
env["PI_RESEARCH_SEARCH_TIMEOUT_MS"] = String(c.SEARCH_TIMEOUT_MS);
|
|
12701
|
+
env["PI_RESEARCH_SCRAPE_SECOND_PAGE"] = String(c.SCRAPE_SECOND_PAGE);
|
|
12679
12702
|
env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"] = String(c.HEALTH_CHECK_TIMEOUT_MS);
|
|
12680
12703
|
return env;
|
|
12681
12704
|
}
|
|
@@ -16523,7 +16546,7 @@ var WorkerPoolManager = class {
|
|
|
16523
16546
|
}
|
|
16524
16547
|
},
|
|
16525
16548
|
exitHandler: (code) => {
|
|
16526
|
-
if (code !== 0 && this.poolEpoch === myEpoch) {
|
|
16549
|
+
if (code !== 0 && !this.isShuttingDown && this.poolEpoch === myEpoch) {
|
|
16527
16550
|
logger.error(`[WorkerPoolManager] Worker exited with code ${code}`);
|
|
16528
16551
|
this.consecutiveErrors++;
|
|
16529
16552
|
if (this.consecutiveErrors >= 3) {
|
|
@@ -24621,7 +24644,8 @@ var DeepResearchOrchestrator = class {
|
|
|
24621
24644
|
const { model, query, complexity, researchId, ctx } = this.options;
|
|
24622
24645
|
const observer = this.observer;
|
|
24623
24646
|
const container = tryGetServiceContainerFromCtx(ctx);
|
|
24624
|
-
const
|
|
24647
|
+
const initialCoordinatorModel = ctx.model ?? model;
|
|
24648
|
+
const evaluatorModel = model;
|
|
24625
24649
|
const orchestrationService = await this.getOrchestrationService();
|
|
24626
24650
|
const planningService = await this.getPlanningService();
|
|
24627
24651
|
await orchestrationService.cleanupResearchServices(void 0, researchId, ctx, this.config);
|
|
@@ -24685,7 +24709,7 @@ var DeepResearchOrchestrator = class {
|
|
|
24685
24709
|
sessionId: researchId,
|
|
24686
24710
|
query,
|
|
24687
24711
|
complexity,
|
|
24688
|
-
model:
|
|
24712
|
+
model: initialCoordinatorModel,
|
|
24689
24713
|
modelRegistry: ctx.modelRegistry,
|
|
24690
24714
|
cwd: ctx.cwd,
|
|
24691
24715
|
config: this.config,
|
|
@@ -24705,7 +24729,7 @@ var DeepResearchOrchestrator = class {
|
|
|
24705
24729
|
query,
|
|
24706
24730
|
complexity,
|
|
24707
24731
|
round: this.currentRound,
|
|
24708
|
-
model:
|
|
24732
|
+
model: evaluatorModel,
|
|
24709
24733
|
modelRegistry: ctx.modelRegistry,
|
|
24710
24734
|
cwd: ctx.cwd,
|
|
24711
24735
|
config: this.config,
|
|
@@ -24870,7 +24894,7 @@ var DeepResearchOrchestrator = class {
|
|
|
24870
24894
|
query,
|
|
24871
24895
|
complexity,
|
|
24872
24896
|
round: this.currentRound,
|
|
24873
|
-
model:
|
|
24897
|
+
model: evaluatorModel,
|
|
24874
24898
|
modelRegistry: ctx.modelRegistry,
|
|
24875
24899
|
cwd: ctx.cwd,
|
|
24876
24900
|
config: this.config,
|
|
@@ -26073,6 +26097,7 @@ async function initializeOrchestrationServices(ctx, container = getServiceContai
|
|
|
26073
26097
|
|
|
26074
26098
|
// src/sdk.ts
|
|
26075
26099
|
init_service_registry();
|
|
26100
|
+
init_safe_unref();
|
|
26076
26101
|
init_service_interfaces();
|
|
26077
26102
|
init_logger();
|
|
26078
26103
|
init_log_utils();
|
|
@@ -26307,6 +26332,17 @@ async function _doInit(options = {}) {
|
|
|
26307
26332
|
}
|
|
26308
26333
|
isInitialized = true;
|
|
26309
26334
|
logger.log("[SDK] Research SDK initialized successfully");
|
|
26335
|
+
shutdownManager.register(async () => {
|
|
26336
|
+
if (!globalContainer) return;
|
|
26337
|
+
const schedulerService = tryGetService(ServiceNames.SCHEDULER, globalContainer);
|
|
26338
|
+
const scheduler = schedulerService?.getScheduler?.();
|
|
26339
|
+
if (!scheduler) return;
|
|
26340
|
+
try {
|
|
26341
|
+
await raceWithDeadline(scheduler.shutdown(), 12e3);
|
|
26342
|
+
} catch (err) {
|
|
26343
|
+
logger.error("[SDK] Error stopping scheduler during shutdown cleanup:", err);
|
|
26344
|
+
}
|
|
26345
|
+
});
|
|
26310
26346
|
_registerSignalHandlers();
|
|
26311
26347
|
} catch (err) {
|
|
26312
26348
|
logger.error("[SDK] Initialization failed:", err);
|
package/dist/thread-worker.mjs
CHANGED
|
@@ -2014,6 +2014,8 @@ function createKillHandler() {
|
|
|
2014
2014
|
|
|
2015
2015
|
// src/infrastructure/browser/thread-worker-browser.ts
|
|
2016
2016
|
import { platform as platform2 } from "node:os";
|
|
2017
|
+
import { readFileSync, existsSync as existsSync3 } from "node:fs";
|
|
2018
|
+
import { homedir as homedir2 } from "node:os";
|
|
2017
2019
|
|
|
2018
2020
|
// src/infrastructure/browser/thread-worker-messaging.ts
|
|
2019
2021
|
init_log_utils();
|
|
@@ -2143,15 +2145,23 @@ async function setupMocking(context) {
|
|
|
2143
2145
|
const isDuckDuckGo = url.hostname === "duckduckgo.com" || url.hostname === "lite.duckduckgo.com" || url.hostname.endsWith(".duckduckgo.com");
|
|
2144
2146
|
if (mockSearch && isDuckDuckGo) {
|
|
2145
2147
|
logToDebugFile2("DEBUG", `[Worker-${workerId2}] Mocking search response for: ${urlStr}`);
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
<
|
|
2153
|
-
|
|
2154
|
-
|
|
2148
|
+
const isSecondPageRequest = request.method() === "POST" && typeof request.postData() === "string" && /(?:^|&)s=2(?:&|$)/.test(request.postData());
|
|
2149
|
+
const mockResultsHtml = isSecondPageRequest ? `
|
|
2150
|
+
<table class="result-link">
|
|
2151
|
+
<tr>
|
|
2152
|
+
<td><a class="result-link" href="https://example.com/mock-result-3?uddg=https%3A%2F%2Fexample.com%2Ftarget-3">Mock Result 3</a></td>
|
|
2153
|
+
</tr>
|
|
2154
|
+
<tr>
|
|
2155
|
+
<td class="result-snippet">Page-two mocked search result snippet.</td>
|
|
2156
|
+
</tr>
|
|
2157
|
+
<tr>
|
|
2158
|
+
<td><a class="result-link" href="https://example.com/mock-result-4?uddg=https%3A%2F%2Fexample.com%2Ftarget-4">Mock Result 4</a></td>
|
|
2159
|
+
</tr>
|
|
2160
|
+
<tr>
|
|
2161
|
+
<td class="result-snippet">Another page-two mocked snippet to verify second-page extraction.</td>
|
|
2162
|
+
</tr>
|
|
2163
|
+
</table>
|
|
2164
|
+
` : `
|
|
2155
2165
|
<table class="result-link">
|
|
2156
2166
|
<tr>
|
|
2157
2167
|
<td><a class="result-link" href="https://example.com/mock-result-1?uddg=https%3A%2F%2Fexample.com%2Ftarget-1">Mock Result 1</a></td>
|
|
@@ -2166,6 +2176,23 @@ async function setupMocking(context) {
|
|
|
2166
2176
|
<td class="result-snippet">Another mocked snippet to verify multiple result extraction.</td>
|
|
2167
2177
|
</tr>
|
|
2168
2178
|
</table>
|
|
2179
|
+
<form action="/lite/" method="post">
|
|
2180
|
+
<input type="hidden" name="q" value="">
|
|
2181
|
+
<input type="hidden" name="s" value="2">
|
|
2182
|
+
<input type="hidden" name="dc" value="3">
|
|
2183
|
+
<input type="submit" value="Next">
|
|
2184
|
+
</form>
|
|
2185
|
+
`;
|
|
2186
|
+
route.fulfill({
|
|
2187
|
+
status: 200,
|
|
2188
|
+
contentType: "text/html",
|
|
2189
|
+
body: `
|
|
2190
|
+
<html><head><title>DuckDuckGo Lite</title></head><body>
|
|
2191
|
+
<form action="/lite/" method="post">
|
|
2192
|
+
<input name="q" type="text">
|
|
2193
|
+
<input type="submit" value="Search">
|
|
2194
|
+
</form>
|
|
2195
|
+
${mockResultsHtml}
|
|
2169
2196
|
</body></html>
|
|
2170
2197
|
`
|
|
2171
2198
|
});
|
|
@@ -2186,10 +2213,11 @@ async function setupMocking(context) {
|
|
|
2186
2213
|
async function extractSearchResults(page) {
|
|
2187
2214
|
return await page.evaluate(() => {
|
|
2188
2215
|
const found = [];
|
|
2189
|
-
const links = Array.from(document.querySelectorAll("a.result-link"));
|
|
2216
|
+
const links = Array.from(document.querySelectorAll("a.result-link, a.result__a"));
|
|
2190
2217
|
links.forEach((link) => {
|
|
2191
|
-
const row = link.closest("tr");
|
|
2192
|
-
const
|
|
2218
|
+
const row = link.closest("tr") || link.closest(".result");
|
|
2219
|
+
const snippetEl = row?.querySelector("td.result-snippet") || row?.querySelector("a.result__snippet") || row?.querySelector(".result__snippet") || row?.nextElementSibling?.querySelector("td.result-snippet");
|
|
2220
|
+
const snippet = snippetEl?.textContent?.trim() || "";
|
|
2193
2221
|
const title = link.textContent?.trim() || "";
|
|
2194
2222
|
let url = link.href;
|
|
2195
2223
|
try {
|
|
@@ -2230,14 +2258,41 @@ async function executeSearchTask(_context, query, signal) {
|
|
|
2230
2258
|
if (process.env["PI_RESEARCH_MOCK_SEARCH"] === "true") {
|
|
2231
2259
|
await page.goto(`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`, { waitUntil: "domcontentloaded" });
|
|
2232
2260
|
} else {
|
|
2233
|
-
|
|
2234
|
-
await page.
|
|
2235
|
-
await
|
|
2236
|
-
page.
|
|
2237
|
-
|
|
2238
|
-
|
|
2261
|
+
const qurl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
2262
|
+
const nav = await page.goto(qurl, { waitUntil: "domcontentloaded" });
|
|
2263
|
+
if ((!nav || nav.status && nav.status() > 399) && !await page.$("a.result__a, a.result-link")) {
|
|
2264
|
+
await page.goto(`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`, { waitUntil: "domcontentloaded" });
|
|
2265
|
+
}
|
|
2266
|
+
try {
|
|
2267
|
+
await page.waitForSelector("a.result__a, a.result-link", { timeout: 8e3 });
|
|
2268
|
+
} catch {
|
|
2269
|
+
}
|
|
2239
2270
|
}
|
|
2240
2271
|
const results = await extractSearchResults(page);
|
|
2272
|
+
const scrapeSecondPage = process.env["PI_RESEARCH_SCRAPE_SECOND_PAGE"] === "true";
|
|
2273
|
+
if (scrapeSecondPage && results.length > 0) {
|
|
2274
|
+
try {
|
|
2275
|
+
const nextButton = page.locator('form input[type="submit"][value*="next" i]').first();
|
|
2276
|
+
if (await nextButton.count() > 0) {
|
|
2277
|
+
logToDebugFile2("DEBUG", `[Worker-${workerId2}] SCRAPE_SECOND_PAGE: fetching results page 2 for: ${query}`);
|
|
2278
|
+
await Promise.all([
|
|
2279
|
+
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
|
2280
|
+
nextButton.click()
|
|
2281
|
+
]);
|
|
2282
|
+
const seen = new Set(results.map((r) => r.url));
|
|
2283
|
+
for (const r of await extractSearchResults(page)) {
|
|
2284
|
+
if (!seen.has(r.url)) {
|
|
2285
|
+
seen.add(r.url);
|
|
2286
|
+
results.push(r);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
} else {
|
|
2290
|
+
logToDebugFile2("DEBUG", `[Worker-${workerId2}] SCRAPE_SECOND_PAGE: no next-page control (single page of results) for: ${query}`);
|
|
2291
|
+
}
|
|
2292
|
+
} catch (err) {
|
|
2293
|
+
logToDebugFile2("WARN", `[Worker-${workerId2}] SCRAPE_SECOND_PAGE: page-2 fetch failed (keeping page-1 results) for: ${query} \u2014 ${err?.message || String(err)}`);
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2241
2296
|
await page.close();
|
|
2242
2297
|
const jitterBase = process.env["PI_RESEARCH_MOCK_SEARCH"] === "true" ? 10 : 500;
|
|
2243
2298
|
const jitterRange = process.env["PI_RESEARCH_MOCK_SEARCH"] === "true" ? 10 : 1e3;
|
|
@@ -2669,6 +2724,8 @@ var ConfigSchema = Type.Object({
|
|
|
2669
2724
|
MAX_CONCURRENT_SCRAPES: Type.Number({ minimum: 1, maximum: 20, default: 3 }),
|
|
2670
2725
|
/** Maximum URLs fetched per scrape batch; extras are listed under "Not Fetched — Over Batch Cap" (default: 8, range: 1-20) */
|
|
2671
2726
|
MAX_SCRAPE_URLS: Type.Number({ minimum: 1, maximum: 20, default: 8 }),
|
|
2727
|
+
/** Also fetch DuckDuckGo results page 2 for every search query (default: false). Configured via env/config file; not surfaced in the config TUI. */
|
|
2728
|
+
SCRAPE_SECOND_PAGE: Type.Boolean({ default: false }),
|
|
2672
2729
|
/** Max YouTube videos transcribed per youtube_transcript call (default: 3, range 1-5) */
|
|
2673
2730
|
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: Type.Number({ minimum: 1, maximum: 5, default: 3 }),
|
|
2674
2731
|
/** Per-video timeout for the youtube_transcript tool in ms (default: 20000, range 5-120s) */
|
|
@@ -2735,11 +2792,12 @@ var ConfigSchema = Type.Object({
|
|
|
2735
2792
|
* the model's real maxTokens at call time.
|
|
2736
2793
|
* Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
|
|
2737
2794
|
SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
|
|
2738
|
-
/** LLM Model override for researcher sub-agents
|
|
2795
|
+
/** LLM Model override for the evaluator/synthesis rounds and researcher sub-agents.
|
|
2739
2796
|
* Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
|
|
2740
|
-
* When set, this
|
|
2741
|
-
* and the knowledge synthesis background LLM.
|
|
2742
|
-
*
|
|
2797
|
+
* When set, this governs the evaluator/synthesis rounds and the researcher
|
|
2798
|
+
* sub-agents (both deep and quick) and the knowledge synthesis background LLM.
|
|
2799
|
+
* The initial coordinator (round 1 planning) uses the parent chat's model (ctx.model)
|
|
2800
|
+
* for prompt-cache continuity with the conversation that triggered research.
|
|
2743
2801
|
*/
|
|
2744
2802
|
RESEARCH_MODEL: Type.Optional(Type.String()),
|
|
2745
2803
|
/** Explicit directory for the knowledge store database (overrides default) */
|
|
@@ -2799,6 +2857,25 @@ var CONTEXT_CREATION_TIMEOUT_MS = 3e4;
|
|
|
2799
2857
|
var COLD_START_ALLOWANCE_MS = BROWSER_LAUNCH_TIMEOUT_MS + CONTEXT_CREATION_TIMEOUT_MS;
|
|
2800
2858
|
|
|
2801
2859
|
// src/infrastructure/browser/thread-worker-browser.ts
|
|
2860
|
+
function loadSocksProxyPrefs() {
|
|
2861
|
+
try {
|
|
2862
|
+
const p = `${homedir2()}/.pi/research/proxy.json`;
|
|
2863
|
+
if (!existsSync3(p)) return {};
|
|
2864
|
+
const cfg = JSON.parse(readFileSync(p, "utf8"));
|
|
2865
|
+
const host = cfg.host ?? "127.0.0.1";
|
|
2866
|
+
const port = cfg.port ?? 1080;
|
|
2867
|
+
return {
|
|
2868
|
+
"network.proxy.type": 1,
|
|
2869
|
+
"network.proxy.socks": host,
|
|
2870
|
+
"network.proxy.socks_port": port,
|
|
2871
|
+
"network.proxy.socks_remote_dns": true,
|
|
2872
|
+
"network.proxy.socks_version": 5,
|
|
2873
|
+
"network.proxy.no_proxies_on": ""
|
|
2874
|
+
};
|
|
2875
|
+
} catch {
|
|
2876
|
+
return {};
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2802
2879
|
var browser = null;
|
|
2803
2880
|
var initPromise = null;
|
|
2804
2881
|
var workerId3 = "";
|
|
@@ -2904,6 +2981,7 @@ async function initBrowser() {
|
|
|
2904
2981
|
// but headless Firefox on macOS/Windows is not guaranteed to be
|
|
2905
2982
|
// silent either, so the mute is applied unconditionally.
|
|
2906
2983
|
firefox_user_prefs: {
|
|
2984
|
+
...loadSocksProxyPrefs(),
|
|
2907
2985
|
"media.volume_scale": "0.0",
|
|
2908
2986
|
"media.autoplay.default": 5,
|
|
2909
2987
|
// SSRF: WebSocket connections bypass BOTH layers of the browser
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,39 @@ 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.6.11] - 2026-09-04
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **New `RESEARCH_MODEL` config setting (env `PI_RESEARCH_MODEL`) separates the research engine's model from the host pi session's model.** When unset, everything runs on the host model exactly as before — the setting is strictly optional. When set, the split is: round-1 planning (`generatePlan`) keeps running on the host session's model (`ctx.model`, falling back to `RESEARCH_MODEL`) for prompt-cache continuity with the ongoing conversation, while round-2+ plan updates and the final synthesis — the calls that do the long-form research writing — run on `RESEARCH_MODEL`. Wired through the config schema (TypeBox string, `src/config.ts`), strict env parsing, `saveConfig` persistence, and documented in `docs/CONFIGURATION.md`; `src/sdk.ts` and the `research_knowledge_search` tool text describe the split.
|
|
13
|
+
|
|
14
|
+
- **Dependency upgrades across the board; `npm audit` reports 0 vulnerabilities.** pi extension packages (`@earendil-works/*`) moved to 0.85.0 with `PI_MIN_VERSION` / `PI_TESTED_MAX_VERSION` following, and `@earendil-works/pi-server` added; the dev stack moved to vitest 5, jsdom 30, eslint 10.10, and dependency-cruiser 18. Three upgrades were deliberately held back by verified constraints: typescript stays `< 6.1.0` (@typescript-eslint 8.69's peer ceiling), playwright-core stays `1.60.0` (camoufox-js requires `< 1.61.0`), and apache-arrow stays `21.1.0` (21.2.0 breaks LanceDB's IPC reads — docs/ARCHITECTURE.md documents why).
|
|
15
|
+
- **New boolean config setting `SCRAPE_SECOND_PAGE` (env `PI_RESEARCH_SCRAPE_SECOND_PAGE`, default `false`): when enabled, every search query also fetches DuckDuckGo Lite's results page 2, roughly doubling the candidate links each query contributes.** The thread-worker's search task (`src/infrastructure/browser/thread-worker-messaging.ts`) now clicks DDG Lite's own next-page form (the hidden `s`/`dc` offsets + "Next" submit at the bottom of the results table) rather than hand-building a page-2 URL, so the offsets the site itself issued stay authoritative whatever per-page count DuckDuckGo currently serves; page-2 results are URL-deduped against page 1 in-worker, and cross-query dedup continues to happen in `performSearch`'s `seenUrls` set. Failure policy: page-1 results are already complete and usable, so any page-2 problem — no next control (single page of results), click/navigation timeout, abort-triggered page close — degrades to page-1-only with a WARN line, never a failed query. The setting follows the full knob integration path: Config schema (TypeBox boolean, `src/config.ts`), strict env parsing (`parseEnvBool`, literal `true` only), the env allowlist, `saveConfig` persistence, and the worker env bridge in `getBrowserEnv` (workers read it from their `process.env` copy). The mock DDG handler now also serves a DISTINCT page-2 result set when a `s=2` POST arrives, making the whole flow end-to-end testable offline; six new worker tests pin the merge, dedup, flag-off, no-next-control, click-failure, and empty-page-1 paths (`test/unit/infrastructure/thread-worker-search-second-page.test.ts`), plus config parse/persist round-trip tests. Not surfaced in the `/research-config` TUI menu, matching every other env-only scrape knob.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **Ctrl+C could quit the entire pi process when the user pressed it twice to abort a research run — and one physical press could clear the editor AND abort the run 150ms later.** Root causes found by reading pi's actual input pipeline (dist/modes/interactive/interactive-mode.js + @earendil-works/pi-tui) and confirmed against the live logs: (1) Extension `onTerminalInput` listeners run BEFORE the focused editor and may consume keys, and pi's `handleCtrlC()` is TIME-based, not content-based — the first press clears the editor and arms a window, and a SECOND press within 500ms calls `shutdown()`, exiting the whole pi process; there is no "abort research" step in it. The previous fix made the extension abort the active run when the editor was empty but still let the key fall through, so the natural double-press gesture (clear, then abort-now) landed inside pi's 500ms window and killed pi itself. (2) Pi runs the Kitty keyboard protocol with flag 2 (press/repeat/release reporting), so every physical key sends TWO sequences ~100-200ms apart — and Tui.handleTerminalInput filters releases only for the focused component, AFTER extension listeners run, while `matchesKey` cannot tell them apart (`matchesKittySequence` parses codepoint+modifier but never the event type, so a ctrl+c RELEASE `\x1b[99;5:3u` matches `matchesKey(data, 'ctrl+c')` exactly like a press). Observed live 2026-09-03 (log lines 150ms apart, same pid): one physical Ctrl+C with text in the editor logged "with editor text — clearing only", then the RELEASE event matched ctrl+c against the editor pi had just cleared and aborted the research run; the same double-fire masqueraded as "Esc" aborts in older logs. The cancel policy in `src/tui/tui-controller.ts` now (a) drops key-RELEASE events outright — pi's own components never see them, so this matches pi's semantics exactly; key repeats stay, as genuine held-key intent — and (b) fully owns Ctrl+C while a run is active: editor has text → fall through (pi clears, run continues); editor empty AND run active → abort the run AND consume the key so pi's exit path can never fire; editor empty AND no run → untouched native behavior (clear/arm, double-press exit keeps working). Esc keeps its panic-cancel role and still falls through to pi's app.interrupt. Verified that consuming Ctrl+C cannot break copying: no `tui.input.copy` handler exists in pi's runtime (message copy is Ctrl+X, terminal selection copy is the emulator's own). Thirteen policy tests pin the contract, including real Kitty press/release/repeat sequences (`test/unit/tui/tui-controller.test.ts`).
|
|
20
|
+
|
|
21
|
+
- **Doubly-encoded plan payloads no longer fail plan parsing, and explicit nulls for optional plan fields are treated as absent.** Two live-failure classes from 2026-09-02 (`src/core/planning-utils.ts`): some models (observed: deepseek-v4-flash via OpenRouter) call `submit_plan` with the plan JSON-encoded INSIDE a string argument, so the toolCall re-serialization hands `parseJsonPlan` a doubly-encoded payload whose raw `{`-scan cannot parse (stray `\"` after `{`), failing with the misleading "No valid JSON found" and dropping the plan into the costly agentic-repair path — `parseJsonPlan` now unwraps a top-level JSON string literal once before extraction (safe by construction: any object resolvable in the outer string is resolvable in the inner one). Separately, models emit optional schema fields (`researchers`/`allQueries`/`content`/`title`/`action`/`fallback`) as explicit `null`: under TypeBox 1.3.7 a null optional field reports a ROOT error ("undefined: must be object") instead of pointing at the field — reproducing "Plan validation failed: undefined: must be object" in the logs — and pushed a perfectly valid `research-complete` synthesize decision into agentic repair. The six optional keys (the complete optional-field list of `ResearchPlanSchema`) are now dropped when null before validation; required-field nulls inside researcher objects still fail validation as they should. Both behaviors are pinned by new tests.
|
|
22
|
+
|
|
23
|
+
- **Worker-pool auto-recovery no longer counts shutdown-window worker deaths as health faults (`src/infrastructure/browser/worker-pool-manager.ts`).** The SIGTERM teardown tears the process down while workers die with code null mid-DAG-disposal; the exitHandler counted those as unexpected deaths, logging ERROR and scheduling pointless auto-recovery on a dying process — all 73 null-code exits across 15 pids in the 2026-09-01/02 logs coincided with SDK SIGTERM shutdown, tripping "3 consecutive exits → pool unhealthy" once per dying session, with zero exits during live research. The exitHandler now skips health-fault accounting while `isShuttingDown` is set; the existing `poolEpoch` check still guards all intentional-destroy paths, and post-shutdown pool rebuilds start clean.
|
|
24
|
+
|
|
25
|
+
- **The browser scheduler is now stopped as the FIRST cleanup task of an SDK shutdown (`src/sdk.ts`), and this runs on every teardown path — signal handlers and cooperative `shutdownResearchSDK()` alike.** Previously the sdk_shutdown cleanup list was empty for scheduler teardown: the scheduler's cluster workers died mid-DAG-disposal with code null while the worker pool was still live, which the pool's exitHandler counted as unexpected deaths ("3 consecutive exits → pool unhealthy") on a process that was exiting anyway. Stopping the scheduler first puts those deaths inside the pool's intentional-destroy window. The task is bounded by `raceWithDeadline(..., 12000)` well inside the 15s signal force-exit deadline, both teardown paths are idempotent (the later DAG dispose no-ops), and per-init registration cannot stack because `runCleanup` detaches the task set after running.
|
|
26
|
+
|
|
27
|
+
- **The OpenAI SDK's `APIConnectionError` message ("Connection error.") is now classified as transient (`src/web-research/retry-utils.ts`).** pi-ai surfaces this message shape for openai-completions providers like OpenRouter when the fetch itself fails before any response (refused / reset / DNS / TLS) — the SDK throws this class only for fetch-level transport failures, after its own 2 internal retries are exhausted, so the string is a reliable transient signal. Seen live 2026-09-02: an OpenRouter planning call died in 4s with zero retries because the pattern was missing, and the same miss made `isDegradableLlmError` throw instead of degrading to the fallback plan — both layers gate on this matcher.
|
|
28
|
+
|
|
29
|
+
- **The DDG search path no longer POSTs the form endpoint; it drives a direct GET of the `html` endpoint with a wait for results, and degrades to the Lite layout when the HTML endpoint serves nothing (`src/infrastructure/browser/thread-worker-messaging.ts`).** The form POST was the first thing DDG's bot defenses throttled (live 2026-09-02: every query timed out in `page.goto`), while a plain GET of `https://html.duckduckgo.com/html/?q=…` still renders the full result list. The task now navigates to the GET URL, waits for a result anchor (`a.result__a` / `a.result-link`) to appear (bounded, abort-aware), and falls back to the Lite host layout when the HTML endpoint returns an empty page. Snippet extraction was rewritten to match both layouts at once: in the HTML layout the snippet (`a.result__snippet` / `.result__snippet`) sits inside the same `div.result` container as the title link, while in the Lite layout each result is a TWO-row pair — the link `<tr>` and a following snippet `<tr>` — so `extractSearchResults` first tries the inside-row selectors, then `row.nextElementSibling`'s `td.result-snippet` (the original rewrite searched only inside the row, silently dropping every Lite snippet). A `s=2` POST is still used for page 2 under `SCRAPE_SECOND_PAGE`, reusing the offsets the site itself issued.
|
|
30
|
+
|
|
31
|
+
- **Browser workers now route through the local SOCKS proxy when `~/.pi/research/proxy.json` is present (`src/infrastructure/browser/thread-worker-browser.ts`).** Some egress paths can reach only one of DuckDuckGo's front-door IPs while the system resolver hands out the unreachable one, blackholing every DDG hostname for every HTTP client. When the proxy descriptor file exists (`{"host":"127.0.0.1","port":1080}`), the worker launches the browser with `--proxy-server=socks5://host:port` and remote DNS on, so hostname resolution happens at the proxy — which pins `*.duckduckgo.com` to verified-live front-door IPs and uses normal DNS for everything else. The file-based contract (not env) was chosen deliberately: a long-lived leader process picks up proxy changes without a restart, and a missing/invalid file is swallowed to `{}` so the browser launches direct.
|
|
32
|
+
|
|
33
|
+
- **The bundled SOCKS proxy (`scripts/ddg-socks-proxy.mjs`) now catches the accept-then-stall front-door pattern and rotates its starting pin.** The dead front-door ACCEPTS the TCP handshake and then never answers the TLS ClientHello, so the original data-deadline — cleared on `connect` — was disarmed exactly when it was needed: clients hung until their own 45s timeout with the fallback never engaging. The data-arrival deadline now SPANS connect plus the post-connect ClientHello wait and clears only on the first upstream byte; the success reply goes out at connect (the SOCKS client cannot send its first payload until it gets the reply, and no transparent retry is possible after that), so a post-reply stall fast-fails both ends (~8s instead of 45s) and rotates the starting pin for the NEXT connection — the live front-door flips with the egress path, so the pin list self-heals toward whichever IP last worked. Two more hardening fixes in the same pass: a client that pipelined its payload with the request (instead of waiting for the success reply) no longer loses those buffered bytes, and a latent double-callback on post-handoff socket errors was fixed.
|
|
34
|
+
|
|
35
|
+
- **The README's CI build-status badge was removed** (cosmetic; the npm version, license, and repository badges remain, markup verified intact).
|
|
36
|
+
|
|
37
|
+
### Verified
|
|
38
|
+
|
|
39
|
+
- 3,046 unit tests over 241 files, ESLint clean on the touched source, all four type-checks (TS 6 src + tests, TS 7 native src + tests), build, and `npm ci --dry-run` manifest verification; `npm audit` reports 0 vulnerabilities.
|
|
40
|
+
|
|
8
41
|
## [1.6.10] - 2026-09-02
|
|
9
42
|
|
|
10
43
|
### Changed
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -12,7 +12,11 @@ environment-variable reference, and finally how the configuration layers resolve
|
|
|
12
12
|
|
|
13
13
|
Run `/research-config` in the pi extension to open an interactive menu. Selecting a
|
|
14
14
|
setting and pressing `Enter` / `Space` cycles its value; the change is saved
|
|
15
|
-
immediately.
|
|
15
|
+
immediately. (On non-TUI hosts — RPC, web hub, print, JSON, SDK — the menu cannot
|
|
16
|
+
render: `/research-config` explains why and answers with the headless diagnostics
|
|
17
|
+
`/research-config health` and `/research-config knowledge-status`. Settings are
|
|
18
|
+
still read from the environment and config files there, and `PI_RESEARCH_*`
|
|
19
|
+
variables still take effect.) A setting is written to one of two scopes:
|
|
16
20
|
|
|
17
21
|
- `[project]` — saved per working directory in the central project registry, so
|
|
18
22
|
a given repo can carry its own value without changing your global default.
|
|
@@ -72,6 +76,7 @@ Research
|
|
|
72
76
|
| `PI_RESEARCH_MAX_GATHERING_CALLS` | `12` | 1–100 | Shared web-gathering calls per researcher (`search` + `security_search` + `stackexchange` + `youtube_transcript`). |
|
|
73
77
|
| `PI_RESEARCH_MAX_CONCURRENT_SCRAPES` | `3` | 1–20 | Concurrent URLs fetched per scrape batch. |
|
|
74
78
|
| `PI_RESEARCH_MAX_SCRAPE_URLS` | `8` | 1–20 | Maximum URLs fetched per scrape batch. URLs past the cap are listed under "Not Fetched — Over Batch Cap" and must be requested in a later batch. (Promoted from a hardcoded constant so it is env/config-file tunable like every other scrape knob.) |
|
|
79
|
+
| `PI_RESEARCH_SCRAPE_SECOND_PAGE` | `false` | true/false | Also fetch DuckDuckGo results page 2 for every search query, roughly doubling the candidate links each query contributes. Each query's page-2 fetch degrades gracefully — if page 2 fails or does not exist, the page-1 results are kept. |
|
|
75
80
|
| `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
|
|
76
81
|
| `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
|
|
77
82
|
| `PI_RESEARCH_MAX_FAILED_RESEARCHERS` | `2` | 1–10 | Unique researcher failures that abort the whole run. Raise to let slower, still-in-flight researchers finish before giving up. |
|
|
@@ -79,7 +84,7 @@ Research
|
|
|
79
84
|
| `PI_RESEARCH_WORKER_CONCURRENCY` | `2` | 1–10 | Tasks per worker process. |
|
|
80
85
|
| `PI_RESEARCH_MAX_CONCURRENT_RUNS` | `3` | ≥1 | Machine-wide cap on research runs executing at the same time, across **every** process (CLI, agent skill, pi extension, SDK). Runs beyond the cap queue rather than fail. All concurrent runs share one leader-elected browser/embedding pool, so oversubscribing it degrades every run at once. |
|
|
81
86
|
| `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` | `600000` | ≥0 | How long a run queues for a free slot before failing with "maximum concurrent research runs reached" (CLI exit `75`). `0` = fail immediately instead of queueing. |
|
|
82
|
-
| `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis
|
|
87
|
+
| `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides the evaluator/synthesis rounds and researcher sub-agents and knowledge synthesis. The initial coordinator (round 1 planning) uses the pi session model for prompt-cache continuity with the conversation that triggered research. Accepts `provider/id` or a bare model id. |
|
|
83
88
|
| `PI_RESEARCH_DISABLED_TOOLS` | _(none)_ | — | Comma-separated research tools to disable for a run (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `grep`, `read`). Removed from every researcher's toolset and named in the coordinator and research-lead prompts. Strictly additive — it can only remove capabilities, never grant one, and it stacks on top of the default exclusions rather than replacing them. An unrecognized name excludes nothing and is warned about rather than failing the run. |
|
|
84
89
|
| `PI_RESEARCH_REPORT_EXPORT_ENABLED` (TUI) | `false` | — | Front-ends write a Markdown report to disk and surface its path. |
|
|
85
90
|
| `PI_RESEARCH_REPORT_EXPORT_DIR` | _(smart cwd)_ | — | Pin exported reports to a fixed directory, bypassing the cwd-relative resolution. Useful for the agent skill, which runs from the host agent's arbitrary directory. |
|
package/docs/PI-EXTENSION.md
CHANGED
|
@@ -19,7 +19,7 @@ Three slash commands are also registered:
|
|
|
19
19
|
| Command | Description |
|
|
20
20
|
|---------|-------------|
|
|
21
21
|
| `/research <query>` | Invokes the `research` tool directly at the configured default depth (`PI_RESEARCH_DEFAULT_RESEARCH_DEPTH`, 1 by default) — a plain live run with no LLM turn. It does not parse an inline depth and does **not** consult the knowledge store; use `/knowledge-store <query>` for a store-only lookup. |
|
|
22
|
-
| `/research-config` | Opens the interactive TUI settings dashboard. |
|
|
22
|
+
| `/research-config` | Opens the interactive TUI settings dashboard. On non-TUI hosts (RPC, web hub, print, JSON, SDK) the menu cannot render, so the command reports why and answers with the headless diagnostics that do work there: `/research-config health` (system health) and `/research-config knowledge-status` (knowledge-store status). |
|
|
23
23
|
| `/knowledge-store <query>` | Searches the local knowledge store for a query and returns a synthesised answer from previously researched findings. Unavailable when Knowledge Mode is `none`. The store auto-manages its own compaction, so there is no maintenance subcommand. |
|
|
24
24
|
|
|
25
25
|

|
|
@@ -57,6 +57,7 @@ During a run pi-research renders a live progress panel:
|
|
|
57
57
|
| Key | Action |
|
|
58
58
|
|-----|--------|
|
|
59
59
|
| `Escape` | Cancel active research |
|
|
60
|
+
| `Ctrl+C` | With text in the editor: clears the editor only. With an empty editor: cancels the run (same as `Escape`). |
|
|
60
61
|
| Arrow keys | Navigate the `/research-config` menu |
|
|
61
62
|
| `Enter` / `Space` | Cycle a setting's value |
|
|
62
63
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincoln504/pi-research",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.11",
|
|
4
4
|
"description": "Web research for your agents with smart and safe tooling + knowledge store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -114,28 +114,29 @@
|
|
|
114
114
|
"image": "https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/hero.png"
|
|
115
115
|
},
|
|
116
116
|
"dependencies": {
|
|
117
|
-
"@earendil-works/pi-ai": ">=0.
|
|
118
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
119
|
-
"@earendil-works/pi-tui": ">=0.
|
|
117
|
+
"@earendil-works/pi-ai": ">=0.85.0 <1",
|
|
118
|
+
"@earendil-works/pi-coding-agent": ">=0.85.0 <1",
|
|
119
|
+
"@earendil-works/pi-tui": ">=0.85.0 <1",
|
|
120
|
+
"@earendil-works/pi-server": ">=0.85.0 <1",
|
|
120
121
|
"@kreuzberg/html-to-markdown-node": "^3.7.2",
|
|
121
|
-
"@lancedb/lancedb": "0.
|
|
122
|
+
"@lancedb/lancedb": "0.38.0",
|
|
122
123
|
"apache-arrow": "21.1.0",
|
|
123
124
|
"bgutils-js": "^4.0.3",
|
|
124
125
|
"camoufox-js": "^0.12.0",
|
|
125
|
-
"esbuild": "^0.28.
|
|
126
|
+
"esbuild": "^0.28.2",
|
|
126
127
|
"impit": "0.14.4",
|
|
127
|
-
"jsdom": "^
|
|
128
|
+
"jsdom": "^30.0.1",
|
|
128
129
|
"node-html-markdown": "^2.0.0",
|
|
129
|
-
"pdf-oxide-wasm": "^0.3.
|
|
130
|
+
"pdf-oxide-wasm": "^0.3.77",
|
|
130
131
|
"playwright-core": "1.60.0",
|
|
131
132
|
"poolifier": "^5.3.2",
|
|
132
|
-
"typebox": "1.3.
|
|
133
|
-
"undici": "^8.
|
|
133
|
+
"typebox": "1.3.26",
|
|
134
|
+
"undici": "^8.10.2",
|
|
134
135
|
"youtubei.js": "^18.0.0"
|
|
135
136
|
},
|
|
136
137
|
"optionalDependencies": {
|
|
137
138
|
"@huggingface/transformers": "4.2.0",
|
|
138
|
-
"webgpu": "^0.
|
|
139
|
+
"webgpu": "^0.6.0"
|
|
139
140
|
},
|
|
140
141
|
"overrides": {
|
|
141
142
|
"lodash.isequal": "npm:fast-deep-equal@^3.1.3",
|
|
@@ -148,21 +149,22 @@
|
|
|
148
149
|
"vite": "^8.0.16",
|
|
149
150
|
"adm-zip": "^0.6.0",
|
|
150
151
|
"jsdom": {
|
|
151
|
-
"undici": "^
|
|
152
|
+
"undici": "^8.10.2"
|
|
152
153
|
},
|
|
153
154
|
"sharp": "^0.35.0"
|
|
154
155
|
},
|
|
155
156
|
"devDependencies": {
|
|
156
157
|
"@eslint/js": "^10.0.1",
|
|
157
158
|
"@types/jsdom": "^30.0.0",
|
|
158
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
159
|
-
"@typescript-eslint/parser": "^8.
|
|
160
|
-
"@vitest/coverage-v8": "^
|
|
161
|
-
"dependency-cruiser": "^
|
|
159
|
+
"@typescript-eslint/eslint-plugin": "^8.69.0",
|
|
160
|
+
"@typescript-eslint/parser": "^8.69.0",
|
|
161
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
162
|
+
"dependency-cruiser": "^18.2.0",
|
|
162
163
|
"depngn": "^0.6.0",
|
|
163
|
-
"eslint": "^10.
|
|
164
|
-
"npm-check-updates": "^
|
|
164
|
+
"eslint": "^10.10.0",
|
|
165
|
+
"npm-check-updates": "^23.1.0",
|
|
165
166
|
"typescript": ">=6.0.3 <6.1.0",
|
|
166
|
-
"
|
|
167
|
+
"vite": "^8.0.16",
|
|
168
|
+
"vitest": "^5.0.0"
|
|
167
169
|
}
|
|
168
170
|
}
|
package/src/config.ts
CHANGED
|
@@ -75,6 +75,8 @@ export const ConfigSchema = Type.Object({
|
|
|
75
75
|
MAX_CONCURRENT_SCRAPES: Type.Number({ minimum: 1, maximum: 20, default: 3 }),
|
|
76
76
|
/** Maximum URLs fetched per scrape batch; extras are listed under "Not Fetched — Over Batch Cap" (default: 8, range: 1-20) */
|
|
77
77
|
MAX_SCRAPE_URLS: Type.Number({ minimum: 1, maximum: 20, default: 8 }),
|
|
78
|
+
/** Also fetch DuckDuckGo results page 2 for every search query (default: false). Configured via env/config file; not surfaced in the config TUI. */
|
|
79
|
+
SCRAPE_SECOND_PAGE: Type.Boolean({ default: false }),
|
|
78
80
|
/** Max YouTube videos transcribed per youtube_transcript call (default: 3, range 1-5) */
|
|
79
81
|
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: Type.Number({ minimum: 1, maximum: 5, default: 3 }),
|
|
80
82
|
/** Per-video timeout for the youtube_transcript tool in ms (default: 20000, range 5-120s) */
|
|
@@ -141,11 +143,12 @@ export const ConfigSchema = Type.Object({
|
|
|
141
143
|
* the model's real maxTokens at call time.
|
|
142
144
|
* Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
|
|
143
145
|
SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
|
|
144
|
-
/** LLM Model override for researcher sub-agents
|
|
146
|
+
/** LLM Model override for the evaluator/synthesis rounds and researcher sub-agents.
|
|
145
147
|
* Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
|
|
146
|
-
* When set, this
|
|
147
|
-
* and the knowledge synthesis background LLM.
|
|
148
|
-
*
|
|
148
|
+
* When set, this governs the evaluator/synthesis rounds and the researcher
|
|
149
|
+
* sub-agents (both deep and quick) and the knowledge synthesis background LLM.
|
|
150
|
+
* The initial coordinator (round 1 planning) uses the parent chat's model (ctx.model)
|
|
151
|
+
* for prompt-cache continuity with the conversation that triggered research.
|
|
149
152
|
*/
|
|
150
153
|
RESEARCH_MODEL: Type.Optional(Type.String()),
|
|
151
154
|
/** Explicit directory for the knowledge store database (overrides default) */
|
|
@@ -246,6 +249,7 @@ const USER_MIGRATION_KEYS = [
|
|
|
246
249
|
'PI_RESEARCH_AVG_TOKENS_PER_SCRAPE',
|
|
247
250
|
'PI_RESEARCH_MAX_CONCURRENT_SCRAPES',
|
|
248
251
|
'PI_RESEARCH_MAX_SCRAPE_URLS',
|
|
252
|
+
'PI_RESEARCH_SCRAPE_SECOND_PAGE',
|
|
249
253
|
'PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS',
|
|
250
254
|
'PI_RESEARCH_SEARCH_TIMEOUT_MS',
|
|
251
255
|
'PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS',
|
|
@@ -930,6 +934,7 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
|
|
|
930
934
|
PI_RESEARCH_AVG_TOKENS_PER_SCRAPE: String(config.AVG_TOKENS_PER_SCRAPE),
|
|
931
935
|
PI_RESEARCH_MAX_CONCURRENT_SCRAPES: String(config.MAX_CONCURRENT_SCRAPES),
|
|
932
936
|
PI_RESEARCH_MAX_SCRAPE_URLS: String(config.MAX_SCRAPE_URLS),
|
|
937
|
+
PI_RESEARCH_SCRAPE_SECOND_PAGE: String(config.SCRAPE_SECOND_PAGE),
|
|
933
938
|
PI_RESEARCH_YOUTUBE_TRANSCRIPT_MAX_VIDEOS: String(config.YOUTUBE_TRANSCRIPT_MAX_VIDEOS),
|
|
934
939
|
PI_RESEARCH_YOUTUBE_TRANSCRIPT_TIMEOUT_MS: String(config.YOUTUBE_TRANSCRIPT_TIMEOUT_MS),
|
|
935
940
|
PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG: config.YOUTUBE_TRANSCRIPT_LANG,
|
|
@@ -1158,6 +1163,7 @@ export function createConfig(env: Record<string, string | undefined>, processEnv
|
|
|
1158
1163
|
AVG_TOKENS_PER_SCRAPE: parseEnvNumber(e, 'PI_RESEARCH_AVG_TOKENS_PER_SCRAPE', DEFAULTS.AVG_TOKENS_PER_SCRAPE, 500, 10000),
|
|
1159
1164
|
MAX_CONCURRENT_SCRAPES: parseEnvNumber(e, 'PI_RESEARCH_MAX_CONCURRENT_SCRAPES', DEFAULTS.MAX_CONCURRENT_SCRAPES, 1, 20, true),
|
|
1160
1165
|
MAX_SCRAPE_URLS: parseEnvNumber(e, 'PI_RESEARCH_MAX_SCRAPE_URLS', DEFAULTS.MAX_SCRAPE_URLS, 1, 20, true),
|
|
1166
|
+
SCRAPE_SECOND_PAGE: parseEnvBool(e, 'PI_RESEARCH_SCRAPE_SECOND_PAGE', DEFAULTS.SCRAPE_SECOND_PAGE),
|
|
1161
1167
|
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: parseEnvNumber(e, 'PI_RESEARCH_YOUTUBE_TRANSCRIPT_MAX_VIDEOS', DEFAULTS.YOUTUBE_TRANSCRIPT_MAX_VIDEOS, 1, 5, true),
|
|
1162
1168
|
YOUTUBE_TRANSCRIPT_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_YOUTUBE_TRANSCRIPT_TIMEOUT_MS', DEFAULTS.YOUTUBE_TRANSCRIPT_TIMEOUT_MS, 5000, 120000),
|
|
1163
1169
|
YOUTUBE_TRANSCRIPT_LANG: parseEnvString(e, 'PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG', DEFAULTS.YOUTUBE_TRANSCRIPT_LANG)!,
|