@lincoln504/pi-research 1.3.10 → 1.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +264 -61
  3. package/dist/prompts/system-coordinator.md +1 -1
  4. package/dist/prompts/system-lead-evaluator.md +1 -1
  5. package/dist/thread-worker.mjs +31 -21
  6. package/docs/ARCHITECTURE.md +60 -22
  7. package/docs/CHANGELOG.md +103 -1
  8. package/docs/CONFIGURATION.md +6 -5
  9. package/package.json +18 -10
  10. package/scripts/cleanup.cjs +26 -5
  11. package/scripts/setup.cjs +25 -1
  12. package/skills/pi-research/SKILL.md +1 -1
  13. package/src/cli.ts +94 -13
  14. package/src/config.ts +6 -15
  15. package/src/constants.ts +13 -0
  16. package/src/core/service-registry.ts +19 -3
  17. package/src/infrastructure/browser/config.ts +36 -5
  18. package/src/infrastructure/browser/task-execution-service.ts +12 -2
  19. package/src/infrastructure/browser/thread-worker-browser.ts +34 -18
  20. package/src/infrastructure/browser/thread-worker.mjs +31 -21
  21. package/src/infrastructure/gpu-resource-service.ts +15 -1
  22. package/src/knowledge/embedder-init.ts +34 -2
  23. package/src/knowledge/store.ts +75 -6
  24. package/src/knowledge/writer-queue.ts +28 -0
  25. package/src/orchestration/research-orchestration-service.ts +35 -1
  26. package/src/orchestration/research-synthesis-service.ts +4 -2
  27. package/src/orchestration/researcher.ts +2 -13
  28. package/src/prompts/system-coordinator.md +1 -1
  29. package/src/prompts/system-lead-evaluator.md +1 -1
  30. package/src/sdk.ts +10 -1
  31. package/src/security/nvd-client.ts +55 -4
  32. package/src/security/osv-client.ts +13 -1
  33. package/src/tools/research-knowledge-search.ts +18 -1
  34. package/src/tools/research-tool-definition.ts +8 -1
  35. package/src/utils/citation-utils.ts +4 -2
  36. package/src/utils/log-utils.ts +16 -1
  37. package/src/utils/text-utils.ts +17 -3
  38. package/src/web-research/scraper-utils.ts +55 -14
@@ -36,7 +36,7 @@ Before splitting the query into researchers, pin down its scope — exactly what
36
36
 
37
37
  1. **Plan ONLY Round 1**: Split the query into distinct sub-topics, all inside the scope above, each assigned to a researcher. **Do NOT plan multiple rounds in advance** — additional rounds are only delegated reactively when Round 1 findings reveal gaps.
38
38
  2. **Assign Goals**: Each researcher gets a focused goal covering a specific angle or time period.
39
- 3. **Temporal Awareness**: Lead with the current month and year shown above. Generate time-sensitive queries using that exact month and year (e.g., append the current year and, where relevant, the current month; "latest", "current status"). Never anchor queries to an older year (e.g. 2024 or 2025) unless the user explicitly asks about the past. Research MUST be grounded in the present month and year.
39
+ 3. **Temporal Awareness**: Lead with the current month and year shown above. Generate time-sensitive queries using that exact month and year (e.g., append the current year and, where relevant, the current month; "latest", "current status"). Never anchor queries to an older year than the one shown above unless the user explicitly asks about the past. Research MUST be grounded in the present month and year.
40
40
  4. **Query Planning**: For EACH researcher, generate targeted, specific queries within the budget. Fill the budget when the topic is broad enough to warrant it, but don't pad queries for a narrow topic. Each round is planned independently — make Round 1 count.
41
41
  5. **YouTube Discovery**: For roughly **one in {{youtube_query_every_n}}** of each researcher's queries, append the word `youtube` to the query text (e.g. `"<topic> explained youtube"`). DuckDuckGo rarely surfaces YouTube on its own, and YouTube links let researchers read video transcripts. Skip this only when the topic is clearly unsuited to video (e.g. a single numeric lookup).
42
42
  6. **Seed Search Burst**: All queries across all researchers are combined into a single pre-search pass that seeds the global link pool.
@@ -74,7 +74,7 @@ Use unique, targeted queries for any new researchers.
74
74
  - **CRITICAL — Queries are mandatory**: Every researcher MUST have at least one query. Never plan a researcher without queries. Researchers receive ONLY the search results you delegate to them.
75
75
  - **Maximize queries**: For EACH researcher, generate the maximum number of targeted, specific queries within the budget. Do not plan fewer than needed — fill the budget. Queries should target primary sources and authoritative evidence.
76
76
  - **YouTube discovery**: For roughly one in {{youtube_query_every_n}} queries, append the word `youtube` to the query text (e.g. `"<topic> analysis youtube"`). DuckDuckGo rarely surfaces YouTube on its own, and YouTube links let researchers read video transcripts. Skip only when the gap is clearly unsuited to video.
77
- - **Temporal anchoring**: Anchor every new time-sensitive query to the current month and year shown at the top of this prompt. Use that exact year (and month where relevant); never default to an older year such as 2024 or 2025 unless the user explicitly asks about the past.
77
+ - **Temporal anchoring**: Anchor every new time-sensitive query to the current month and year shown at the top of this prompt. Use that exact year (and month where relevant); never default to an older year than the one shown there unless the user explicitly asks about the past.
78
78
  - **Flexible coverage**: Use up to {{max_team_size}} researchers to cover distinct angles in parallel. Scale based on research gaps — a single well-targeted researcher is often sufficient for focused gaps.
79
79
  - **Source diversity**: Encourage researchers to find multiple authoritative sources per topic area to enable comprehensive citations in the final synthesis.
80
80
 
