@lincoln504/pi-research 1.6.9 → 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 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>
@@ -53,6 +56,10 @@ tries to compile from source; upgrading npm fixes it.
53
56
 
54
57
  In pi it works out of the box on the session's model and pi's configuration. Standalone use ([agent skill](docs/AGENT-SKILL.md) or [SDK](docs/SDK.md)) needs a model configured. See [Configuration](docs/CONFIGURATION.md).
55
58
 
59
+ ### Uninstall
60
+
61
+ `pi remove npm:@lincoln504/pi-research` removes the extension, and `npm uninstall -g @lincoln504/pi-research` the standalone engine. npm 7+ no longer runs `preuninstall`, so **nothing else is removed on its own**: skill links into other agents (Claude Code, Codex, …), the state directory (`~/.pi/research/state`), and the cache (`~/.cache/pi-research`, including any downloaded embedding models) stay in place. Remove the skill links first with `pi-research skill uninstall` (or `/research-config` → Remove from External Agents); the shared stealth-browser cache (`~/.cache/camoufox`) is preserved unless `PI_RESEARCH_PURGE_BROWSERS=1`. See [AGENT-SKILL.md](docs/AGENT-SKILL.md#installation-flow) for the full picture.
62
+
56
63
  ### How it works
57
64
 
58
65
  A research run loops through agent teams: a coordinator plans and starts with a search, researcher agents scrape and read in parallel, and a research lead decides whether to go another round, then writes the report from every report collected. The result is one cited Markdown report with findings optionally saved to the knowledge store.
@@ -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.9", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
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 and knowledge synthesis.
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 overrides ctx.model for researcher sub-agents (both deep and quick)
3177
- * and the knowledge synthesis background LLM. The coordinator and the research lead
3178
- * always use the caller's model (ctx.model).
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
- const result = extractJson(text, "object");
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, result.value);
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 coordinatorModel = ctx.model ?? model;
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: coordinatorModel,
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: coordinatorModel,
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: coordinatorModel,
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);
@@ -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
- route.fulfill({
2147
- status: 200,
2148
- contentType: "text/html",
2149
- body: `
2150
- <html><head><title>DuckDuckGo Lite</title></head><body>
2151
- <form action="/lite/" method="post">
2152
- <input name="q" type="text">
2153
- <input type="submit" value="Search">
2154
- </form>
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 snippet = row?.nextElementSibling?.querySelector("td.result-snippet")?.textContent?.trim() || "";
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
- await page.goto("https://lite.duckduckgo.com/lite/", { waitUntil: "domcontentloaded" });
2234
- await page.fill('input[name="q"]', query);
2235
- await Promise.all([
2236
- page.waitForNavigation({ waitUntil: "domcontentloaded" }),
2237
- page.keyboard.press("Enter")
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 and knowledge synthesis.
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 overrides ctx.model for researcher sub-agents (both deep and quick)
2741
- * and the knowledge synthesis background LLM. The coordinator and the research lead
2742
- * always use the caller's model (ctx.model).
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,58 @@ 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
+
41
+ ## [1.6.10] - 2026-09-02
42
+
43
+ ### Changed
44
+
45
+ - **`/research-config` now behaves properly on every pi host — TUI, RPC/web-hub GUIs, print/json, and the SDK (`guis` is a family of frontends, not one client).** The interactive settings menu is now gated on `ctx.mode === 'tui'` (not just `ctx.hasUI`, which is true in RPC too): on non-TUI hosts the command replies with a clear non-interactive guidance message instead of silently entering a menu that cannot render. Those hosts get working headless diagnostics instead — `/research-config health` and `/research-config knowledge-status` now run on any host (previously they were reachable only through the menu, even though the tool failure text points users at health). Every destructive `ctx.ui.confirm` now carries a 60s timeout, so no host can wedge on a dialog whose client never responds.
46
+
47
+ - **The knowledge store's Arrow/LanceDB boundary is now verified in the actual consumer layout on every CI run.** The dev tree resolves a single apache-arrow 21.1.0 (forced by `overrides`), but a fresh npm consumer install resolves two copies — 21.1.0 nested under the package for the direct pin and 18.1.0 hoisted for `@lancedb/lancedb`'s declared peer ceiling. docs/ARCHITECTURE.md pins 21.1.0 for a measured reason and warns mismatched Arrow copies may not interoperate, and this divergence is invisible to the dev-tree tests. The fresh-install CI job now runs `scripts/consumer-interop-probe.cjs`, which replicates `createStoreTable` (same fields, FTS/BTree index set, reopen-from-disk) with the consumer's exact resolution. README gains an Uninstall section documenting that npm 7+ runs no `preuninstall`, so skill links / `~/.pi/research/state` / caches persist unless removed first.
48
+
49
+ ### Fixed
50
+
51
+ - **Ctrl+C during a research run no longer cancels the run while the editor still holds typed text — it clears the editor first, and only a subsequent Ctrl+C (editor now empty) cancels the research.** The global TUI controller (`src/tui/tui-controller.ts`) treated Ctrl+C and Esc identically as cancel keys, aborting the session's active research runs on every press of either. That fought pi's own keybinding contract: Ctrl+C is `app.clear` ("Clear editor" — pi's `handleCtrlC()` clears the editor text and never interrupts the agent, doubling as the two-press-within-500ms exit), while Esc is `app.interrupt` ("Cancel or abort"), so a user discarding a drafted follow-up mid-run got the whole run killed instead of an empty editor. The controller now reads the editor via `ctx.ui.getEditorText()`: with text present, Ctrl+C falls through untouched (pi clears the text; the run keeps going); with an empty editor it is an explicit cancel gesture and aborts, still scoped to the current pi session as before; Esc keeps its unconditional panic-cancel role; and a throwing `getEditorText()` (ctx gone stale mid-run) is treated as empty — cancel — the safe default. The two gestures coexist with pi's native double-press exit in practice: the clear-then-decide rhythm is naturally slower than the 500ms window, so the human cancel path never trips pi's shutdown. Seven new tests pin the policy (`test/unit/tui/tui-controller.test.ts`).
52
+
53
+ - **`/research-config` could silently do nothing on RPC hosts.** `ctx.hasUI` is true in RPC, so the old guard passed and `ctx.ui.custom(...)` was entered even though no non-TUI host can render or answer the menu. The mode gate in the change above makes the menu reachable only on a real TUI and hands every other host the guidance message plus the headless `health` / `knowledge-status` diagnostics.
54
+
55
+ ### Verified
56
+
57
+ - 3,027 unit tests over 240 files (14 new across this Unreleased cycle: 7 Ctrl+C-policy + 7 host-mode), ESLint clean on the touched source, all four type-checks (TS 6 src + tests, TS 7 native src + tests), and dependency-cruiser clean (238 modules / 1,218 dependencies).
58
+
59
+
8
60
  ## [1.6.9] - 2026-09-01
9
61
 
10
62
  ### 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. A setting is written to one of two scopes:
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, while the coordinator and research lead keep using the session model. Accepts `provider/id` or a bare model id. |
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. |
@@ -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
  ![Running a live investigation with the /research slash command](https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/01-slash-research.gif)
@@ -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.9",
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.84.0 <1",
118
- "@earendil-works/pi-coding-agent": ">=0.84.0 <1",
119
- "@earendil-works/pi-tui": ">=0.84.0 <1",
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.37.1",
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.1",
126
+ "esbuild": "^0.28.2",
126
127
  "impit": "0.14.4",
127
- "jsdom": "^29.1.1",
128
+ "jsdom": "^30.0.1",
128
129
  "node-html-markdown": "^2.0.0",
129
- "pdf-oxide-wasm": "^0.3.75",
130
+ "pdf-oxide-wasm": "^0.3.77",
130
131
  "playwright-core": "1.60.0",
131
132
  "poolifier": "^5.3.2",
132
- "typebox": "1.3.7",
133
- "undici": "^8.9.0",
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.4.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": "^7.29.0"
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.65.0",
159
- "@typescript-eslint/parser": "^8.65.0",
160
- "@vitest/coverage-v8": "^4.1.10",
161
- "dependency-cruiser": "^17.4.3",
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.8.0",
164
- "npm-check-updates": "^22.2.9",
164
+ "eslint": "^10.10.0",
165
+ "npm-check-updates": "^23.1.0",
165
166
  "typescript": ">=6.0.3 <6.1.0",
166
- "vitest": "^4.1.10"
167
+ "vite": "^8.0.16",
168
+ "vitest": "^5.0.0"
167
169
  }
168
170
  }