@lincoln504/pi-research 1.3.8 → 1.3.10

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/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,75 @@ 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.10] - 2026-08-14
9
+
10
+ The post-release verification round for 1.3.9 kept digging and found that the
11
+ fetch scrape path — the fast primary transport in front of the stealth browser —
12
+ had been dead in production on every supported Node version, hidden completely by
13
+ the browser fallback. Three more researcher-session defects surfaced from the same
14
+ investigation, two of them demonstrated live against a real provider. Each fix
15
+ carries a regression test verified to fail against the pre-fix code (the
16
+ accounting fix pins the deep-researcher site; the quick site shares the same
17
+ helper call), and the fetch fix carries the first test of that layer that uses a
18
+ real socket instead of a mocked `fetch`. An independent fresh-eyes review of the
19
+ whole delta then caught two follow-on defects, fixed below.
20
+
21
+ ### Fixed
22
+
23
+ - **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.
25
+ - **`--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
+ - **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
+ - **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.
28
+ - **One unit test silently made live network requests.** The fetch-stub migration for the undici change covered two of the three test files that drive the scrape layer; the missed one (`scraper-pdf-native-module-failure`) kept stubbing the *global* fetch, so every run of that "hermetic" test performed real DNS and an outbound TLS connection to a third-party host — and passed only because that host's certificate mismatch happened to reproduce the stubbed failure. Found by the fresh-eyes review; it now shims `getSsrfSafeFetcher` like its siblings.
29
+
30
+ ### Verified
31
+
32
+ - Full suites green after the changes: 191 unit files / 2457 tests, integration 53+8 skipped and 103, lint, both type-checks, dependency rules.
33
+ - Live production runs on OpenRouter at depths 0, 1, and 2: reports with citations, fetch-path scrapes at ~0.6–1.5 s/page with zero browser fallbacks and zero undici errors, non-zero cost accounting, knowledge store round-trip (same-day scrapes retrieved and grounded).
34
+ - The stderr FD-2 capture deliberately still skips pipes/sockets: when a parent process is reading stderr (pi host, MCP, CI), stealing FD 2 would hide fatal diagnostics to suppress cosmetic Dawn noise. Reviewed and kept as designed.
35
+
36
+ ## [1.3.9] - 2026-08-14
37
+
38
+ A whole-tree audit sweep. Six defects, each found by looking at what the code
39
+ actually does at runtime rather than at what the last review changed: two of them
40
+ (the redaction boundary and the SSRF literal range) are further instances of bug
41
+ classes earlier releases fixed elsewhere but did not fully close, and one — the
42
+ quadratic backtracking in the redactor — surfaced only because the boundary fix
43
+ was benchmarked before being accepted. Every fix below carries a regression test
44
+ confirmed, by a local revert-and-rerun, to fail against the pre-fix code.
45
+
46
+ ### Fixed
47
+
48
+ - **Redacting one large log message burned ~1.4 seconds of CPU.** `URL_CREDENTIALS_PATTERN` matched the URL scheme with an unbounded `[a-z0-9+.-]*`, so at every position inside a long alphanumeric run the engine consumed the rest of the input and then backtracked one character at a time looking for `://` — quadratic in the input length. `redactSecrets()` runs on **every** log message and deliberately scans a 40_000-character window, and the content it scans is largely untrusted: scraped pages and provider error bodies. A single page carrying one long unbroken token was enough, and the cost lands on the main thread. Bounding the scheme (RFC 3986 schemes are short; none approaches 32 characters) makes each start position O(32) instead of O(n) for identical matches — measured 1439 ms → 6.7 ms on a hostile 40k input, ~215x. Three pre-existing redaction tests dropped from 394/107/91 ms to 4/2/2 ms as a side effect, which is how long the cost had been sitting in plain sight. A regression test now pins the bound ~100x below the pre-fix cost, loose enough not to flake on a slow CI runner.
49
+ - **Log redaction masked `api_key=` but not `PI_RESEARCH_API_KEY=`.** `\b` is not a word boundary between `_` and a letter — both are word characters — so every secret whose key name carried an underscore-joined prefix passed through the redactor untouched and was written to the diagnostic log in the clear. That is the single most common way a credential is ever written down — this tool's own `PI_RESEARCH_API_KEY`, and every `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `DB_PASSWORD` / `MY_SECRET` beside it — and it reaches the log by more than one route: `redactSecrets()` is the last-resort filter over *arbitrary* content, which for a research tool includes scraped pages and provider error bodies. The existing `PI_BROWSER_AUTH_SECRET=` coverage passed only because a 64-char run of `a` is also valid hex, so the long-hex pattern caught it and the key-name gap stayed hidden. All secret-name and opaque-token patterns now treat any non-alphanumeric character as the boundary, so a prefixed name matches while a name merely *containing* the word (`monkey=`, `keyboard=`, `passwordless=`) still does not.
50
+ - **SSRF: the deprecated IPv4-compatible `::/96` range was accepted.** `http://[::127.0.0.1]/` normalizes to `[::7f00:1]`, which matched none of the guards: it is not `::1`, carries no `fc`/`fd`/`fe8x` prefix, has no `::ffff:` prefix for the mapped-IPv4 decoder to read, and is none of the 6to4/Teredo/NAT64 tunnel prefixes that are already blocked whole-range. It was the last IPv4-in-IPv6 form still open, and whether it reaches the embedded address depends on the OS, an intervening proxy, and NAT64 translation — the same "depends on the path" property that made those other ranges whole-range blocks. Now blocked structurally (top 96 bits zero), which is why it cannot swallow the mapped `::ffff:0:0/96` range next to it.
51
+ - **Dawn printed WebGPU warnings onto the user's terminal.** onnxruntime-node's bundled Dawn writes its limit-clamping notices ("`maxDynamicUniformBuffersPerPipelineLayout artificially reduced from 1000000 to 16`") to FD 2 from native code the moment a WebGPU instance is created, so a plain `pi-research knowledge …` run emitted four of them onto the terminal — and for the agent skill they landed in the stderr an agent is instructed to read for *errors*. `loadPipelineWithTimeout` already builds its pipeline inside the FD-level stderr capture for exactly this reason; the adapter probe in `initializeDawnWebGPU` is the other native entry point and was missing it. Confirmed gone on a real run, not just in the test.
52
+ - **`--depth` accepted a value that only started with a valid integer.** `parseInt` scans a leading prefix, so `--depth 2x` ran depth 2 and `--depth 2.9` ran depth 2, silently, while the error text promised "an integer 0–3". Depth is the knob that decides how long a run takes and what it costs, so a typo picking a different one is not a harmless clamp. The whole token is now matched.
53
+ - **The fetch scrape path rotated its User-Agent per redirect hop.** The UA was picked inside the manual redirect loop, so one scrape could present as Chrome on Windows for the first hop and Firefox on macOS for the next — something no browser does, which turned the rotation meant to blend in into a distinctive signal, on exactly the multi-hop requests (consent gates, tracking redirectors) where bot detection is most attentive. One UA now covers the whole chain.
54
+
55
+ ### Changed
56
+
57
+ - **User-Agent pool refreshed to current stable browsers.** It was pinned at Chrome 137/136 and Firefox 139/138 — around fourteen major versions behind on both, since each ships roughly every four weeks. A UA that stale is itself a bot signal, which is the thing rotating it exists to avoid. Now Chrome 151/150 and Firefox 153/152, verified against Chrome Releases (151 stable, 2026-07-28) and the Firefox release notes (153 stable, 2026-08-11), with the shelf life and the refresh procedure written down at the definition and a test guarding the *shape* of a future refresh (a `rv:`/`Firefox/` major mismatch, a missing Chrome minor triplet, an over-wide version spread) rather than a version floor that would go stale on the browsers' schedule.
58
+ - **pi 0.84.2.** Read the full (not grepped) 0.84.2 changelog: no breaking changes section, and its one dependency-visible change — the Mistral SDK transport replaced by a native HTTP stream — shows up here only as `@mistralai/mistralai`, `zod`, and `zod-to-json-schema` dropping out of the lockfile. `PI_TESTED_MAX_VERSION` moved to 0.84.2 to record what was actually exercised (full unit + integration suites, plus a live knowledge-store run). The supported floor is unchanged at 0.84.0.
59
+ - **Release workflow serializes.** Added the `concurrency: release` group the workflow's own TODO called for, with `cancel-in-progress: false` so a release already mid-publish is never cancelled by a newer tag. Closes one of the two documented hardening TODOs; SHA-pinning the actions remains open.
60
+ - **README wording.** Tagline and alt text read "for agents", the install label reads "As a Pi Coding Agent extension" with only "Pi" linked, the standalone list drops the CLI and links the agent-skill doc, and the italics and surplus links in the use-case and requirements bullets are gone.
61
+ - Removed the dead `REQUEST_DELAY_MS_NVD` / `REQUEST_DELAY_MS_OTHER` constants (no reader anywhere in the tree; the security clients carry their own throttles).
62
+
63
+ ### Documentation
64
+
65
+ - **`PI_RESEARCH_ALLOW_LOOPBACK_SCRAPE` was documented as a wider hole than it is.** The table described it as permitting "loopback/private/internal addresses, bypassing SSRF protection". The flag is deliberately scoped to loopback only — link-local `169.254.0.0/16` (cloud metadata) and RFC1918 LAN ranges stay blocked with it set, at request time and at connect time, and a test pins that. Corrected, since an inaccurate description of a security control is worse than none.
66
+ - Documented `PI_RESEARCH_STRICT_SETUP` (read by `scripts/setup.cjs`; makes a failed browser download fail the install instead of deferring it to first use) in both `docs/CONFIGURATION.md` and `.env.example` — it was the only environment variable in the tree missing from either, and `.env.example` states outright that it is the exhaustive reference.
67
+ - Corrected two stale in-code comments that still named `>=0.80.8` as the declared `@earendil-works/*` range after the floor moved to 0.84.0 in 1.3.3, and the pi version cited in the ARCHITECTURE typebox pin rationale (0.84.1 → 0.84.2; the pinned typebox is still 1.3.7).
68
+
69
+ ### Verified
70
+
71
+ - Full unit suite, the cross-group integration suite against a real Camoufox browser, lint, both type-checks, the architectural dependency rules, and the production audit gate — all green. Live end-to-end checks: all four security databases (NVD, CISA KEV, GitHub Advisories, OSV) answered with the expected shapes, and a knowledge-store run exercised the embedder, LanceDB, and the synthesis path. A live `research` run could not complete — the configured OpenRouter account is out of credits — but the engine reported that correctly: exit 70, the provider's own 402 surfaced as the cause, and no report fabricated from nothing.
72
+ - The single allowlisted advisory (GHSA-f88m-g3jw-g9cj, `sharp` via `@huggingface/transformers`, unreachable image path) still has no upstream fix; no new advisories. No open CodeQL alerts, issues, or pull requests.
73
+ - Swept the rest of the tree for the same backtracking shape rather than fixing only the instance that was measured. The remaining candidates are lazy or anchored, and the regexes that actually run over scraped content (`IMAGE_LINK_PATTERN`, `MARKDOWN_IMAGE_PATTERN`, the blank-line collapse) stay under 20 ms on 500_000-character hostile inputs — unbalanced brackets, unterminated parens, single-character runs — at the scrape cap. The redactor was the only instance.
74
+ - Environment-variable coverage is now exhaustive in both directions: every `PI_RESEARCH_*` name read anywhere in `src/` or `scripts/` appears in `docs/CONFIGURATION.md` and `.env.example`, and every documented name is read somewhere. The one deliberate omission is the `PI_RESEARCH_KNOWLEDGE_STORE_CACHE_TTL_DAYS` read-time alias, which exists only for backward compatibility and should not be advertised over the canonical `PI_RESEARCH_CACHE_TTL_DAYS`.
75
+ - Considered and rejected: the chunker's per-iteration `text.slice(0, start).match(/```/g)` is also quadratic in document length, but measured at 22 ms for a 500k-character document — the scrape cap — which does not justify changing code on the storage hot path. Measuring is what separated it from the redactor case above, where the same shape cost 1.4 s.
76
+
8
77
  ## [1.3.8] - 2026-08-13