@@ -99,7 +99,10 @@ var init_log_utils = __esm({
99
99
  `${B_LEFT}(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})${B_RIGHT}`,
100
100
  "g"
101
101
  );
102
- JWT_PATTERN = new RegExp(`${B_LEFT}eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+`, "g");
102
+ JWT_PATTERN = new RegExp(
103
+ `${B_LEFT}eyJ[A-Za-z0-9_-]{1,1024}\\.[A-Za-z0-9_-]{1,8192}\\.[A-Za-z0-9_-]{1,4096}`,
104
+ "g"
105
+ );
103
106
  BASIC_AUTH_PATTERN = new RegExp(`${B_LEFT}Basic\\s+[A-Za-z0-9+/]{16,}={0,2}`, "gi");
104
107
  BEARER_AUTH_PATTERN = new RegExp(`${B_LEFT}Bearer\\s+[A-Za-z0-9._~+/-]+=*`, "gi");
105
108
  PROVIDER_TOKEN_PATTERN = new RegExp(
@@ -1509,6 +1512,7 @@ var scraper_utils_exports = {};
1509
1512
  __export(scraper_utils_exports, {
1510
1513
  createJsMarkdownConverter: () => createJsMarkdownConverter,
1511
1514
  createNativeMarkdownConverter: () => createNativeMarkdownConverter,
1515
+ disposeSsrfSafeFetcher: () => disposeSsrfSafeFetcher,
1512
1516
  extractDomain: () => extractDomain,
1513
1517
  formatErrorWithCause: () => formatErrorWithCause,
1514
1518
  getRandomUserAgent: () => getRandomUserAgent,
@@ -1644,19 +1648,33 @@ function isSsrfBlockError(error) {
1644
1648
  }
1645
1649
  return false;
1646
1650
  }
1647
- async function getSsrfSafeFetcher() {
1648
- if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
1651
+ function getSsrfSafeFetcher() {
1652
+ cachedSsrfFetcher ??= (async () => {
1653
+ try {
1654
+ const { Agent, fetch: undiciFetch } = await import("undici");
1655
+ return {
1656
+ fetch: undiciFetch,
1657
+ dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
1658
+ };
1659
+ } catch (e) {
1660
+ logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
1661
+ return null;
1662
+ }
1663
+ })();
1664
+ return cachedSsrfFetcher;
1665
+ }
1666
+ async function disposeSsrfSafeFetcher() {
1667
+ const pending = cachedSsrfFetcher;
1668
+ cachedSsrfFetcher = void 0;
1669
+ if (pending === void 0) return;
1670
+ const cached = await pending.catch(() => null);
1671
+ const closable = cached?.dispatcher;
1672
+ if (!closable?.close) return;
1649
1673
  try {
1650
- const { Agent, fetch: undiciFetch } = await import("undici");
1651
- cachedSsrfFetcher = {
1652
- fetch: undiciFetch,
1653
- dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
1654
- };
1674
+ await closable.close();
1655
1675
  } catch (e) {
1656
- logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
1657
- cachedSsrfFetcher = null;
1676
+ logger.debug(`[Scrapers] Error closing SSRF dispatcher: ${e instanceof Error ? e.message : String(e)}`);
1658
1677
  }
1659
- return cachedSsrfFetcher;
1660
1678
  }
1661
1679
  function formatErrorWithCause(err) {
1662
1680
  try {
@@ -2544,15 +2562,6 @@ var ConfigSchema = Type.Object({
2544
2562
  * the model's real maxTokens at call time.
2545
2563
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
2546
2564
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
2547
- /** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
2548
- * Without a cap the session inherits the model's full catalog ceiling as the
2549
- * per-request max_tokens — ~230k for large-context models — which providers that
2550
- * budget-check max_tokens up front (OpenRouter 402s against remaining credits)
2551
- * reject outright on low balances, even though the actual response would cost
2552
- * cents. Default 16384: researcher turns are tool calls plus a cited report, far
2553
- * below this. Clamped to the model's real ceiling at session build time.
2554
- * Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
2555
- RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
2556
2565
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
2557
2566
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
2558
2567
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
@@ -2589,7 +2598,8 @@ var ConfigSchema = Type.Object({
2589
2598
  /**
2590
2599
  * Comma-separated list of research tools to DISABLE for every researcher in a run
2591
2600
  * (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
2592
- * search, scrape, security_search, stackexchange, youtube_transcript, grep.
2601
+ * search, scrape, security_search, stackexchange, youtube_transcript, grep, read
2602
+ * (RESEARCH_TOOL_NAMES in constants.ts is the canonical list).
2593
2603
  * Disable-only: the list is merged into the per-run excludeTools stream, so disabled
2594
2604
  * tools are both removed from each researcher's toolset AND named in the
2595
2605
  * coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
@@ -12,7 +12,7 @@ pi CLI
12
12
  └── pi-research extension (src/index.ts)
13
13
  ├── Registered Tools research, health, research_knowledge_search (always registered; reports why when the store is disabled)
14
14
  ├── Commands /research, /research-config, /knowledge-store
15
- ├── Events input (mid-run steering), session_shutdown (cleanup)
15
+ ├── Events input (mid-run steering), session_shutdown (cleanup), session_before_compact / session_compact, before_agent_start, after_provider_response
16
16
  └── Layers
17
17
  ├── Orchestration quick/deep research coordination
18
18
  ├── Agent Tools search, scrape, youtube_transcript, security_search, stackexchange, grep, read
@@ -106,10 +106,10 @@ agent to invoke:
106
106
  |------|-------|------|---------|
107
107
  | `search` | ✓ | — | DuckDuckGo Lite via the stealth browser |
108
108
  | `scrape` | ✓ | ✓ | Batch page fetch → Markdown via the stealth browser (up to 6 URLs per call) |
109
- | `youtube_transcript` | ✓ | ✓ | YouTube captions via youtubei.js + BotGuard PoToken (≤3 videos, one call per researcher) |
109
+ | `youtube_transcript` | ✓ | ✓ | YouTube captions via youtubei.js + BotGuard PoToken (≤3 videos by default, configurable 1–5; one call per researcher) |
110
110
  | `security_search` | ✓ | ✓ | NVD, CISA KEV, GitHub Advisories, OSV |
111
111
  | `stackexchange` | ✓ | ✓ | Stack Exchange network |
112
- | `grep` | — | — | Local ripgrep (from pi-coding-agent) — off by default, see below |
112
+ | `grep` | — | — | Local ripgrep (from pi-coding-agent) — always excluded, see below |
113
113
  | `read` | ✓ | ✓ | Local file reads (from pi-coding-agent) |
114
114
 
115
115
  In deep research `search` is excluded — the coordinator runs the search burst and hands
@@ -290,34 +290,72 @@ src/
290
290
  ### Key design decisions
291
291
 
292
292
  Read-only researchers — researcher agents are limited to the tool set above. They cannot
293
- write files, spawn processes, or make arbitrary network calls. They *can* read and grep
294
- files: `read` and `grep` are registered unconditionally and the researcher exclusion list
295
- (`bash`, `write`, `edit`, `repl`, `git`, `terminal`) does not cover them. The `cwd` passed
296
- to those tools is a resolution base, not a jail — an absolute path resolves to itself — so
297
- the boundary is "no mutation", not "only this directory".
293
+ write files, spawn processes, or make arbitrary network calls. They *can* read files:
294
+ `read` is registered and the researcher exclusion list (`bash`, `write`, `edit`, `repl`,
295
+ `git`, `terminal`) does not cover it. Local `grep` is registered but always excluded (see
296
+ the tool table above). The `cwd` passed to `read` is a resolution base, not a jail — an
297
+ absolute path resolves to itself — so the boundary is "no mutation", not "only this
298
+ directory".
298
299
 
299
300
  Worker pool over direct browser — browser processes are isolated in workers so a crash
300
301
  in one cannot affect the orchestrator or other sessions.
301
302
 
302
303
  Pinned browser stack — `playwright-core` and `impit` are pinned to exact versions and
303
- `camoufox-js` to its `0.10.x` line (`^0.10.2`, which excludes the `0.11.x` alpha); the
304
- three are coupled and upgraded together, because each floating range broke fresh consumer
305
- installs that our lockfile masked. camoufox-js `0.10.2` fetches Firefox
306
- 135/beta.24, the newest camoufox with binaries for every supported OS (later builds
307
- dropped Windows). playwright-core `1.60.0` is the newest the FF135 Juggler protocol
308
- accepts (1.61 rejects it and fails every launch). impit `0.13.0` avoids the
309
- `only-allow pnpm` preinstall guard that impit 0.13.1/0.14.0 shipped, which fails
310
- `npm install -g`; a direct-dependency pin is required because npm `overrides` do not
311
- propagate to consumers. Will upgrade all three together when camoufox ships stable
312
- cross-platform binaries. Rationale in full:
313
- `src/infrastructure/browser/thread-worker-browser.ts`.
304
+ `camoufox-js` to its `0.10.x` line; the three are coupled and upgraded together, because
305
+ each floating range broke fresh consumer installs that our lockfile masked.
306
+ playwright-core stays at `1.60.0` (1.61+ rejects camoufox's Juggler and fails every
307
+ launch — corroborated upstream, since camoufox-js `0.12.0` declares
308
+ `peerDependencies: { "playwright-core": "<1.61.0" }`, the same bound this pin holds by
309
+ hand). `impit` stays at `0.13.0`: camoufox-js 0.10.2 requires `^0.13.0`, and within that
310
+ range only 0.13.0 lacks the `only-allow pnpm` preinstall guard that breaks
311
+ `npm install -g` (an upstream mistake in 0.13.1/0.14.0, dropped again in 0.14.1). An
312
+ exact pin is required because npm `overrides` do not propagate to consumers. Rationale in
313
+ full: `src/infrastructure/browser/thread-worker-browser.ts`.
314
+
315
+ The two original blockers have both lapsed — camoufox restored Windows binaries in
316
+ `v152.0.4-beta.26` (2026-07-16) and impit's guard is long gone — but camoufox-js `0.12.0`
317
+ is held back by a third, worse one: it requires `better-sqlite3 ^13.0.1`, and
318
+ better-sqlite3 13 **dropped prebuilt binaries**. 12.x installs via
319
+ `prebuild-install || node-gyp rebuild`; 13.x ships no install script at all, so npm
320
+ auto-runs `node-gyp rebuild` against its `binding.gyp` and every install demands a full
321
+ C++ toolchain. This was measured, not inferred: the upgrade was made, and `npm ci` failed
322
+ outright on the Windows CI runner (`could not find a version of Visual Studio 2017 or
323
+ newer`) — i.e. it would break installation for most Windows users, which is exactly what
324
+ this pin exists to prevent. Check better-sqlite3 before camoufox next time.
325
+
326
+ The browser BINARY, by contrast, is not pinned and cannot be. `camoufox-js fetch` takes
327
+ no version argument: it walks the `daijro/camoufox` GitHub releases newest-first and takes
328
+ the first non-prerelease release carrying an asset for this OS/arch. So the binary a
329
+ consumer gets is whatever camoufox published most recently at install time, regardless of
330
+ which camoufox-js version is installed — the npm pins do not freeze it, and a future
331
+ camoufox release could break launches for fresh installs with no change on our side.
332
+ Windows assets were in fact missing from `v146-hardware` through `v152.0.2-alpha` and
333
+ returned in `v152.0.4-beta.26` (2026-07-16). Current newest is `v152.0.4-beta.28`
334
+ (Firefox 152); it launches and drives cleanly under playwright-core `1.60.0` **with
335
+ camoufox-js 0.10.2** (whose `MIN_VERSION` is `beta.19`, so it accepts it), verified
336
+ directly, as does the older `v135.0.1-beta.24` an existing cache may still hold. The
337
+ practical consequence is that browser freshness is independent of the npm pin: a stale
338
+ `camoufox-js` does not mean a stale Firefox. Re-verify a real launch when bumping this
339
+ stack — the unit and integration suites mock the browser and cannot catch a Juggler
340
+ mismatch.
314
341
 
315
342
  Pinned data stack — `apache-arrow` is a direct dependency at `21.1.0`, and `overrides`
316
343
  forces the whole tree to that single version so LanceDB and Arrow share one Arrow instance
317
344
  (mismatched Arrow copies do not interoperate — arrays built by one are rejected by the
318
- other). This sits above `@lancedb/lancedb` 0.29's declared Arrow peer ceiling
319
- (`>=15.0.0 <=18.1.0`); it is verified working, but the override should be re-validated
320
- whenever `@lancedb/lancedb` is upgraded.
345
+ other). This sits above `@lancedb/lancedb` 0.37's declared Arrow peer ceiling
346
+ (`>=15.0.0 <=18.1.0`) — npm will not even resolve the pairing without the override — and
347
+ it is verified working, but it should be re-validated whenever `@lancedb/lancedb` is
348
+ upgraded.
349
+
350
+ `21.1.0` is exact for a measured reason, not caution. Bumping the PATCH-looking minor to
351
+ `21.2.0` breaks the store outright: LanceDB's Rust-side Arrow reader cannot parse the
352
+ schema that Arrow 21.2 writes, failing every table open with
353
+ `Failed to read IPC file: Arrow error: Parser error: Unable to get root as footer:
354
+ RangeOutOfBounds … UnionVariant { variant: "Type::FixedSizeList" }` — 56 unit and 36
355
+ integration tests, every one that touches a real table. Do not treat this range as
356
+ caret-safe. Note also that every `@lancedb/lancedb` release through 0.37 declares the same
357
+ `<=18.1.0` Arrow ceiling, so upgrading LanceDB does not resolve the override; it only
358
+ changes which pairing needs re-validating.
321
359
 
322
360
  Pinned validation library — `typebox` is pinned to the exact version the pi host packages
323
361
  depend on (`@earendil-works/pi-ai` / `@earendil-works/pi-coding-agent` 0.84.2 pin `1.3.7`). Every
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,108 @@ 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.3.12] - 2026-08-15
9
+
10
+ A six-way independent audit of the whole tree (core/orchestration/LLM,
11
+ infrastructure/security, knowledge + scrape pipeline, tools/TUI/CLI,
12
+ release/CI/docs, and test-suite integrity), run against the clean 1.3.11 tree,
13
+ followed by a second pass that exercised each external integration live rather
14
+ than reading it. Every new regression test is mutation-verified — re-run against
15
+ the reinstated pre-fix code and confirmed to fail.
16
+
17
+ ### Fixed
18
+
19
+ - **`security_search` returned the oldest CVEs on record and could not reach recent ones.** NVD 2.0 returns keyword matches oldest-first and offers no sort parameter (`sortBy` is a 404), so paging from index 0 and stopping at `maxResults` deterministically kept the least useful window. Measured against the live API: a search for `openssl` returned CVE-1999-0428 through 2004 out of 566 matches, and nothing published after 2004 was reachable at any page size — the researcher was being handed 27-year-old advisories as the current vulnerability picture. The client now reads `totalResults` from the first page and seeks once to the tail, then orders the window newest-first (undated entries last). Only fires when the match set is larger than what is returned, so a query whose results already fit still costs exactly one request. The same search now returns the 20 most recent, through 2026-08-13. OSV had a milder form of this — its per-package list is in ID order, so truncation dropped advisories on alphabetical accident — and is now sorted newest-first before the cut.
20
+ - **The NVD rate limiter could leave a search permanently unsettled.** The inter-request spacing timer (up to 6s without an API key) was `unref`'d, but that timer *is* the operation — nothing else settles the awaited promise. With no other ref'd handle, Node treated the process as idle mid-search and the promise never resolved: no result, no error, no timeout. Reproduced deterministically as soon as a second request was issued. It is now ref'd, and remains bounded and abortable so shutdown cannot be delayed beyond one spacing interval. Every other `unref` in the tree guards a timeout against real I/O that holds the loop open by itself; this was the only one with nothing behind it.
21
+ - **ReDoS in the JWT redaction pattern, the sibling the 1.3.9 fix did not reach.** Bounding the URL-credential scheme fixed one quadratic pattern and left an identical one next to it: JWT segments exclude `.`, so an unbounded `[A-Za-z0-9_-]+` consumes to the end of a dotless base64url run and then backtracks one character at a time — and because `_` counts as a non-alphanumeric left boundary, the same run offers a fresh start position every few characters. Measured 393ms of blocked event loop per log message across the 40 000-character scan window, on a function that runs for every log line over largely untrusted content. Bounding the segments brings the identical input to 41ms with no loss of coverage; the bounds sit far above any real token.
22
+ - **`PI_RESEARCH_DISABLED_TOOLS` and the SDK's `excludeTools` accepted unknown tool names silently.** `RESEARCH_TOOL_NAMES` documents itself as the vocabulary all three exclusion surfaces accept, but only `--exclude-tools` validated against it — the other two reached the same blind Set union, where a typo excludes nothing while the user believes the tool is off for a whole billed run. Both now warn at the shared chokepoint, naming the offending value and the valid set. A warning rather than an error there: that path serves a library API and an ambient env var, where aborting a legitimate request over one unrecognized name is the wrong trade. The CLI keeps its fail-fast.
23
+
24
+ - **`PI_RESEARCH_STATE_DIR` pointed one directory short could wipe `config.env` and the knowledge store.** `scripts/cleanup.cjs` decided whether an override was safe to remove wholesale by testing whether `<dirName>/research` appeared as any two adjacent path segments — a naming-convention match, not the ownership proof its own comment required. That also matched the research **root** (`~/.pi/research`, which holds `config.env` and `knowledge_db/`), unrelated directories beneath it (`/mnt/backup/.pi/research/mystate`), and traversal forms (`.../research/state/../../..`) that resolve outside our tree entirely — each taking a recursive `rmSync` with no prompt. Dropping the documented `/state` suffix is a one-directory-off mistake the runtime honors literally, so a deliberate `npm run cleanup` could silently destroy every saved setting and the entire knowledge store. The check now requires the full default shape — the resolved path's trailing segments being `<dirName>/research/state` — which keeps a relocated-but-default layout reclaimable while refusing the root, siblings, and traversal escapes; everything else falls through to the targeted, filename-scoped sweep.
25
+ - **The GPU lock could be stolen from a live process that was still using it.** The staleness backstop was a hardcoded 5 minutes, which exactly equalled the default `EMBEDDING_MODEL_INIT_TIMEOUT_MS` (300000, schema max 600000) — and the embedder holds that lock across the whole model download plus pipeline load. Dead owners are already reclaimed separately via PID+startTime liveness, so this path only ever fires against a process just proven alive: a first-time model fetch running inside its own permitted budget would have the lock taken out from under it, re-admitting exactly the concurrent WebGPU/Dawn initialization the lock exists to prevent. The threshold is now 15 minutes, strictly above the configurable maximum hold.
26
+ - **The CPU-fallback embedder load leaked a native ONNX session on timeout.** `loadPipelineWithTimeout` grew a late-arrival disposal guard in 1.3.11, but the sibling `loadModelOnCPU` — the path taken *after* a WebGPU failure, on a host already under memory pressure — kept the same uncancellable `withTimeout` race with no such guard, orphaning one native session per retry. It now disposes the late arrival identically.
27
+ - **Concurrent knowledge searches tore down each other's TUI indicator.** The widget ID is a single global slot and the tool is `executionMode: 'parallel'` (and separately drivable via `/knowledge-store`), so the first search to finish removed the panel while a sibling was still running. Show/hide is now reference-counted; only the last one out removes it.
28
+ - **`registerAndReplace()` had no disposal-in-flight guard.** Unlike `clear()`/`replace()`/`disposeAll()`, it read and disposed `registration.instance` without joining an in-flight `disposalPromise`, then swapped the whole registration object — so a concurrent teardown could dispose the same instance twice and have its replacement orphaned. Currently unreachable (no call sites), fixed as a latent landmine.
29
+ - **The SSRF-safe undici `Agent` was never released, and was not actually a singleton.** It is meant to be one process-lifetime instance holding keep-alive sockets to every host scraped; the CLI hard-exits so it never noticed, but an embedding host (the pi extension) survives SDK init/shutdown cycles. Two problems: nothing ever closed it, and the cache held the *resolved value*, assigned only after `await import('undici')` — so every concurrent first caller passed the `undefined` check, built its own `Agent`, and all but the last were handed live instances the cache never referenced. Those opened sockets that no shutdown path could ever close, which is the very leak being fixed; scrapes run concurrently by design, so the first burst hit it. The cache now holds the construction *promise*, so concurrent callers join one build, and `shutdownResearchSDK()` closes it and clears the cache — awaiting an in-flight construction rather than orphaning it — so a later re-initialization rebuilds instead of reusing a closed Agent.
30
+ - **A cancelled run reported itself as a component failure in `--json`.** A Ctrl-C unwinding through the planner surfaces internally as `Coordinator failed: Request was aborted`, and that raw wording was emitted as the payload's `error`. The plain-text branch has always refused to say "failed" for a deliberate stop — "Cancellation is a completed intention, not a failure" — but JSON said it anyway, and SKILL.md instructs agents to relay these messages, so a user who pressed Ctrl-C was told the coordinator broke. `error` now reads `pi-research research cancelled.` with the original wording preserved under `cause`; `cancelled`, `exitCode` and the absence of `retryable` are unchanged. Verified against a real interrupted run: exit 130, correct payload.
31
+ - **Two prompts hardcoded the stale-year examples that `injectCurrentDate` was deliberately generalized away from.** That helper derives its examples from the current year precisely so the guidance "stays correct in any year (a hardcoded '2024 or 2025' reads wrong from 2027 on)" — and the coordinator and evaluator templates then said exactly `2024 or 2025` in their own bodies, duplicating the injected guidance with values that expire. From January 2027 they would have told the model the stale years were 2024/2025 while the actually-stale ones were 2025/2026, implicitly legitimising the year to avoid. Both now refer to the injected anchor instead of restating years.
32
+ - **`PLAYWRIGHT_BROWSERS_PATH` moved where the browser was looked for but never where it was installed** — plumbing added, and the documentation corrected to stop overclaiming. It was documented as overriding "where the camoufox binary is stored and looked up" and only ever did the second half: camoufox-js below 0.12.0 hardcodes `userCacheDir("camoufox")` and honours no variable at all. Setting it downloads to the default cache, leaves the postinstall's "already installed" probe permanently unsatisfied (so every install re-fetches), and makes the runtime report the browser missing right after a successful download. `CAMOUFOX_INSTALL_DIR` — the variable camoufox-js 0.12.0 introduced — now takes precedence, `PLAYWRIGHT_BROWSERS_PATH` is an alias, and both the postinstall fetch and the browser workers are handed it. **This is inert on the pinned camoufox-js 0.10.x** (see the dependency note below for why the pin cannot advance), so the plumbing is correct-and-waiting rather than a live fix; `CONFIGURATION.md` now states plainly that a custom browser directory is unsupported until the pin moves.
33
+
34
+ ### Tests
35
+
36
+ - The 1.3.11 backup-pruner TTL retention fix — a silent permanent-data-loss fix — shipped with no regression test. Six now cover it (retention inside the TTL, pruning past it, never deleting the newest, ranking by the trailing backup timestamp rather than whole-name sort, migration-temp reaping, and never touching a temp-named live table); two fail against the pre-fix code.
37
+ - `task-execution-service`'s module-level liveness-probe cache (1s) and restart cooldown (10s) both outlive a test, and the reset seam that existed for exactly this had zero callers — leaving the suite order-dependent. The seam now resets both globals and is called in `beforeEach`.
38
+ - `cli-bridge-env.test.ts` wrote fixtures into the real home directory; a hard kill before its cleanup stranded them with nothing to sweep up. It now redirects `HOME`/`USERPROFILE` to a temp dir.
39
+ - Fake timers in `circuit-breaker.test.ts` and `retry-utils.test.ts` were never uninstalled (`restoreAllMocks` does not do it), leaving the clock faked at file end.
40
+ - **`verify-package` now checks that `dist/prompts` is byte-identical to `src/prompts`, not merely present.** The existing check counted files, so a stale copy passed every gate — and `dist/prompts` is what the bundled CLI and the agent-skill engine actually load. Editing a prompt without rebuilding leaves the shipped product running the old instructions with a fully green suite; that state existed in this working tree and the new check caught it. A truncated or partial copy fails here too.
41
+ - **The shipped prompt templates are now rendered through the real substitution path and asserted to leave no `{{placeholder}}` behind**, for the coordinator and for the evaluator at all three complexity levels (its template branches on complexity, so one level passing proves little). Previously one placeholder was covered, by name, against a stub template — so adding a placeholder to a `.md` without wiring it into the call map sent literal braces to the model as instructions it could not act on. The test reads the real files, so it cannot drift from what ships.
42
+ - New coverage for the NVD recency seek (tail offset requested, no extra request when results already fit, newest-first ordering) and for the rate-limiter timer staying ref'd — the last asserted directly, because that failure is invisible under a test runner, which always holds the event loop open by itself.
43
+ - **The JWT ReDoS regression test asserts a complexity class rather than a wall-clock budget.** Its first form used a 200ms threshold with roughly 2× headroom, which cannot work here: the 193-file parallel suite inflated a 41ms post-fix pass to 288ms, overlapping the ~393ms pre-fix cost on an idle machine, so any fixed number either flakes under load or stops catching the regression. It now doubles the input and requires the cost not to more than double — both measurements inflate together under load, so the ratio is stable. Measured 1.87× fixed against 4.01× unbounded on the raw pattern (2.06× through `redactSecrets`), threshold 3.0, mutation-verified at 4.80× against the pre-fix pattern.
44
+
45
+ ### Changed
46
+
47
+ - **npm keywords aligned with the repository's GitHub topics.** All eight topics (`agent-skills`, `ai-research`, `browser-automation`, `claude-code`, `llm-tools`, `web-access`, `web-scraping`, `web-search`) are now present; `claude-code` and `codex` were missing entirely despite both being first-class agent-skill install targets alongside the already-listed `openclaw`. Added rather than replaced: `pi-package` is load-bearing, not decorative — pi's own package documentation states the pi.dev gallery lists packages carrying that keyword, so swapping the list wholesale for the GitHub topics would have delisted this package from the pi ecosystem. pi has no separate tag field of its own (its manifest accepts only `extensions`/`skills`/`prompts`/`themes`/`video`/`image`), so npm `keywords` is the single tag surface for both registries.
48
+
49
+ ### Added
50
+
51
+ - **`status` now reports whether the stealth browser is installed**, with the exact command to fix it. This became urgent rather than cosmetic: npm 11.19 blocks package install scripts by default, so a fresh `npm install -g @lincoln504/pi-research` now skips this package's `postinstall` — and therefore the camoufox download — while reporting success. Verified by installing the packed tarball under npm 11.19: everything else survives (onnxruntime, sharp, esbuild and webgpu all ship prebuilt platform packages, and `dist/` is in the tarball), so the browser is the single casualty and nothing else signals it. Previously `status` answered `ready: true` with no browser present and the first symptom was a scrape degrading mid-run. A missing browser is reported alongside `ready` rather than folded into it, because the fetch path still serves most pages. The check is a resolve plus an `existsSync` — it never launches anything.
52
+
53
+ ### Dependencies
54
+
55
+ Every upgrade below was validated by actually running the subsystem it touches, not by a green suite alone — the browser is mocked throughout the test suite, so a Juggler mismatch would reach production with the whole unit suite passing.
56
+
57
+ - **Browser binary refreshed to Firefox 152 without moving the npm pin.** The camoufox *binary* is fetched dynamically and is not governed by the `camoufox-js` version, so `v152.0.4-beta.28` (Firefox 152) runs fine on the pinned camoufox-js 0.10.2, whose `MIN_VERSION` of `beta.19` accepts it. That closes a real staleness gap — the stealth browser had been presenting Firefox 135 from March 2025 while the fetch path advertised Firefox 153, itself a bot signal. Verified by launching and driving a real browser (both 152.0.4-beta.28 and the older 135.0.1-beta.24 launch, navigate and report a correct UA under playwright-core 1.60.0) and by all 13 live-network search/scrape connectivity tests.
58
+ - **`camoufox-js` stays on ^0.10.2 and `impit` on 0.13.0 — attempted, measured, reverted.** Both blockers ARCHITECTURE.md recorded had genuinely lapsed (camoufox restored Windows assets in `v152.0.4-beta.26`; impit's `only-allow pnpm` guard existed only in 0.13.1/0.14.0), so the upgrade was made — and CI caught a third, worse blocker on Windows. camoufox-js 0.12.0 requires `better-sqlite3 ^13.0.1`, and better-sqlite3 13 **dropped prebuilt binaries**: 12.x installs via `prebuild-install || node-gyp rebuild`, while 13.x ships no install script at all, so npm auto-runs `node-gyp rebuild` against its `binding.gyp` and every install demands a full C++ toolchain. `npm ci` failed outright on the Windows runner (`could not find a version of Visual Studio 2017 or newer`) — i.e. it would break installation for most Windows users, exactly what this pin exists to prevent. The blocker is now better understood than before: check better-sqlite3 first, not camoufox.
59
+ - **`@lancedb/lancedb` 0.29.0 → 0.37.1.** Validated beyond the suite: 0.37.1 opens a real 116 MB, 2249-row store written by 0.29 and serves a live knowledge search against it with real embeddings. Two measured invariants the store's own code depends on were re-checked and hold: the three commit-conflict strings `isLanceCommitConflict` matches are still present in the 0.37.1 native binary, and `createIndex` still advances `version()` by exactly one for a vector index, an FTS index and a `replace: true` rebuild alike.
60
+ - **`youtubei.js` ^17 → ^18** (live transcript fetch verified) and **`@types/jsdom` ^28 → ^30**.
61
+
62
+ Held deliberately, each for a checked reason rather than caution:
63
+
64
+ - **`apache-arrow` stays at `21.1.0`.** The minor bump to 21.2.0 breaks the knowledge store outright — LanceDB's Rust-side reader cannot parse the schema Arrow 21.2 writes, failing every real-table open with `Unable to get root as footer: RangeOutOfBounds … Type::FixedSizeList`, across 56 unit and 36 integration tests. Attempted, measured, reverted; the pin's rationale is now empirical rather than assumed.
65
+ - **`bgutils-js` stays at ^3.** v4 removes the root export entirely (no `"."` in its `exports` map), so the `await import('bgutils-js')` the PoToken path uses would throw outright, and restructures the API (`Challenge.create` → a free `getChallenge`, `BotGuardClient` now event-emitter based with a different `snapshot` shape). That is a rewrite of the most fragile integration in the tree, and v3 demonstrably works — a live transcript fetch succeeds today.
66
+ - **`playwright-core` stays at `1.60.0`** (camoufox-js peer range, above) and **`typebox` at `1.3.7`** (pi 0.84.2, the newest published, pins exactly that).
67
+ - **`typescript` stays in `>=6.0.3 <6.1.0`.** TypeScript 7 is unusable here regardless of its own merits: typescript-eslint's newest release (8.67.0) still declares `typescript: ">=4.8.4 <6.1.0"` and hardcodes the same supported range, so 7.x would break linting. 6.0.3 is already the newest release in the supported line.
68
+
69
+ ### Verified, no change needed
70
+
71
+ - Every external integration was exercised against its live API rather than read: NVD, OSV, CISA KEV, and GitHub Advisories all return current data; YouTube transcripts resolve correctly despite `youtubei.js` and `bgutils-js` each being a major behind; StackExchange returns canonical high-score answers on the path the tool actually uses. CISA KEV and GitHub Advisories already order results deliberately (by due date and `direction=desc` respectively) and were left alone.
72
+ - The published package points `pi.extensions` at raw `src/`, which contains two `enum`s and parameter properties — both fatal under Node's strip-only type stripping. pi loads extensions through `jiti`, which transpiles fully, so this is safe; noted because the failure mode would be a total load failure, not a degradation.
73
+
74
+ ## [1.3.11] - 2026-08-14
75
+
76
+ Alongside the revert below, a four-way independent audit (docs-vs-code
77
+ consistency, orchestration/TUI/citations, the knowledge store, and CLI/config
78
+ precedence) swept the areas least recently re-checked, and an independent
79
+ fresh-eyes review of the resulting delta caught two follow-on defects (fixed
80
+ below). Regression tests cover the highest-severity fixes — the `--config`
81
+ credential drop (mutation-verified), the revalidation touch, the tool-name and
82
+ initial-links validation, the exit-78 message split, and the citation marker —
83
+ plus live end-to-end verification of the `--config` and validation behavior;
84
+ the remaining fixes are covered by the existing suites they run under.
85
+
86
+ ### Fixed
87
+
88
+ - **`--config` silently dropped credentials stored in the base `config.env`.** With an explicit `--config <path>`, the env bridge layered only `cli.env` + the named file — the global base was never read. Typed config keys survived (getConfig reads the base independently), but the env-only keys the bridge exists for — `PI_RESEARCH_API_KEY`, `PI_RESEARCH_PROVIDER`, and the security-API keys — vanished, so `research "q" --config ci.env` exited 78 ("no model or API key") on a correctly configured machine, contradicting the flag's own help text. The global base now always layers underneath (base → cli.env → named file). Reproduced live before the fix; regression test fails against the pre-fix code.
89
+ - **Two sessions configured with different embedding models could permanently destroy knowledge-store data.** Every session opens the same physical table, and a model mismatch migrates it aside as a backup — so two sessions with different `PI_RESEARCH_EMBEDDING_MODEL` values alternate migrations, and the backup pruner's keep-only-newest rule deleted the only surviving copy of the first model's data after just two alternations, silently. Superseded backups are now retained until they age past `CACHE_TTL_DAYS` (a backup older than the TTL holds only rows eviction would have deleted anyway), so alternation can no longer destroy data inside the retention window.
90
+ - **A stable page became permanently "stale" once `KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS` was set.** The over-age read is treated as a miss and re-scraped, but the re-ingest's content-hash dedup skipped the write without refreshing the timestamp — so the entry could never satisfy the serve-age gate again (a live scrape on every run, for exactly the content that never changes) and TTL eviction eventually deleted an entry re-verified byte-identical possibly hours earlier. The unchanged-content skip now touches the current generation's timestamp — guarded by a just-in-time newest-generation re-check so a concurrent process's newer ingest can't be outranked by the touch.
91
+ - **An interrupted `knowledge` search could report success.** `cmdKnowledge` wired the abort signal but never consulted the cancellation latch on the resolve path — its own comment named "resolve normally and report ok:true/exit 0 for an interrupted run" as the failure being prevented, and only the throwing half was handled. It now mirrors `cmdResearch`: exit 128+N and a `cancelled: true` JSON field.
92
+ - **Cancelled `--json` research output nondeterministically lost its diagnostics.** The signal handler's fire-and-forget shutdown resets the SDK globals that `researchers`/`trackedErrors` are read from; reading them at JSON-emit time raced it. They are now snapshotted as soon as the run resolves, which shrinks the race window to near zero (a shutdown that completes before the run's continuation is even scheduled can still win — strictly narrower than before).
93
+ - **Fresh-store init could burn all 5 retries against a never-initialized embedder.** The model-mismatch local fallback embedder is constructed uninitialized, and only the migration branch warmed it — the no-existing-table branch went straight to `createTable()`, which throws "embedder dimension unknown", failing every retry identically. The fresh-table branch now warms the embedder the same way.
94
+ - **Headless pi-extension runs leaked per-run session state for the process lifetime.** The non-TUI cleanup path cleared steering messages but never called `endResearchSession`, so every run left its failure-count entries and AbortController in the module-level session map (the TUI and SDK paths both free them).
95
+ - **A run that failed while queued for a run slot fired no terminal observer event.** Capacity exhaustion or cancel-while-queued rethrew before any orchestrator existed, so an observer that had just been told `run_queued` never received `onError` — violating the "exactly one of onComplete/onError" contract. The terminal callback now fires on both paths.
96
+ - **The rebuilt report carried a stray `##`/`**`/`>` line when the model wrote a decorated CITED LINKS header.** The header detector deliberately tolerates markdown prefixes, but both body-slicing consumers cut at the header *text*, leaving the tolerated marker dangling at the end of the body. Both now slice at the start of the header line.
97
+ - **`--exclude-tools` accepted unknown tool names silently** (a typo excluded nothing for the whole billed run — the repository's own test fixture had exactly this bug, excluding a tool named `security` that does not exist); names are now validated against the canonical set. The fresh-eyes review then caught the validator omitting `read` — excluding researchers' local-file reads is a legitimate hardening move that had always worked and would have started failing on the CLI; `read` is in the valid set. **A bare `--initial-links` with zero URLs** likewise proceeded silently and now fails fast like every other value-taking flag.
98
+ - **The exit-78 message conflated "nothing configured" with "model configured, key missing".** SKILL.md tells agents to relay the message verbatim, so users who had set `PI_RESEARCH_MODEL` were pointed at the wrong knob. The two cases now produce distinct messages.
99
+ - **`PI_RESEARCH_*` boolean/enum env vars silently mis-parsed with surrounding whitespace** (`'true '` read as false); both parsers now trim like the numeric parser always has. **On a model-load timeout, the still-loading ONNX pipeline now gets disposed** when it eventually resolves instead of leaking one native session per retry.
100
+
101
+ ### Documentation
102
+
103
+ - `.env.example` corrections: `PI_RESEARCH_ALLOW_LOOPBACK_SCRAPE` described a wider hole than the loopback-only scope it actually has (the same overstatement 1.3.9 fixed in CONFIGURATION.md); `PI_RESEARCH_LLM_TIMEOUT_MS` still showed the old 10-minute ceiling (raised to 30 in the schema); the "exhaustive reference" claim is honored again by documenting `PLAYWRIGHT_BROWSERS_PATH`, `XDG_CACHE_HOME`, and the skill launcher's `PI_RESEARCH_BIN`/`PI_RESEARCH_PATH`.
104
+ - ARCHITECTURE.md's "Key design decisions" still said researchers "can read and grep files" — stale since grep became default-excluded, and wrong outright since 1.3.10 made the exclusion unconditional. Rewritten, along with the tool table's "off by default" wording, the extension event list, and the fixed "≤3 videos" phrasing (configurable 1–5). README's "read and search files" corrected to read-only-files. CONFIGURATION.md now states that provider-native key variables are honored only as real environment variables, and the CLI help finally mentions `--version`/`-v` (implemented all along).
105
+
106
+ ### Removed
107
+
108
+ - **The 1.3.10 researcher `max_tokens` cap (`PI_RESEARCH_RESEARCHER_MAX_TOKENS`) is reverted.** It solved a non-problem and created a real risk. The 402 it "fixed" only fires when an OpenRouter balance is nearly exhausted, and that error already names the remedy — it is the provider's top-up prompt, not a client defect. Meanwhile the cap re-introduced the exact failure class `PLANNING_MAX_TOKENS` was raised to escape in 1.2.x: reasoning tokens count against `max_tokens` on OpenAI-compatible APIs, so with `PI_RESEARCH_LLM_THINKING_LEVEL` raised — or a model that cannot fully disable thinking — a 16k ceiling can be consumed before or during the report and truncate it. It also second-guessed the model catalog: pi sessions everywhere else honor the catalog `maxTokens`, and a user who wants a different ceiling can already set one per model via `modelOverrides` in `~/.pi/agent/models.json`, which covers every session this package creates. Researcher sessions once again inherit the model's catalog ceiling unmodified.
109
+
8
110
  ## [1.3.10] - 2026-08-14
9
111
 
10
112
  The post-release verification round for 1.3.9 kept digging and found that the
@@ -21,7 +123,7 @@ whole delta then caught two follow-on defects, fixed below.
21
123
  ### Fixed
22
124
 
23
125
  - **Every fetch scrape failed instantly; the whole path silently ran on the browser fallback.** `getSsrfSafeDispatcher()` built an `Agent` from the undici 8.x this package depends on and handed it to Node's **global** `fetch()` — which is backed by the undici compiled *into* the runtime (6.x on Node 22, 7.x on Node 25). Cross-major dispatchers are rejected outright (`UND_ERR_INVALID_ARG: invalid onRequestStart method`), so every fetch attempt threw before a single packet left the machine, burned one retry, and fell back to the stealth browser: ~3.4 s per page against the ~0.9 s the fetch path delivers, the browser pool saturated with work it never needed — and the connect-time SSRF DNS pin, the entire reason the dispatcher exists, never engaged once (request-time validation still applied throughout, so this was defence-in-depth loss, not an open hole). Nothing caught it because every test of the layer stubs the global `fetch`, and the fallback kept scrapes succeeding. The replacement, `getSsrfSafeFetcher()`, returns the Agent *paired with undici's own `fetch`*, making the invalid combination unrepresentable, and a new test drives the real fetch path against a real loopback socket with the browser layer disabled — it fails against the pre-fix code with the exact production error. Measured on live runs: 0 fetch / 5 browser successes before, 4 fetch / 0 browser after.
24
- - **Researchers sent the model's full ~230k catalog ceiling as `max_tokens` — and OpenRouter 402'd every call on a low balance.** The researcher session inherited the model's catalog `maxTokens` as its per-request output cap. Providers that budget-check `max_tokens` up front rejected the call before running it: observed live as `402: You requested up to 231037 tokens, but can only afford 24467` on every researcher, failing entire runs whose real cost was cents. Researcher output per call — tool calls plus a cited report — is nowhere near that. New `PI_RESEARCH_RESEARCHER_MAX_TOKENS` knob (default 16384, range 1024–131072), clamped to the model's real ceiling, applied in the shared session factory so deep and quick sub-agents are both covered.
126
+ - **Researchers sent the model's full ~230k catalog ceiling as `max_tokens` — and OpenRouter 402'd every call on a low balance.** The researcher session inherited the model's catalog `maxTokens` as its per-request output cap. Providers that budget-check `max_tokens` up front rejected the call before running it: observed live as `402: You requested up to 231037 tokens, but can only afford 24467` on every researcher, failing entire runs whose real cost was cents. Researcher output per call — tool calls plus a cited report — is nowhere near that. New `PI_RESEARCH_RESEARCHER_MAX_TOKENS` knob (default 16384, range 1024–131072), clamped to the model's real ceiling, applied in the shared session factory so deep and quick sub-agents are both covered. *(Reverted in 1.3.11 — see above.)*
25
127
  - **`--exclude-tools` silently granted researchers local-filesystem `grep`.** A non-empty exclusion list *replaced* the default exclusions instead of adding to them, so `--exclude-tools stackexchange` enabled a capability as a side effect of a flag documented as "disable internal tools". The same defect was fixed for `PI_RESEARCH_DISABLED_TOOLS` in 1.3.0 with the rationale "a subtract-only setting must never grant a capability"; the caller-facing surface kept the replacement semantics until now. Both surfaces are strictly additive over the default exclusions. (Behavioral change: there is no longer any way to hand researchers `grep`, which ARCHITECTURE.md previously documented as a quirk of the replacement rule.)
26
128
  - **A misconfigured $0 price table was silent on depth-0 runs.** Both researcher `message_end` accounting sites hand-copied the extract→increment→emit block instead of calling `recordLlmUsage`, so `warnIfUnpriced` — added in 1.1.2 precisely because a hand-written `models.json` entry silently discards pi's pricing — never ran for researcher usage. Depth-1+ runs still warned via the coordinator; depth-0 runs have no coordinator and reported `$0.0000` with no diagnostic at all. Metric labels and observer payloads are unchanged (`onTokensConsumed` now fires before `onResearcherProgress` rather than after — no consumer is order-sensitive), and the deep path keeps its `safeObserve` isolation.
27
129
  - **A connect-time SSRF block was filed as a generic fetch error.** undici never surfaces the connector's `ESSRFBLOCKED` error directly — `fetch` throws `TypeError: fetch failed` with the real error down the `cause` chain — so the message-based classification at both tracking sites missed it and a DNS-rebinding block would have been invisible in the SSRF metrics. Unreachable before this release (the dispatcher path had never executed); now that it is live, `isSsrfBlockError()` walks the bounded cause chain. Diagnostics-only: the block itself always held.
@@ -69,7 +69,7 @@ Research
69
69
  | `PI_RESEARCH_MAX_RESEARCHERS` (TUI) | `3` | 1–5 | Parallel researchers. |
70
70
  | `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` (TUI) `[project]` | `1` | 1–3 | Depth for `/research` and the CLI when `--depth` is omitted (1=normal, 2=deep, 3=ultra). |
71
71
  | `PI_RESEARCH_MAX_SCRAPE_BATCHES` (TUI) | `2` | 0–99 | Scrape batches per researcher (0 = unlimited). |
72
- | `PI_RESEARCH_MAX_GATHERING_CALLS` | `12` | 1–100 | Shared web-gathering calls per researcher (`search` + `security_search` + `stackexchange` + `youtube_transcript`). Local `grep` has its own separate budget. |
72
+ | `PI_RESEARCH_MAX_GATHERING_CALLS` | `12` | 1–100 | Shared web-gathering calls per researcher (`search` + `security_search` + `stackexchange` + `youtube_transcript`). |
73
73
  | `PI_RESEARCH_MAX_CONCURRENT_SCRAPES` | `3` | 1–20 | Concurrent URLs fetched per scrape batch. |
74
74
  | `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
75
75
  | `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
@@ -79,7 +79,7 @@ Research
79
79
  | `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. |
80
80
  | `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. |
81
81
  | `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 evaluator keep using the session model. Accepts `provider/id` or a bare model id. |
82
- | `PI_RESEARCH_DISABLED_TOOLS` | _(none)_ | — | Comma-separated research tools to disable for a run (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `grep`). Removed from every researcher's toolset and named in the coordinator/evaluator prompt. Strictly additive — it can only remove capabilities, never grant one, and it stacks on top of the default exclusions rather than replacing them. |
82
+ | `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/evaluator prompt. 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. |
83
83
  | `PI_RESEARCH_REPORT_EXPORT_ENABLED` (TUI) | `false` | — | Front-ends write a Markdown report to disk and surface its path. |
84
84
  | `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. |
85
85
  | `PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING` | `0.15` | 0.05–1.0 | Max fraction of the context window used for initial scrape context. |
@@ -114,7 +114,6 @@ These are advanced, env-only knobs (not in the TUI).
114
114
  | `PI_RESEARCH_LLM_THINKING_LEVEL` | `off` | off · minimal · low · medium · high | Chain-of-thought level for all engine LLM work (coordinator, evaluator, synthesis, JSON-repair, knowledge extraction, and researcher sub-agents). Off by default — these calls emit structured JSON / cited reports, so a thinking block only consumes the output budget and can truncate the answer. Clamped per model by pi. |
115
115
  | `PI_RESEARCH_PLANNING_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens for the plan + mid-round evaluator decision. Clamped to the model's real ceiling. |
116
116
  | `PI_RESEARCH_SYNTHESIS_MAX_TOKENS` | `32768` | 1024–131072 | Max output tokens for the final synthesized report. Clamped to the model's real ceiling. |
117
- | `PI_RESEARCH_RESEARCHER_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens per researcher LLM call (deep and quick). Without it the model's full catalog ceiling is sent as `max_tokens`, which OpenRouter budget-checks against remaining credits and rejects on low balances. |
118
117
 
119
118
  Knowledge store
120
119
 
@@ -136,7 +135,7 @@ API keys
136
135
 
137
136
  | Variable | Description |
138
137
  |----------|-------------|
139
- | `PI_RESEARCH_API_KEY` / `PI_RESEARCH_PROVIDER` | Explicit LLM credentials for SDK / CLI mode (not needed when pi's configuration supplies the key). On the CLI / agent skill both can also live in `config.env` / `cli.env`. The provider is required alongside the key, or inferred from a `provider/model-id` `PI_RESEARCH_MODEL`. |
138
+ | `PI_RESEARCH_API_KEY` / `PI_RESEARCH_PROVIDER` | Explicit LLM credentials for SDK / CLI mode (not needed when pi's configuration supplies the key). On the CLI / agent skill both can also live in `config.env` / `cli.env`. The provider is required alongside the key, or inferred from a `provider/model-id` `PI_RESEARCH_MODEL`. Note: provider-native variables (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, …) are honored only as **real environment variables** — placing one in `config.env` / `cli.env` does nothing (the files bridge only `PI_RESEARCH_*` keys plus `STACKEXCHANGE_API_KEY` / `GITHUB_TOKEN` / `NVD_API_KEY`). |
140
139
  | `STACKEXCHANGE_API_KEY` | Raises the Stack Exchange tool's limit from 300/day to 10,000/day. Obtain at <https://stackapps.com/apps/oauth>. |
141
140
  | `GITHUB_TOKEN` | Raises the security tool's GitHub Advisory limit from 60/hr to 5000/hr (any default-scope token). |
142
141
  | `NVD_API_KEY` | Raises the security tool's NVD limit ~10× and tightens request spacing. Request at <https://nvd.nist.gov/developers/request-an-api-key>. Recommended when using severity-filtered security searches: those issue a second (CVSS v2) NVD query to catch v2-only CVEs, which roughly doubles request time against the 6 s/request unauthenticated throttle. |
@@ -157,8 +156,10 @@ Diagnostics & platform
157
156
  | `PI_RESEARCH_SKILL_DIR` | _(auto)_ | Override the bundled research-skill source directory used by the skill installer. |
158
157
  | `PI_RESEARCH_PURGE_BROWSERS` | _(unset)_ | Read by the bundled `scripts/cleanup.cjs`: set `1` to also delete the shared camoufox browser cache (kept by default because other installs may use it). Note npm ≥7 does not run `preuninstall`, so that script does not fire on `npm uninstall` — see [AGENT-SKILL.md](AGENT-SKILL.md). |
159
158
  | `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` | _(unset)_ | Set `1` during `npm install` to skip the camoufox browser download (fetched lazily on first use instead; standard Playwright convention). |
160
- | `PLAYWRIGHT_BROWSERS_PATH` | _(user cache)_ | Standard Playwright variable, honoured here too: overrides where the camoufox binary is stored and looked up, and is passed through to browser workers. |
159
+ | `CAMOUFOX_INSTALL_DIR` | _(user cache)_ | camoufox-js's own variable, and the only one that can relocate the browser. Sets where pi-research looks the binary up, and is exported to the postinstall fetch and to browser workers. **Not effective on the currently pinned camoufox-js (0.10.x), which hardcodes its cache directory and honours no variable** — so relocating the browser is unsupported until that pin can advance (see ARCHITECTURE.md). |
160
+ | `PLAYWRIGHT_BROWSERS_PATH` | _(user cache)_ | Alias for the above, accepted for compatibility and exported onward as `CAMOUFOX_INSTALL_DIR`. Same caveat: on the pinned camoufox-js it moves only where pi-research looks, not where the download lands, so setting it reports the browser missing from the custom path after a successful install. |
161
161
  | `XDG_CACHE_HOME` | `~/.cache` | Standard XDG variable. When set, every `~/.cache/pi-research/...` path below is rooted at `$XDG_CACHE_HOME/pi-research/...` instead. |
162
+ | `PI_RESEARCH_BIN` (alias `PI_RESEARCH_PATH`) | _(auto)_ | Agent-skill launcher only: explicit path to the pi-research engine binary when auto-resolution (PATH → local install → npx) should be bypassed. See [skills/pi-research/references/configuration.md](../skills/pi-research/references/configuration.md). |
162
163
  | `PLAYWRIGHT_INSTALL_DEPS` | _(unset)_ | Linux only. Set `true` during `npm install` to also install system libraries via `npx playwright install-deps` (same as `npm run install:system-deps`). |
163
164
  | `PI_RESEARCH_STRICT_SETUP` | _(unset)_ | Read by the bundled `scripts/setup.cjs` during `npm install`. Set `1`/`true` to make a failed browser download fail the install instead of deferring it to first use. |
164
165
  | `PI_RESEARCH_CONFIG_DIR_NAME` | `.pi` | Override the host config-directory name under your home dir (advanced; e.g. set to share another harness's config root). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.3.10",
3
+ "version": "1.3.12",
4
4
  "description": "Free unlimited web search and deep research for the Pi coding agent. Local knowledge store. Also works from Claude Code, Codex, and OpenClaw.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -53,19 +53,27 @@
53
53
  "pi-package",
54
54
  "pi-extension",
55
55
  "pi-coding-agent",
56
+ "claude-code",
56
57
  "openclaw",
58
+ "codex",
59
+ "agent-skills",
57
60
  "agent-skill",
58
61
  "skills",
59
- "research",
62
+ "web-search",
60
63
  "web-research",
61
64
  "deep-research",
62
- "agent",
63
- "web-search",
65
+ "ai-research",
66
+ "research",
67
+ "web-scraping",
64
68
  "scraping",
65
- "knowledge-store",
66
- "duckduckgo",
69
+ "browser-automation",
67
70
  "stealth-browser",
68
- "camoufox"
71
+ "camoufox",
72
+ "duckduckgo",
73
+ "knowledge-store",
74
+ "llm-tools",
75
+ "web-access",
76
+ "agent"
69
77
  ],
70
78
  "author": "Lincoln Deen",
71
79
  "contributors": [
@@ -120,7 +128,7 @@
120
128
  "@earendil-works/pi-tui": ">=0.84.0 <1",
121
129
  "@huggingface/transformers": "4.2.0",
122
130
  "@kreuzberg/html-to-markdown-node": "^3.5.5",
123
- "@lancedb/lancedb": "0.29.0",
131
+ "@lancedb/lancedb": "0.37.1",
124
132
  "apache-arrow": "21.1.0",
125
133
  "bgutils-js": "^3.2.0",
126
134
  "camoufox-js": "^0.10.2",
@@ -133,7 +141,7 @@
133
141
  "poolifier": "^5.3.2",
134
142
  "typebox": "1.3.7",
135
143
  "undici": "^8.9.0",
136
- "youtubei.js": "^17.2.0"
144
+ "youtubei.js": "^18.0.0"
137
145
  },
138
146
  "optionalDependencies": {
139
147
  "webgpu": "^0.4.0"
@@ -154,7 +162,7 @@
154
162
  },
155
163
  "devDependencies": {
156
164
  "@eslint/js": "^10.0.1",
157
- "@types/jsdom": "^28.0.3",
165
+ "@types/jsdom": "^30.0.0",
158
166
  "@typescript-eslint/eslint-plugin": "^8.65.0",
159
167
  "@typescript-eslint/parser": "^8.65.0",
160
168
  "@vitest/coverage-v8": "^4.1.10",
@@ -197,13 +197,34 @@ const dirName = rawDirName && !rawDirName.includes('/') && !rawDirName.includes(
197
197
  * several tools' state by name); a substring match would treat that shared
198
198
  * folder as exclusively ours and wholesale-delete it. Only the exact default
199
199
  * layout is something WE constructed and can therefore prove is ours.
200
+ *
201
+ * Segment-adjacency is NOT that proof either, and used to be what this did:
202
+ * it tested whether `<dirName>/research` appeared ANYWHERE in the path, with no
203
+ * constraint on what followed. That also matched:
204
+ * - the research ROOT itself (`~/.pi/research`) — which holds config.env and
205
+ * knowledge_db/, the two things this script's header promises to preserve;
206
+ * - any unrelated directory beneath it (`/mnt/backup/.pi/research/mystate`);
207
+ * - traversal forms (`~/.pi/research/../../..`) that resolve far outside our
208
+ * tree entirely.
209
+ *
210
+ * The real proof is the full default *shape*: the runtime only ever builds
211
+ * `<root>/<dirName>/research/state`. Requiring those three trailing segments on
212
+ * the RESOLVED path keeps a relocated-but-default layout reclaimable while
213
+ * refusing the research root, sibling directories, and traversal escapes.
200
214
  */
215
+ const DEFAULT_STATE_TAIL = [dirName, 'research', 'state'];
216
+
201
217
  function isPiResearchNamespaced(p) {
202
- const segs = p.split(/[\\/]+/).filter(Boolean);
203
- for (let i = 0; i < segs.length - 1; i++) {
204
- if (segs[i] === dirName && segs[i + 1] === 'research') return true;
205
- }
206
- return false;
218
+ // Resolve first so `..` cannot smuggle the tail past this check.
219
+ const resolved = path.resolve(p);
220
+ // Windows and macOS default to case-insensitive filesystems, so a path
221
+ // differing only in case still names the directory we built.
222
+ const fold = (s) =>
223
+ process.platform === 'win32' || process.platform === 'darwin' ? s.toLowerCase() : s;
224
+ const segs = resolved.split(/[\\/]+/).filter(Boolean);
225
+ if (segs.length < DEFAULT_STATE_TAIL.length) return false;
226
+ const tail = segs.slice(-DEFAULT_STATE_TAIL.length);
227
+ return DEFAULT_STATE_TAIL.every((want, i) => fold(tail[i]) === fold(want));
207
228
  }
208
229
 
209
230
  /**
package/scripts/setup.cjs CHANGED
@@ -47,8 +47,25 @@ function resolveCamoufoxBin() {
47
47
  return null;
48
48
  }
49
49
 
50
+ /**
51
+ * The custom camoufox location, if the user set one. Mirrors
52
+ * getCustomCamoufoxDir() in src/infrastructure/browser/config.ts — the two must
53
+ * agree or install and lookup land in different directories.
54
+ *
55
+ * CAMOUFOX_INSTALL_DIR first: it is camoufox-js's own variable and the only one
56
+ * that can relocate the install (camoufox-js <0.12.0 hardcodes
57
+ * userCacheDir("camoufox") and honours nothing). PLAYWRIGHT_BROWSERS_PATH is
58
+ * kept as the documented alias and is exported BELOW as CAMOUFOX_INSTALL_DIR.
59
+ * NOTE: inert on the pinned camoufox-js 0.10.x, which reads neither — this is
60
+ * forward-compatible plumbing, not a live fix. See src/infrastructure/browser/
61
+ * config.ts and thread-worker-browser.ts.
62
+ */
63
+ function customCamoufoxDir() {
64
+ return process.env.CAMOUFOX_INSTALL_DIR || process.env.PLAYWRIGHT_BROWSERS_PATH || null;
65
+ }
66
+
50
67
  function camoufoxCachePath() {
51
- const customPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
68
+ const customPath = customCamoufoxDir();
52
69
  if (customPath) return customPath;
53
70
 
54
71
  if (isWindows) {
@@ -68,6 +85,13 @@ if (process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === '1') {
68
85
  console.log('pi-research: skipping browser download (PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1)');
69
86
  } else {
70
87
  const env = { ...process.env };
88
+ // Make the fetch honour the custom location. Without this the download always
89
+ // went to camoufox's default cache while camoufoxCachePath() reported the
90
+ // custom one: the "already installed" probe never matched, every install
91
+ // re-fetched, and at runtime the browser was reported missing despite having
92
+ // downloaded successfully.
93
+ const customDir = customCamoufoxDir();
94
+ if (customDir) env.CAMOUFOX_INSTALL_DIR = customDir;
71
95
 
72
96
  const installDeps = process.argv.includes('--system-deps') || process.env.PLAYWRIGHT_INSTALL_DEPS === 'true';
73
97
  if (installDeps && isLinux) {
@@ -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.3.10", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.3.12", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8