@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/README.md +7 -7
- package/dist/cli.mjs +105 -58
- package/dist/thread-worker.mjs +69 -31
- package/docs/ARCHITECTURE.md +9 -7
- package/docs/CHANGELOG.md +69 -0
- package/docs/CONFIGURATION.md +3 -1
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +5 -1
- package/src/config.ts +12 -0
- package/src/constants.ts +8 -17
- package/src/core/pi-version.ts +2 -2
- package/src/index.ts +5 -3
- package/src/infrastructure/browser/thread-worker.mjs +69 -31
- package/src/knowledge/embedder-utils.ts +19 -9
- package/src/orchestration/quick-research-orchestrator.ts +6 -6
- package/src/orchestration/researcher-executor.ts +8 -6
- package/src/orchestration/researcher.ts +13 -2
- package/src/utils/log-utils.ts +45 -13
- package/src/utils/user-agent.ts +26 -15
- package/src/web-research/scraper-utils.ts +70 -12
- package/src/web-research/web-scraper.ts +19 -9
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.
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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/
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* (`
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
-
|
|
207
|
-
? callerExclusions
|
|
208
|
-
: DEFAULT_EXCLUDED_TOOLS;
|
|
209
|
-
return [...new Set([...base, ...configDisabled])];
|
|
200
|
+
return [...new Set([...DEFAULT_EXCLUDED_TOOLS, ...(callerExclusions ?? []), ...configDisabled])];
|
|
210
201
|
}
|
package/src/core/pi-version.ts
CHANGED
|
@@ -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.
|
|
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:
|
|
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.
|
|
95
|
-
//
|
|
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
|
-
//
|
|
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+.-]
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
114
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
115
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
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/
|
|
118
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
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/
|
|
121
|
-
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
122
|
-
// Firefox on Windows
|
|
123
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:
|
|
124
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:
|
|
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:
|
|
127
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:
|
|
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
|
-
|
|
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
|
-
|
|
1623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1657
|
+
cachedSsrfFetcher = null;
|
|
1630
1658
|
}
|
|
1631
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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 {
|
|
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 } =
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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') {
|