9
78
 
10
79
  Presentation and dependency maintenance only. No engine changes.
@@ -114,6 +114,7 @@ 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. |
117
118
 
118
119
  Knowledge store
119
120
 
@@ -159,6 +160,7 @@ Diagnostics & platform
159
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. |
160
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. |
161
162
  | `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
+ | `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. |
162
164
  | `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). |
163
165
 
164
166
  Testing only — never enable in production
@@ -168,7 +170,7 @@ Testing only — never enable in production
168
170
  | `PI_RESEARCH_MOCK_SEARCH` | Return fabricated search results instead of real web data. |
169
171
  | `PI_RESEARCH_MOCK_SCRAPE` | Return fabricated scrape results instead of real page content. |
170
172
  | `PI_RESEARCH_FORCE_READY` | Bypass readiness checks and run even when critical services failed to initialize. **pi extension only** — the CLI, agent skill and SDK do not consult it. |
171
- | `PI_RESEARCH_ALLOW_LOOPBACK_SCRAPE` | Permit scraping loopback/private/internal addresses, bypassing SSRF protection. |
173
+ | `PI_RESEARCH_ALLOW_LOOPBACK_SCRAPE` | Permit scraping **loopback** addresses (`127.0.0.0/8`, `::1`, `*.localhost`, `::ffff:127.x`) so integration tests can drive the real browser and scrape pipeline against a local server. Deliberately scoped to loopback only: link-local `169.254.0.0/16` (cloud metadata) and RFC1918 LAN ranges stay blocked even with this set, at request time and at connect time. |
172
174
 
173
175
  ### How configuration is layered
174
176
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.3.8",
3
+ "version": "1.3.10",
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,
@@ -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.8", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.3.10", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/src/cli.ts CHANGED
@@ -1360,7 +1360,11 @@ export function parseArgs(argv: string[]): ParsedArgs {
1360
1360
  }
1361
1361
  if (a === '--depth') {
1362
1362
  const v = rest[++i];
1363
- const n = v === undefined ? NaN : parseInt(v, 10);
1363
+ // Match the WHOLE token, not parseInt's leading-prefix scan: parseInt
1364
+ // reads '2x' as 2 and '2.9' as 2, so a typo silently ran a different
1365
+ // depth — the one knob that decides how long a run takes and what it
1366
+ // costs — while the error text promised "an integer 0–3".
1367
+ const n = v !== undefined && /^\d+$/.test(v.trim()) ? Number(v.trim()) : NaN;
1364
1368
  if (isNaN(n) || n < 0 || n > 3) throw new UsageError('--depth must be an integer 0–3.');
1365
1369
  depth = n;
1366
1370
  } else if (a === '--model') {
package/src/config.ts CHANGED
@@ -122,6 +122,15 @@ export const ConfigSchema = Type.Object({
122
122
  * the model's real maxTokens at call time.
123
123
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
124
124
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
125
+ /** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
126
+ * Without a cap the session inherits the model's full catalog ceiling as the
127
+ * per-request max_tokens — ~230k for large-context models — which providers that
128
+ * budget-check max_tokens up front (OpenRouter 402s against remaining credits)
129
+ * reject outright on low balances, even though the actual response would cost
130
+ * cents. Default 16384: researcher turns are tool calls plus a cited report, far
131
+ * below this. Clamped to the model's real ceiling at session build time.
132
+ * Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
133
+ RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
125
134
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
126
135
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
127
136
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
@@ -233,6 +242,7 @@ const USER_MIGRATION_KEYS = [
233
242
  'PI_RESEARCH_LLM_THINKING_LEVEL',
234
243
  'PI_RESEARCH_PLANNING_MAX_TOKENS',
235
244
  'PI_RESEARCH_SYNTHESIS_MAX_TOKENS',
245
+ 'PI_RESEARCH_RESEARCHER_MAX_TOKENS',
236
246
  'PI_RESEARCH_MIGRATION_STRATEGY',
237
247
  'PI_RESEARCH_CONSOLE_LOG',
238
248
  'PI_RESEARCH_MODEL',
@@ -865,6 +875,7 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
865
875
  PI_RESEARCH_LLM_THINKING_LEVEL: config.LLM_THINKING_LEVEL,
866
876
  PI_RESEARCH_PLANNING_MAX_TOKENS: String(config.PLANNING_MAX_TOKENS),
867
877
  PI_RESEARCH_SYNTHESIS_MAX_TOKENS: String(config.SYNTHESIS_MAX_TOKENS),
878
+ PI_RESEARCH_RESEARCHER_MAX_TOKENS: String(config.RESEARCHER_MAX_TOKENS),
868
879
  PI_RESEARCH_MIGRATION_STRATEGY: config.MIGRATION_STRATEGY,
869
880
  PI_RESEARCH_CONSOLE_LOG: String(config.CONSOLE_LOG),
870
881
  ...(config.RESEARCH_MODEL ? { PI_RESEARCH_MODEL: config.RESEARCH_MODEL } : {}),
@@ -1095,6 +1106,7 @@ export function createConfig(env: Record<string, string | undefined>, processEnv
1095
1106
  LLM_THINKING_LEVEL: parseEnvEnum(e, 'PI_RESEARCH_LLM_THINKING_LEVEL', ['off', 'minimal', 'low', 'medium', 'high'] as const, DEFAULTS.LLM_THINKING_LEVEL),
1096
1107
  PLANNING_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_PLANNING_MAX_TOKENS', DEFAULTS.PLANNING_MAX_TOKENS, 1024, 131072),
1097
1108
  SYNTHESIS_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_SYNTHESIS_MAX_TOKENS', DEFAULTS.SYNTHESIS_MAX_TOKENS, 1024, 131072),
1109
+ RESEARCHER_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_RESEARCHER_MAX_TOKENS', DEFAULTS.RESEARCHER_MAX_TOKENS, 1024, 131072),
1098
1110
  MIGRATION_STRATEGY: parseEnvEnum(e, 'PI_RESEARCH_MIGRATION_STRATEGY', ['drop', 're-embed', 'backup'] as const, DEFAULTS.MIGRATION_STRATEGY),
1099
1111
  CONSOLE_LOG: parseEnvBool(e, 'PI_RESEARCH_CONSOLE_LOG', DEFAULTS.CONSOLE_LOG),
1100
1112
  RESEARCH_MODEL: parseEnvString(e, 'PI_RESEARCH_MODEL', DEFAULTS.RESEARCH_MODEL),
package/src/constants.ts CHANGED
@@ -8,10 +8,6 @@ import { getConfig, type Config } from './config.ts';
8
8
 
9
9
  // ==================== Time Constants ====================
10
10
 
11
- /** Delay between API requests in milliseconds */
12
- export const REQUEST_DELAY_MS_NVD = 6000;
13
- export const REQUEST_DELAY_MS_OTHER = 1000;
14
-
15
11
  /** Duration (ms) for a green (success) flash on a researcher column */
16
12
  export const FLASH_GREEN_DURATION_MS = 400;
17
13
 
@@ -189,22 +185,17 @@ export const DEFAULT_EXCLUDED_TOOLS: readonly string[] = ['grep'];
189
185
  /**
190
186
  * Resolve the effective researcher exclusion list.
191
187
  *
192
- * An EMPTY array is "no preference", not "exclude nothing". The two front-ends
193
- * express no-preference differently — the pi extension always builds an array
194
- * (`[...new Set([...])]`, often empty) while the CLI omits the key entirely — so a
195
- * plain truthiness test made the same user input mean opposite things: grep was
196
- * available in the extension and excluded on the CLI, regardless of depth.
197
- *
198
- * `configDisabled` (PI_RESEARCH_DISABLED_TOOLS) is strictly ADDITIVE. Folding it in
199
- * as a replacement meant disabling any unrelated tool silently ENABLED grep, so a
200
- * subtract-only setting could grant a capability.
188
+ * EVERY input is strictly ADDITIVE on top of DEFAULT_EXCLUDED_TOOLS. Both
189
+ * subtract-only surfaces — `--exclude-tools` / `excludeTools` and
190
+ * PI_RESEARCH_DISABLED_TOOLS (`configDisabled`) — are documented as "disable
191
+ * tools", so neither may GRANT a capability as a side effect. Each has held the
192
+ * replacement semantics at some point, with the same consequence both times:
193
+ * excluding any unrelated tool silently enabled local-filesystem grep for web
194
+ * researchers.
201
195
  */
202
196
  export function resolveExcludedTools(
203
197
  callerExclusions: readonly string[] | undefined,
204
198
  configDisabled: readonly string[] = [],
205
199
  ): string[] {
206
- const base = callerExclusions && callerExclusions.length > 0
207
- ? callerExclusions
208
- : DEFAULT_EXCLUDED_TOOLS;
209
- return [...new Set([...base, ...configDisabled])];
200
+ return [...new Set([...DEFAULT_EXCLUDED_TOOLS, ...(callerExclusions ?? []), ...configDisabled])];
210
201
  }
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * - As a pi EXTENSION, the host supplies `@earendil-works/*`. Our package.json
9
9
  * range never constrains it, so an in-process check is the only enforcement.
10
- * - As a standalone CLI/SDK, the range is `>=0.80.8 <1` and published tarballs
10
+ * - As a standalone CLI/SDK, the range is `>=0.84.0 <1` and published tarballs
11
11
  * carry no lockfile, so every fresh install resolves the newest 0.x at that
12
12
  * instant. pi is pre-1.0: a minor bump may break anything under semver, and
13
13
  * already has (0.83.0 extended the ResourceLoader contract).
@@ -83,7 +83,7 @@ export const PI_MIN_VERSION: SemverParts = { major: 0, minor: 84, patch: 0 };
83
83
  * Compared on MAJOR.MINOR only: a patch bump within a tested line is not a new
84
84
  * surface, and warning on it would be noise.
85
85
  */
86
- export const PI_TESTED_MAX_VERSION: SemverParts = { major: 0, minor: 84, patch: 1 };
86
+ export const PI_TESTED_MAX_VERSION: SemverParts = { major: 0, minor: 84, patch: 2 };
87
87
 
88
88
  export type PiCompatibilityLevel = 'ok' | 'unparseable' | 'too-old' | 'untested';
89
89
 
package/src/index.ts CHANGED
@@ -91,12 +91,14 @@ function extractResultText(result: AgentToolResult<unknown>): string {
91
91
  * Pi Research Extension
92
92
  */
93
93
  export default async function (pi: ExtensionAPI) {
94
- // Runtime version check — must match the @earendil-works/* dependency minimum (>=0.80.8).
95
- // 0.80.8 is the floor because pi 0.80.8 introduced ModelRuntime and removed
94
+ // Runtime version check — must match the @earendil-works/* dependency minimum (>=0.84.0).
95
+ // The floor is 0.84.0 because setRuntimeApiKey() lost its `allowNetwork` option there
96
+ // and only 0.84.0+ hardcodes the equivalent guard internally (see pi-version.ts). The
97
+ // APIs it rests on arrived in 0.80.8, which introduced ModelRuntime and removed
96
98
  // AuthStorage/ModelRegistry.create(); buildModelRegistry (model-registry-factory.ts)
97
99
  // unconditionally calls ModelRuntime.create(), and createAgentSession() is invoked
98
100
  // without the removed `modelRegistry` option (researcher.ts), relying on the
99
- // 0.80.8+ host auto-building its ModelRuntime. This in-host check is the real
101
+ // host auto-building its ModelRuntime. This in-host check is the real
100
102
  // enforcement point: when running as an extension the host's version is NOT
101
103
  // constrained by npm's resolution of our own package.json range.
102
104
  // Two thresholds, not one — see src/core/pi-version.ts. Below the floor the APIs
@@ -77,22 +77,36 @@ function redactSecrets(message) {
77
77
  function stripTerminalEscapes(message) {
78
78
  return message.replace(TERMINAL_ESCAPE_PATTERN, "").replace(CONTROL_CHARS_PATTERN, " ");
79
79
  }
80
- var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, COOKIE_HEADER_PATTERN, SENSITIVE_KV_PATTERN, KNOWN_TOKEN_PATTERN, JWT_PATTERN, BASIC_AUTH_PATTERN, BEARER_AUTH_PATTERN, PROVIDER_TOKEN_PATTERN, LONG_HEX_SECRET_PATTERN, CONTROL_CHARS_PATTERN, REDACT_SCAN_LENGTH, TOKEN_CHAR_PATTERN, REDACT_SCAN_EXTENSION_MAX, TERMINAL_ESCAPE_PATTERN;
80
+ var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, B_LEFT, B_RIGHT, COOKIE_HEADER_PATTERN, SENSITIVE_KV_PATTERN, KNOWN_TOKEN_PATTERN, JWT_PATTERN, BASIC_AUTH_PATTERN, BEARER_AUTH_PATTERN, PROVIDER_TOKEN_PATTERN, LONG_HEX_SECRET_PATTERN, CONTROL_CHARS_PATTERN, REDACT_SCAN_LENGTH, TOKEN_CHAR_PATTERN, REDACT_SCAN_EXTENSION_MAX, TERMINAL_ESCAPE_PATTERN;
81
81
  var init_log_utils = __esm({
82
82
  "src/utils/log-utils.ts"() {
83
83
  "use strict";
84
84
  logContextStorage = new AsyncLocalStorage();
85
85
  MAX_LOG_MESSAGE_LENGTH = 1e4;
86
86
  ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
87
- URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/gi;
88
- COOKIE_HEADER_PATTERN = /\b(set[_-]?cookie|cookie)\b(["']?\s*[:=]\s*["']?)([^\r\n]+)/gi;
89
- SENSITIVE_KV_PATTERN = /\b(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key|key)\b(["']?\s*[:=]\s*["']?)([^\s"',&)]+)/gi;
90
- KNOWN_TOKEN_PATTERN = /\b(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/g;
91
- JWT_PATTERN = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
92
- BASIC_AUTH_PATTERN = /\bBasic\s+[A-Za-z0-9+/]{16,}={0,2}/gi;
93
- BEARER_AUTH_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi;
94
- PROVIDER_TOKEN_PATTERN = /\b(AIza[0-9A-Za-z_-]{35}|ya29\.[0-9A-Za-z_-]{20,}|[sp]k_(?:live|test)_[0-9A-Za-z]{16,})\b/g;
95
- LONG_HEX_SECRET_PATTERN = /\b[0-9a-fA-F]{32,}\b/g;
87
+ URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]{0,31}:\/\/)[^/\s:@]+:[^/\s:@]+@/gi;
88
+ B_LEFT = "(?<![A-Za-z0-9])";
89
+ B_RIGHT = "(?![A-Za-z0-9])";
90
+ COOKIE_HEADER_PATTERN = new RegExp(
91
+ `${B_LEFT}(set[_-]?cookie|cookie)${B_RIGHT}(["']?\\s*[:=]\\s*["']?)([^\\r\\n]+)`,
92
+ "gi"
93
+ );
94
+ SENSITIVE_KV_PATTERN = new RegExp(
95
+ `${B_LEFT}(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key|key)${B_RIGHT}(["']?\\s*[:=]\\s*["']?)([^\\s"',&)]+)`,
96
+ "gi"
97
+ );
98
+ KNOWN_TOKEN_PATTERN = new RegExp(
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
+ "g"
101
+ );
102
+ JWT_PATTERN = new RegExp(`${B_LEFT}eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+`, "g");
103
+ BASIC_AUTH_PATTERN = new RegExp(`${B_LEFT}Basic\\s+[A-Za-z0-9+/]{16,}={0,2}`, "gi");
104
+ BEARER_AUTH_PATTERN = new RegExp(`${B_LEFT}Bearer\\s+[A-Za-z0-9._~+/-]+=*`, "gi");
105
+ PROVIDER_TOKEN_PATTERN = new RegExp(
106
+ `${B_LEFT}(AIza[0-9A-Za-z_-]{35}|ya29\\.[0-9A-Za-z_-]{20,}|[sp]k_(?:live|test)_[0-9A-Za-z]{16,})${B_RIGHT}`,
107
+ "g"
108
+ );
109
+ LONG_HEX_SECRET_PATTERN = new RegExp(`${B_LEFT}[0-9a-fA-F]{32,}${B_RIGHT}`, "g");
96
110
  CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
97
111
  REDACT_SCAN_LENGTH = MAX_LOG_MESSAGE_LENGTH * 4;
98
112
  TOKEN_CHAR_PATTERN = /[A-Za-z0-9_\-./+=]/;
@@ -110,21 +124,21 @@ var init_user_agent = __esm({
110
124
  "src/utils/user-agent.ts"() {
111
125
  "use strict";
112
126
  USER_AGENTS = [
113
- // Chrome on Windows (updated 2025)
114
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
115
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
127
+ // Chrome on Windows
128
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
129
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
116
130
  // Chrome on macOS
117
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
118
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
131
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
132
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
119
133
  // Chrome on Linux
120
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
121
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
122
- // Firefox on Windows (updated 2025)
123
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0",
124
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0",
134
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
135
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
136
+ // Firefox on Windows
137
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0",
138
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
125
139
  // Firefox on macOS
126
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0",
127
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
140
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:153.0) Gecko/20100101 Firefox/153.0",
141
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0"
128
142
  ];
129
143
  }
130
144
  });
@@ -1498,8 +1512,9 @@ __export(scraper_utils_exports, {
1498
1512
  extractDomain: () => extractDomain,
1499
1513
  formatErrorWithCause: () => formatErrorWithCause,
1500
1514
  getRandomUserAgent: () => getRandomUserAgent,
1501
- getSsrfSafeDispatcher: () => getSsrfSafeDispatcher,
1515
+ getSsrfSafeFetcher: () => getSsrfSafeFetcher,
1502
1516
  isBenignScrapeFailure: () => isBenignScrapeFailure,
1517
+ isSsrfBlockError: () => isSsrfBlockError,
1503
1518
  ssrfSafeLookup: () => ssrfSafeLookup,
1504
1519
  stripImageLinks: () => stripImageLinks,
1505
1520
  validateContent: () => validateContent,
@@ -1619,16 +1634,29 @@ function ssrfSafeLookup(hostname, options, callback, resolveImpl = dnsCb.lookup)
1619
1634
  return callback(null, safe[0].address, safe[0].family);
1620
1635
  });
1621
1636
  }
1622
- async function getSsrfSafeDispatcher() {
1623
- if (cachedSsrfDispatcher !== void 0) return cachedSsrfDispatcher;
1637
+ function isSsrfBlockError(error) {
1638
+ let e = error;
1639
+ for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
1640
+ if (e.code === "ESSRFBLOCKED" || e.message.includes("not allowed")) {
1641
+ return true;
1642
+ }
1643
+ e = e.cause;
1644
+ }
1645
+ return false;
1646
+ }
1647
+ async function getSsrfSafeFetcher() {
1648
+ if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
1624
1649
  try {
1625
- const { Agent } = await import("undici");
1626
- cachedSsrfDispatcher = new Agent({ connect: { lookup: ssrfSafeLookup } });
1650
+ const { Agent, fetch: undiciFetch } = await import("undici");
1651
+ cachedSsrfFetcher = {
1652
+ fetch: undiciFetch,
1653
+ dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
1654
+ };
1627
1655
  } catch (e) {
1628
1656
  logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
1629
- cachedSsrfDispatcher = null;
1657
+ cachedSsrfFetcher = null;
1630
1658
  }
1631
- return cachedSsrfDispatcher;
1659
+ return cachedSsrfFetcher;
1632
1660
  }
1633
1661
  function formatErrorWithCause(err) {
1634
1662
  try {
@@ -1678,6 +1706,7 @@ function isPrivateIpv6(ip) {
1678
1706
  if (normalized.startsWith("2002:")) return true;
1679
1707
  if (/^2001:0{0,4}:/.test(normalized) || normalized.startsWith("2001::")) return true;
1680
1708
  if (normalized.startsWith("64:ff9b:")) return true;
1709
+ if (/^::(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9a-f]{1,4}(?::[0-9a-f]{1,4})?)$/.test(normalized)) return true;
1681
1710
  if (normalized === "::" || normalized === "0:0:0:0:0:0:0:0") return true;
1682
1711
  return false;
1683
1712
  }
@@ -1761,7 +1790,7 @@ function validateContent(html, markdown, url) {
1761
1790
  throw error;
1762
1791
  }
1763
1792
  }
1764
- var cachedSsrfDispatcher;
1793
+ var cachedSsrfFetcher;
1765
1794
  var init_scraper_utils = __esm({
1766
1795
  "src/web-research/scraper-utils.ts"() {
1767
1796
  "use strict";
@@ -1770,7 +1799,7 @@ var init_scraper_utils = __esm({
1770
1799
  init_logger();
1771
1800
  init_scraper_types();
1772
1801
  init_browser_error_utils();
1773
- cachedSsrfDispatcher = void 0;
1802
+ cachedSsrfFetcher = void 0;
1774
1803
  }
1775
1804
  });
1776
1805
 
@@ -2515,6 +2544,15 @@ var ConfigSchema = Type.Object({
2515
2544
  * the model's real maxTokens at call time.
2516
2545
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
2517
2546
  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 }),
2518
2556
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
2519
2557
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
2520
2558
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
@@ -254,16 +254,26 @@ export async function initializeDawnWebGPU(): Promise<boolean> {
254
254
  if (dawnInitialized) return true;
255
255
 
256
256
  try {
257
- // @ts-ignore — optional dependency
258
- const { create, globals } = await import('webgpu');
259
- Object.assign(globalThis, globals);
260
- // Use Object.defineProperty — Node.js defines navigator as a configurable getter
261
- Object.defineProperty(globalThis, 'navigator', {
262
- value: { gpu: create([]) },
263
- writable: true,
264
- configurable: true,
257
+ // Dawn writes its limit-clamping notices ("maxDynamicUniformBuffersPer-
258
+ // PipelineLayout artificially reduced from 1000000 to 16 …") straight to
259
+ // FD 2 from native code the moment create() spins up an instance, so they
260
+ // land on the user's terminal — and, for the agent skill, in the stderr an
261
+ // agent is told to read for ERRORS. loadPipelineWithTimeout already runs the
262
+ // pipeline construction inside this same FD-level capture for exactly this
263
+ // reason; the adapter probe below is the other native entry point and was
264
+ // missing it, which is why a plain `knowledge` run still printed them.
265
+ const adapter = await logger.runCapturingStderr(async () => {
266
+ // @ts-ignore — optional dependency
267
+ const { create, globals } = await import('webgpu');
268
+ Object.assign(globalThis, globals);
269
+ // Use Object.defineProperty — Node.js defines navigator as a configurable getter
270
+ Object.defineProperty(globalThis, 'navigator', {
271
+ value: { gpu: create([]) },
272
+ writable: true,
273
+ configurable: true,
274
+ });
275
+ return await (globalThis as any).navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
265
276
  });
266
- const adapter = await (globalThis as any).navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
267
277
 
268
278
  if (!adapter) {
269
279
  logger.warn('[embedder] No WebGPU adapter available — using CPU.');
@@ -13,7 +13,7 @@ import {
13
13
  import { type Model } from '@earendil-works/pi-ai';
14
14
  import { injectCurrentDate } from '../core/llm/inject-date.ts';
15
15
  import { loadPrompt } from '../core/llm/prompts.ts';
16
- import { extractUsage } from '../types/llm.ts';
16
+ import { recordLlmUsage } from '../utils/llm-usage.ts';
17
17
  import { logger } from '../logger.ts';
18
18
  import { getConfig, type Config } from '../config.ts';
19
19
  import { createResearcherSession } from './researcher.ts';
@@ -238,13 +238,13 @@ export class QuickResearchOrchestrator {
238
238
 
239
239
  const rawUsage = msg['usage'] as any;
240
240
  if (rawUsage) {
241
- const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
242
-
241
+ const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
242
+ component: 'quick_researcher',
243
+ complexity: 0,
244
+ observer,
245
+ });
243
246
  if (tokens > 0 || cost > 0) {
244
- metrics.increment('llm_tokens_total', tokens, { component: 'quick_researcher', complexity: '0' });
245
- metrics.increment('llm_cost_total', cost, { component: 'quick_researcher', complexity: '0' });
246
247
  observer?.onResearcherProgress?.('quick', undefined, tokens, cost);
247
- observer?.onTokensConsumed?.(tokens, cost);
248
248
  }
249
249
  }
250
250
  } else if (event.type === 'tool_execution_start') {
@@ -9,7 +9,7 @@ import type { SystemResearchState } from './deep-research-types.ts';
9
9
  import { createResearcherSession } from './researcher.ts';
10
10
  import { registerScrapedLinks } from '../utils/shared-links.ts';
11
11
  import { ensureAssistantResponse } from '../utils/text-utils.ts';
12
- import { extractUsage } from '../types/llm.ts';
12
+ import { recordLlmUsage } from '../utils/llm-usage.ts';
13
13
  import { logger } from '../logger.ts';
14
14
  import { metrics } from '../utils/metrics.ts';
15
15
  import { ServiceNames,
@@ -347,13 +347,15 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
347
347
 
348
348
  const rawUsage = msg['usage'] as any;
349
349
  if (rawUsage) {
350
- const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
351
-
350
+ // Observer exceptions must not kill the researcher, so hand recordLlmUsage
351
+ // a safeObserve-wrapped sink rather than the raw observer.
352
+ const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
353
+ component: 'researcher',
354
+ complexity,
355
+ observer: { onTokensConsumed: (t, c) => safeObserve(() => observer?.onTokensConsumed?.(t, c)) },
356
+ });
352
357
  if (tokens > 0 || cost > 0) {
353
- metrics.increment('llm_tokens_total', tokens, { component: 'researcher', complexity: String(complexity) });
354
- metrics.increment('llm_cost_total', cost, { component: 'researcher', complexity: String(complexity) });
355
358
  safeObserve(() => observer?.onResearcherProgress?.(id, undefined, tokens, cost));
356
- safeObserve(() => observer?.onTokensConsumed?.(tokens, cost));
357
359
  }
358
360
  }
359
361
  } else if (event.type === 'tool_execution_start') {
@@ -85,6 +85,17 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
85
85
 
86
86
  logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
87
87
 
88
+ // The session sends model.maxTokens as the per-request max_tokens. Large-context
89
+ // catalog entries carry ~230k ceilings, and providers that budget-check max_tokens
90
+ // up front (OpenRouter 402s it against remaining credits) then reject every
91
+ // researcher call on a low balance even though the real response costs cents.
92
+ // Clamp the session's copy of the model to RESEARCHER_MAX_TOKENS; Model is plain
93
+ // catalog data, so a spread clone is safe and the shared registry entry is untouched.
94
+ const outputCap = config?.RESEARCHER_MAX_TOKENS ?? 16384;
95
+ const sessionModel = modelToUse.maxTokens > outputCap
96
+ ? { ...modelToUse, maxTokens: outputCap }
97
+ : modelToUse;
98
+
88
99
  try {
89
100
  const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
90
101
 
@@ -138,7 +149,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
138
149
  tools, // Explicit allowlist (BUG-1 fix)
139
150
  sessionManager: SessionManager.inMemory(), // Each researcher gets its own isolated session
140
151
  settingsManager: researcherSettings,
141
- model: modelToUse,
152
+ model: sessionModel,
142
153
  // pi 0.80.8 replaced the SDK's `modelRegistry`/`authStorage` session options
143
154
  // with the async `modelRuntime`. Pass the runtime backing OUR registry when
144
155
  // this run built one (standalone CLI/SDK): it carries any explicit API key
@@ -185,7 +196,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
185
196
 
186
197
  sessionRef.session = result.session;
187
198
 
188
- return { session: result.session, resolvedModel: modelToUse };
199
+ return { session: result.session, resolvedModel: sessionModel };
189
200
  } catch (tailError) {
190
201
  const session = result?.session;
191
202
  if (session && typeof session.abort === 'function') {