@lincoln504/pi-research 1.7.1 → 1.7.3

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,40 @@ 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.7.3] - 2026-09-30
9
+
10
+ ### Changed
11
+
12
+ - **Quick (depth 0) research is now shaped like a quick answer, not a report.** Depth 0 is for a single verifiable fact, but it inherited the deep path's volume and framing: five search queries, eight URLs per scrape batch, and the deep `researcher.md` system prompt with two placeholders blanked — a prompt that asks for an exhaustive, critically-deconstructed report, advertises scrape rounds 2+ and a sibling "Session URL Pool" a single-pass run cannot have, and states that omitting information is a failure. Three changes, all scoped to quick runs (the pi extension tool, the CLI and the SDK's `--depth 0`; the deep path is untouched):
13
+ - `QUICK_MAX_QUERIES` default `5` → `3` (range unchanged, 1–10).
14
+ - New `QUICK_MAX_SCRAPE_URLS` (`PI_RESEARCH_QUICK_MAX_SCRAPE_URLS`, default `5`, range 1–20) caps the `scrape` tool's URLs per call for quick runs, plumbed through `createResearcherSession` → `createResearchTools` → `createScrapeTool` the way `maxSearchQueries` already was, so the advertised cap, the per-call slice and the over-cap report stay one number. `PI_RESEARCH_MAX_SCRAPE_URLS` (`8`) remains the deep cap, and `PI_RESEARCH_MAX_SCRAPE_BATCHES` still caps the batch count, so a quick run's scrape ceiling falls from 2×8 to 2×5 URLs.
15
+ - New `src/prompts/quick-researcher.md`: single-pass, answer-first, "stop once the answer is sourced", with every hard requirement the engine depends on kept intact (grounding, `[N]` citations, the `CITED LINKS` format `parseCitations` parses, the untrusted-content rule, and the `RESEARCHER_AGENT_MARKER` the host hook keys on to skip steering injection). The deep `researcher.md` is unchanged, so its byte-identical-across-researchers prompt-cache property is unaffected.
16
+ - **pi packages verified against 0.99.2; the floor moves `0.87.0` → `0.99.0` and the tested ceiling to 0.99.2.** `@earendil-works/pi-ai`, `pi-coding-agent` and `pi-tui` move `>=0.87.0 <1` → `>=0.99.0 <1`, and the lockfile resolves 0.99.2. (`@earendil-works/pi-server` was bumped to the same floor in the interim and then deleted outright later in this release — see below.) The floor moved because this release starts depending on a 0.99-only surface: tool `exposure` (see below) and `ToolDefinition.execute()`'s context widening to `ExtensionToolContext`. A pre-0.99 host does not fail a type check for either — it ignores an unknown `exposure` value and would declare a withdrawn tool to the model, which is exactly the class of silent breakage the floor exists to prevent. The upgrade's compile-time fallout was three direct `ToolDefinition.execute()` call sites (the `/research` and `/knowledge-store` command handlers, the SDK's `searchKnowledge`), which now view their plain context through `asToolExecContext()` (`src/utils/tool-exec-context.ts`, documented there: pi-research's tools never use `ctx.tools` or `ctx.executeTool()`). One runtime contract test caught a new host loader member (`loadExtensionPaths`, a private helper, added to `HOST_INTERNAL_HELPERS`). Full unit suite (3,176 tests) passes against 0.99.2.
17
+ - **The knowledge-search tool is withdrawn from the model's tool list while Knowledge Mode is `none`, using pi 0.99's `exposure`.** Previously `research_knowledge_search` was registered `direct` unconditionally, so a disabled store still showed the model a tool that could only miss; only its prompt block was gated. It is now re-registered with `exposure: 'hidden'` when the mode is `none` and back to `'direct'` when it is not (`syncKnowledgeSearchExposure` in `src/index.ts`, run at load, on `session_start`, and on each new prompt), so the toggle still applies from the next prompt without a pi restart and the disabled tool is genuinely undeclared. `pi.getAllTools()` reports the exposure; a hidden tool is neither declared to the model nor listed in the prompt.
18
+ - **`npm audit` production tree: the `brace-expansion` advisories are fixed by override, and the one copy no override can reach left the shipped tree.** `brace-expansion` `GHSA-q2hr-2g5m-vwhr` / `GHSA-qhr7-859c-m2p7` / `GHSA-6j4f-fj2g-mc7p` (brace-expansion ≤ 5.0.11, a CPU-DoS in glob brace expansion) is fixed by the new `overrides` entry `brace-expansion: ^5.0.12` everywhere in the tree EXCEPT the copy frozen inside the pi host's own `npm-shrinkwrap.json` (`@earendil-works/pi-coding-agent` → `minimatch@10.2.6` → `brace-expansion@5.0.9`), which no downstream override can reach. That single node was briefly allowlisted with the location-scoped exception shape in `config/tooling/audit-exceptions.json`; the peer-dependency change below removed the host's shrinkwrapped subtree from the shipped tree, so the entry is gone and the allowlist is empty again. A second new override, `fast-uri: ^3.1.8`, fixes `GHSA-hrr3-gc8f-f4qj` (dev-only, via `depngn` → `table` → `ajv`). `node scripts/audit-gate.cjs` is green with zero allowlisted advisories.
19
+ - **In-range dependency refresh:** `undici` 8.11.0 → 8.11.2, `vite` 8.3.0 → 8.3.1, `vitest` / `@vitest/coverage-v8` 5.0.1 → 5.0.3, `dependency-cruiser` 18.3.1 → 18.5.0, `@typescript-eslint/*` 8.70.1 → 8.71.0. The deliberate holds are unchanged and re-verified against the new tree: `apache-arrow` 21.1.0 (21.2.0 breaks LanceDB 0.39.0 IPC reads), `playwright-core` 1.60.0 (camoufox-js 0.12.0 peer cap `<1.61.0`), `typebox` 1.3.27 (lockstep with pi 0.99.2's own pin), `@huggingface/transformers` 4.2.0 (4.3.0 reshuffles the ONNX native stack for no needed gain), `typescript` 6.0.3.
20
+
21
+ - **CI action pins refreshed to the newest releases.** `actions/setup-node` v6.5.0 → v7.0.0, `actions/upload-artifact` v4.6.2 → v7.0.1, `actions/download-artifact` v6.0.0 → v8.0.1, `github/codeql-action` v4.37.7 → v4.38.2 (`init` and `analyze` kept on the same commit). `actions/checkout` (v7.0.1) and `actions/cache` (v6.1.0) were already the newest release. Every pin stays a full commit SHA with the version in a trailing comment.
22
+ - **The pinned-stack version claims in the docs were stale and are corrected.** `docs/ARCHITECTURE.md` still cited `typebox` `1.3.7` across pi's 0.84/0.85 lines and `@lancedb/lancedb` 0.38. `typebox` is `1.3.27`, the version `@earendil-works/pi-ai` and `@earendil-works/pi-coding-agent` 0.99.2 both declare (read from their installed `package.json`), and LanceDB is `0.39.0`. The Arrow hold was re-verified against 0.39.0 on 2026-10-01: `21.2.0` still fails every table open with `Failed to read IPC file: Arrow error: Parser error: Unable to get root as footer: RangeOutOfBounds … UnionVariant { variant: "Type::FixedSizeList" }`. The same three numbers were corrected in the ES/JA/ZH translations.
23
+
24
+ - **The `@earendil-works/pi-*` host packages are peer dependencies now, the unused `@earendil-works/pi-server` is gone, and the shipped tree audits clean with no allowlist.** pi's extension loader aliases every `@earendil-works/*` and `typebox` specifier to the HOST's own copies, so the copies inside a managed install are never loaded in-host — they existed only for the standalone CLI and SDK, at a cost of roughly 145MB per managed install (`pi-coding-agent` alone is ~440MB unpacked here). pi's own installer passes `--legacy-peer-deps` on purpose so host packages are not installed alongside. `@earendil-works/pi-ai`, `pi-coding-agent` and `pi-tui` therefore move `dependencies` → `peerDependencies` (`>=0.99.0 <1`), with matching `devDependencies` so this repo's own install, tests and CI still resolve them. Three consequences:
25
+ - The production tree no longer contains `pi-coding-agent`'s `npm-shrinkwrap.json` subtree, so the `brace-expansion` advisories it froze are no longer shipped and `config/tooling/audit-exceptions.json` is empty again (the gate prints a stale-entry warning if the entry is left in place). The full-tree audit still reports it, as the informational dev summary.
26
+ - `@earendil-works/pi-server` is removed. It was declared as a dependency but imported by nothing in `src/`, `dist/` or the agent skill.
27
+ - Standalone installs are unaffected on npm ≥ 7, which resolves peers automatically. An install that skips them (`--legacy-peer-deps`, yarn classic) is now caught: the agent-skill launcher's dependency preflight covers the PATH and `~/.pi/bin` engine locations too (it dereferences the bin symlink before deciding, and skips the check when the layout cannot be identified, so a working install is never failed), and the message plus the docs name the one-line fix. `test/unit/cli.test.ts` covers both the fail-closed path and the pass-through.
28
+
29
+ ### Fixed
30
+
31
+ - **The log-redaction JWT bounds are asserted directly instead of timed.** `redactSecrets` keeps every JWT segment quantifier bounded, because an unbounded `[A-Za-z0-9_-]+` consumed to the end of a dotless base64url run and backtracked one character at a time, and `_`/`-` count as non-alphanumeric left boundaries, so the same run offered a fresh start position every few characters. The guard was a wall-clock scaling ratio, and it failed CI twice: 3.12x against a 3.0 threshold on ubuntu/node 22, 3.47x on node 24 before that. The ratio is not measurable on this input. The bounded pattern costs about 0.004ms per call, so a doubling ratio was reading call overhead and regexp-cache warm-up rather than regex work, and V8's literal prefilter hides the pathology either way: an unbounded variant of the same pattern also scales linearly on `_eyJ` runs. The test now asserts the bound itself (`['{1,1024}', '{1,8192}', '{1,4096}']` on the exported `JWT_PATTERN`), which is deterministic and still fails if a bound is dropped.
32
+
33
+ - **Research runs no longer fail on models that cannot turn thinking off.** The engine's direct LLM calls (coordinator, lead router, synthesizer, JSON repair, knowledge-search extraction and triage) all go through `buildSafeOptions` (`src/core/llm/llm-utils.ts`), which sends thinking `off` by default (`PI_RESEARCH_LLM_THINKING_LEVEL`). Some models reject that: zai/glm-5.3, whose catalog entry marks `off` as unsupported, answers every such call with 400 "This model always engages in thinking and cannot be disabled; please use low, high, or max", so a depth 1–3 run failed at the coordinator's first plan (quick mode was unaffected: it runs as a pi agent session, which already clamps the level). `off` is now raised to the nearest level the model supports (pi-ai's `clampThinkingLevel`, `low` for glm-5.3) only when the model cannot disable thinking; every other model and every explicit level is sent exactly as before. With the fix, the same depth-1 run on glm-5.3 completes.
34
+
35
+ ## [1.7.2] - 2026-09-27
36
+
37
+ ### Changed
38
+
39
+ - **Scraped pages keep only the useful part of the converter's front matter.** The native converter turns every `<meta>` tag into a front-matter line: 54 lines on a GitHub page (analytics and error-reporting URLs, verification hashes, nonces, app ids), and on arXiv the abstract three times over. Nothing parses these lines; researchers read them and the knowledge store indexes them. `trimFrontMatter` (`src/web-research/scraper-utils.ts`) now keeps the title, description, author and publication/modification dates (first key present per slot: plain, Open Graph, citation or Dublin Core names) and drops the rest; a page with none of them loses the block. GitHub pages shrink by about 11%, arXiv abstract pages by about a third. Side effect: an empty page shell whose front matter alone used to pass the stub check is now treated as a stub, so the browser fallback gets a chance.
40
+ - **Scraped pages no longer carry empty same-page links.** Documentation generators such as Quarto and nbdev put an empty anchor before every code line (`<a href="#cb2-1"></a>`) and next to headings; the native HTML→Markdown converter turned each into `[](#cb2-1)`, so every code block in a research scrape (and in the knowledge store) started each line with one. Both converters now drop links that have no text and point to the same page (`cleanConvertedMarkdown` in `src/web-research/scraper-utils.ts`, run after the existing image-link stripping so heading anchors that wrap an icon — GitHub's `[![](link.svg)](#title)` — go too). Links with text, and empty links to other pages, are kept. On an nbdev documentation page (fastcore.fast.ai/docments.html) this removes 583 such links, 16% of the scraped text.
41
+
8
42
  ## [1.7.1] - 2026-09-27
9
43
 
10
44
  ### Changed
@@ -75,7 +75,8 @@ Research
75
75
  | `PI_RESEARCH_MAX_RESEARCHERS` (TUI) | `3` | 1–5 | Parallel researchers. |
76
76
  | `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` (TUI) `[project]` | `1` | 0–3 | Depth for `/research` and the CLI when `--depth` is omitted (0=quick single-pass — requires `PI_RESEARCH_QUICK_RESEARCH` —, 1=normal, 2=deep, 3=ultra). The agent-facing `research` tool-usage prompt renders its depth guidance relative to this setting too: when quick is the configured default, depth 0 is the ordinary choice; a user who keeps the default (1) gets byte-identical guidance. |
77
77
  | `PI_RESEARCH_QUICK_RESEARCH` (TUI) `[project]` | `false` | `true` · `false` | Opt-in gate for depth-0 (quick) research. When `false` (default), the agent-facing `research` tool keeps its upstream `minimum: 1` schema — agents can neither see nor send depth 0 — and a `DEFAULT_RESEARCH_DEPTH` of 0 resolves to 1. Toggling applies on the next session. |
78
- | `PI_RESEARCH_QUICK_MAX_QUERIES` | `5` | 1–10 | Max queries per `search` call in quick (depth 0) research. Applies to every quick run — the pi extension tool, the CLI, and the SDK's `--depth 0` — since they share the same researcher session factory. Deliberately below deep research's 30-query cap; the ceiling of 10 keeps quick mode from ever exceeding the old 5–10 prompt guidance. |
78
+ | `PI_RESEARCH_QUICK_MAX_QUERIES` | `3` | 1–10 | Max queries per `search` call in quick (depth 0) research. Applies to every quick run — the pi extension tool, the CLI, and the SDK's `--depth 0` — since they share the same researcher session factory. Deliberately below deep research's 30-query cap; the ceiling of 10 keeps quick mode from ever exceeding the old 5–10 prompt guidance. |
79
+ | `PI_RESEARCH_QUICK_MAX_SCRAPE_URLS` | `5` | 1–20 | Max URLs fetched per scrape batch in quick (depth 0) research. Separate from `PI_RESEARCH_MAX_SCRAPE_URLS` (deep, `8`) because quick mode answers a single-fact question and the deep budget pulls in pages that question does not need; `PI_RESEARCH_MAX_SCRAPE_BATCHES` still caps how many batches a quick run may use. Applies to every quick run (pi extension tool, CLI, SDK `--depth 0`). |
79
80
  | `PI_RESEARCH_MAX_SCRAPE_BATCHES` (TUI) | `2` | 0–99 | Scrape batches per researcher (0 = unlimited). When prompt caching is known to be active for the resolved research model (Anthropic API models, or a provider route explicitly configured for Anthropic-style cache control), the effective limit is this value plus one — the cached prompt prefix makes the extra batch cheap. |
80
81
  | `PI_RESEARCH_MAX_GATHERING_CALLS` | `12` | 1–100 | Shared web-gathering calls per researcher (`search` + `security_search` + `stackexchange` + `youtube_transcript`). |
81
82
  | `PI_RESEARCH_MAX_CONCURRENT_SCRAPES` | `3` | 1–20 | Concurrent URLs fetched per scrape batch. |
package/docs/SDK.md CHANGED
@@ -13,6 +13,14 @@ run the `pi` extension, which keeps its own private copy your scripts can't impo
13
13
  npm install @lincoln504/pi-research
14
14
  ```
15
15
 
16
+ The `@earendil-works/pi-*` host packages are peer dependencies, so npm ≥7 installs them
17
+ with the command above. If your install skips peers — `--legacy-peer-deps`, yarn
18
+ classic — the SDK cannot import them, so add the one it loads directly:
19
+
20
+ ```bash
21
+ npm install @earendil-works/pi-coding-agent
22
+ ```
23
+
16
24
  On npm ≥11.19 (and npm 12), dependency install scripts are skipped by default. Nothing
17
25
  here needs them: better-sqlite3 13 carries prebuilt bindings for every supported
18
26
  platform and loads them at runtime, and the stealth browser self-provisions on its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "description": "Web research for your agents with smart and safe tooling + knowledge store",
5
5
  "type": "module",
6
6
  "private": false,
@@ -115,10 +115,6 @@
115
115
  "image": "https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/hero.png"
116
116
  },
117
117
  "dependencies": {
118
- "@earendil-works/pi-ai": ">=0.87.0 <1",
119
- "@earendil-works/pi-coding-agent": ">=0.87.0 <1",
120
- "@earendil-works/pi-server": ">=0.87.0 <1",
121
- "@earendil-works/pi-tui": ">=0.87.0 <1",
122
118
  "@kreuzberg/html-to-markdown-node": "^3.7.2",
123
119
  "@lancedb/lancedb": "0.39.0",
124
120
  "apache-arrow": "21.1.0",
@@ -132,22 +128,29 @@
132
128
  "playwright-core": "1.60.0",
133
129
  "poolifier": "^5.3.2",
134
130
  "typebox": "1.3.27",
135
- "undici": "^8.10.2",
131
+ "undici": "^8.11.2",
136
132
  "youtubei.js": "^18.0.0"
137
133
  },
134
+ "peerDependencies": {
135
+ "@earendil-works/pi-ai": ">=0.99.0 <1",
136
+ "@earendil-works/pi-coding-agent": ">=0.99.0 <1",
137
+ "@earendil-works/pi-tui": ">=0.99.0 <1"
138
+ },
138
139
  "optionalDependencies": {
139
140
  "@huggingface/transformers": "4.2.0",
140
141
  "webgpu": "^0.6.0"
141
142
  },
142
143
  "overrides": {
143
144
  "lodash.isequal": "npm:fast-deep-equal@^3.1.3",
145
+ "brace-expansion": "^5.0.12",
146
+ "fast-uri": "^3.1.8",
144
147
  "ip-address": "^10.2.0",
145
148
  "apache-arrow": "21.1.0",
146
149
  "onnxruntime-web": "1.24.3",
147
150
  "node-domexception": "npm:@nolyfill/domexception@^1.0.28",
148
151
  "ua-parser-js": "^1.0.41",
149
152
  "protobufjs": "7.6.5",
150
- "vite": "^8.2.2",
153
+ "vite": "^8.3.1",
151
154
  "adm-zip": "^0.6.1",
152
155
  "jsdom": {
153
156
  "undici": "^8.10.2"
@@ -155,17 +158,20 @@
155
158
  "sharp": "^0.35.0"
156
159
  },
157
160
  "devDependencies": {
161
+ "@earendil-works/pi-ai": ">=0.99.0 <1",
162
+ "@earendil-works/pi-coding-agent": ">=0.99.0 <1",
163
+ "@earendil-works/pi-tui": ">=0.99.0 <1",
158
164
  "@eslint/js": "^10.0.1",
159
165
  "@types/jsdom": "^30.0.0",
160
- "@typescript-eslint/eslint-plugin": "8.70.1",
161
- "@typescript-eslint/parser": "8.70.1",
162
- "@vitest/coverage-v8": "^5.0.1",
163
- "dependency-cruiser": "^18.2.0",
166
+ "@typescript-eslint/eslint-plugin": "8.71.0",
167
+ "@typescript-eslint/parser": "8.71.0",
168
+ "@vitest/coverage-v8": "^5.0.3",
169
+ "dependency-cruiser": "^18.5.0",
164
170
  "depngn": "^0.6.0",
165
171
  "eslint": "10.11.0",
166
172
  "npm-check-updates": "^23.1.0",
167
173
  "typescript": ">=6.0.3 <6.1.0",
168
- "vite": "^8.2.2",
169
- "vitest": "^5.0.1"
174
+ "vite": "^8.3.1",
175
+ "vitest": "^5.0.3"
170
176
  }
171
177
  }
package/src/config.ts CHANGED
@@ -96,12 +96,19 @@ export const ConfigSchema = Type.Object({
96
96
  * (default: 5; 1 = every query). Configured via env/config file; not surfaced
97
97
  * in the config TUI. */
98
98
  YOUTUBE_QUERY_EVERY_N: Type.Number({ minimum: 1, maximum: 100, default: 5 }),
99
- /** Max queries per `search` call in quick (depth 0) research (default: 5, range: 1-10).
99
+ /** Max queries per `search` call in quick (depth 0) research (default: 3, range: 1-10).
100
100
  * Applies to every quick run — the pi extension tool, the CLI, and the SDK's
101
101
  * `--depth 0` — since all three share the same researcher session factory. The
102
102
  * ceiling is 10 so quick mode can never exceed the old 5–10 prompt guidance.
103
103
  * Configured via env/config file; not surfaced in the config TUI. */
104
- QUICK_MAX_QUERIES: Type.Number({ minimum: 1, maximum: 10, default: 5 }),
104
+ QUICK_MAX_QUERIES: Type.Number({ minimum: 1, maximum: 10, default: 3 }),
105
+ /** Max URLs scraped per `scrape` call in quick (depth 0) research (default: 5,
106
+ * range: 1-20). Quick mode is for single-fact lookups, where the deep default
107
+ * (MAX_SCRAPE_URLS, 8) pulls in pages the question does not need; its per-batch
108
+ * budget is therefore separate. Batches are still capped by MAX_SCRAPE_BATCHES.
109
+ * Applies to every quick run (pi extension tool, CLI, SDK `--depth 0`).
110
+ * Configured via env/config file; not surfaced in the config TUI. */
111
+ QUICK_MAX_SCRAPE_URLS: Type.Number({ minimum: 1, maximum: 20, default: 5 }),
105
112
  /** Health check timeout in milliseconds (default: 10000ms) */
106
113
  HEALTH_CHECK_TIMEOUT_MS: Type.Number({ minimum: 2000, maximum: 120000, default: 10000 }),
107
114
  /** Default timeout for browser page operations like search (default: 45000ms) */
@@ -282,6 +289,7 @@ const USER_MIGRATION_KEYS = [
282
289
  'PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG',
283
290
  'PI_RESEARCH_YOUTUBE_QUERY_EVERY_N',
284
291
  'PI_RESEARCH_QUICK_MAX_QUERIES',
292
+ 'PI_RESEARCH_QUICK_MAX_SCRAPE_URLS',
285
293
  'PI_RESEARCH_CONSOLE_LOG',
286
294
  'PI_RESEARCH_MODEL',
287
295
  'PI_RESEARCH_KNOWLEDGE_DIR',
@@ -970,6 +978,7 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
970
978
  PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG: config.YOUTUBE_TRANSCRIPT_LANG,
971
979
  PI_RESEARCH_YOUTUBE_QUERY_EVERY_N: String(config.YOUTUBE_QUERY_EVERY_N),
972
980
  PI_RESEARCH_QUICK_MAX_QUERIES: String(config.QUICK_MAX_QUERIES),
981
+ PI_RESEARCH_QUICK_MAX_SCRAPE_URLS: String(config.QUICK_MAX_SCRAPE_URLS),
973
982
  PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS: String(config.BROWSER_TASK_TIMEOUT_MS),
974
983
  PI_RESEARCH_LLM_TIMEOUT_MS: String(config.LLM_TIMEOUT_MS),
975
984
  PI_RESEARCH_LLM_THINKING_LEVEL: config.LLM_THINKING_LEVEL,
@@ -1202,6 +1211,7 @@ export function createConfig(env: Record<string, string | undefined>, processEnv
1202
1211
  YOUTUBE_TRANSCRIPT_LANG: parseEnvString(e, 'PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG', DEFAULTS.YOUTUBE_TRANSCRIPT_LANG)!,
1203
1212
  YOUTUBE_QUERY_EVERY_N: parseEnvNumber(e, 'PI_RESEARCH_YOUTUBE_QUERY_EVERY_N', DEFAULTS.YOUTUBE_QUERY_EVERY_N, 1, 100, true),
1204
1213
  QUICK_MAX_QUERIES: parseEnvNumber(e, 'PI_RESEARCH_QUICK_MAX_QUERIES', DEFAULTS.QUICK_MAX_QUERIES, 1, 10, true),
1214
+ QUICK_MAX_SCRAPE_URLS: parseEnvNumber(e, 'PI_RESEARCH_QUICK_MAX_SCRAPE_URLS', DEFAULTS.QUICK_MAX_SCRAPE_URLS, 1, 20, true),
1205
1215
  HEALTH_CHECK_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS', DEFAULTS.HEALTH_CHECK_TIMEOUT_MS, 2000, 120000),
1206
1216
  SEARCH_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_SEARCH_TIMEOUT_MS', DEFAULTS.SEARCH_TIMEOUT_MS, 5000, 120000),
1207
1217
  TUI_REFRESH_DEBOUNCE_MS: parseEnvNumber(e, 'PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS', DEFAULTS.TUI_REFRESH_DEBOUNCE_MS, 0, 1000),
@@ -4,7 +4,7 @@
4
4
  * Shared logic for safe and robust LLM interactions.
5
5
  */
6
6
 
7
- import { type Model, type AssistantMessage, type SimpleStreamOptions, type ModelThinkingLevel, type Tool, type TSchema, type Context, type ToolCall } from '@earendil-works/pi-ai';
7
+ import { clampThinkingLevel, type Model, type AssistantMessage, type SimpleStreamOptions, type ModelThinkingLevel, type Tool, type TSchema, type Context, type ToolCall } from '@earendil-works/pi-ai';
8
8
  import { extractText } from '../../utils/text-utils.ts';
9
9
  import { logger } from '../../logger.ts';
10
10
  import { completeSimple } from './pi-ai-completion.ts';
@@ -20,6 +20,10 @@ import { completeSimple } from './pi-ai-completion.ts';
20
20
  * 'off' is deliberate: these engine calls emit structured JSON / cited reports, so a
21
21
  * thinking block only consumes the output-token budget (often truncating before the
22
22
  * text block is emitted) for at most a marginal quality gain that does not justify the cost.
23
+ * Some models cannot turn thinking off (their `thinkingLevelMap` marks `off` as
24
+ * unsupported, e.g. zai/glm-5.3, which rejects the request with a 400); for those
25
+ * 'off' becomes the nearest level the model supports (pi-ai's clampThinkingLevel,
26
+ * the same rule pi's own agent sessions apply). Other levels pass through unchanged.
23
27
  *
24
28
  * Callers should also pass the research `sessionId` in `options`. pi-ai forwards it as the
25
29
  * provider's prompt-cache / session-affinity key — OpenAI's `prompt_cache_key`, OpenRouter's
@@ -40,7 +44,8 @@ export function buildSafeOptions(
40
44
  defaultCap: number = 4096,
41
45
  thinkingLevel: ModelThinkingLevel = 'off'
42
46
  ): SimpleStreamOptions {
43
- const effective: ModelThinkingLevel = (options.reasoning as ModelThinkingLevel | undefined) ?? thinkingLevel;
47
+ const requested: ModelThinkingLevel = (options.reasoning as ModelThinkingLevel | undefined) ?? thinkingLevel;
48
+ const effective: ModelThinkingLevel = requested === 'off' ? clampThinkingLevel(model, 'off') : requested;
44
49
  return {
45
50
  ...options,
46
51
  // Ensure maxTokens is never null/None to avoid provider-side crashes
@@ -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.87.0 <1` and published tarballs
10
+ * - As a standalone CLI/SDK, the range is `>=0.99.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,8 +83,19 @@ export function compareVersions(a: SemverParts, b: SemverParts): number {
83
83
  * finishTurn boundary. The SDK paths (session service, model-registry factory)
84
84
  * are written against that surface; an older host may resolve but is not a
85
85
  * tested or supported target.
86
+ *
87
+ * 0.99.0 added tool `exposure` (including `hidden`, which is how a tool is
88
+ * withdrawn when tools cannot be unregistered) and widened
89
+ * `ToolDefinition.execute()`'s context from `ExtensionContext` to
90
+ * `ExtensionToolContext` (adds `tools` and `executeTool()`). pi-research uses
91
+ * `exposure` to keep an optional tool out of the model's tool list without a
92
+ * restart, and adapts plain contexts with `asToolExecContext()` at the three
93
+ * places it invokes its own tools directly (see utils/tool-exec-context.ts).
94
+ * Relying on either on an older host is a silent misbehaviour rather than a
95
+ * type error — a pre-0.99 host ignores `exposure: hidden` and would declare a
96
+ * withdrawn tool to the model — so the floor moved with the adoption.
86
97
  */
87
- export const PI_MIN_VERSION: SemverParts = { major: 0, minor: 87, patch: 0 };
98
+ export const PI_MIN_VERSION: SemverParts = { major: 0, minor: 99, patch: 0 };
88
99
 
89
100
  /**
90
101
  * Newest host line this release was actually exercised against (CI + a real run).
@@ -92,7 +103,7 @@ export const PI_MIN_VERSION: SemverParts = { major: 0, minor: 87, patch: 0 };
92
103
  * Compared on MAJOR.MINOR only: a patch bump within a tested line is not a new
93
104
  * surface, and warning on it would be noise.
94
105
  */
95
- export const PI_TESTED_MAX_VERSION: SemverParts = { major: 0, minor: 87, patch: 1 };
106
+ export const PI_TESTED_MAX_VERSION: SemverParts = { major: 0, minor: 99, patch: 2 };
96
107
 
97
108
  export type PiCompatibilityLevel = 'ok' | 'unparseable' | 'too-old' | 'untested';
98
109
 
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { logger } from './logger.ts';
14
14
  import { checkPiCompatibility } from './core/pi-version.ts';
15
15
  import { randomUUID } from 'node:crypto';
16
16
  import { shutdownManager } from './utils/shutdown-manager.ts';
17
+ import { asToolExecContext } from './utils/tool-exec-context.ts';
17
18
  import { healthRegistry } from './healthcheck/index.ts';
18
19
  import { registerBeforeExitSafetyNet } from './knowledge/embedder-utils.ts';
19
20
  import { getConfig, validateConfig } from './config.ts';
@@ -92,11 +93,12 @@ function extractResultText(result: AgentToolResult<unknown>): string {
92
93
  * Pi Research Extension
93
94
  */
94
95
  export default async function (pi: ExtensionAPI) {
95
- // Runtime version check — must match the @earendil-works/* dependency minimum (>=0.87.0).
96
+ // Runtime version check — must match the @earendil-works/* dependency minimum (>=0.99.0).
96
97
  // The floor was raised to 0.84.0 because setRuntimeApiKey() lost its `allowNetwork` option
97
98
  // there and only 0.84.0+ hardcodes the equivalent guard internally (see pi-version.ts), then
98
99
  // to 0.85.0 to track the pi extension package line, and to 0.87.0 for the 0.86
99
- // TranscriptContext / 0.87 SessionEntry-union surface (see pi-version.ts). The
100
+ // TranscriptContext / 0.87 SessionEntry-union surface, and to 0.99.0 for tool `exposure`
101
+ // and the ExtensionToolContext widening of ToolDefinition.execute() (see pi-version.ts). The
100
102
  // APIs it rests on arrived in 0.80.8, which introduced ModelRuntime and removed
101
103
  // AuthStorage/ModelRegistry.create(); buildModelRegistry (model-registry-factory.ts)
102
104
  // unconditionally calls ModelRuntime.create(), and createAgentSession() is invoked
@@ -127,6 +129,12 @@ export default async function (pi: ExtensionAPI) {
127
129
  // 1. REGISTER CRITICAL EVENT LISTENERS IMMEDIATELY
128
130
  // This ensures we capture steering even if initialization takes time.
129
131
  pi.on('input', async (event: any, ctx: ExtensionContext) => {
132
+ // A new prompt (not steering typed while the agent runs): re-align the
133
+ // knowledge-search tool's exposure with live config before pi builds this
134
+ // turn's prompt, so a /research-config Knowledge Mode change applies without
135
+ // a pi restart. Registration happens before this closure runs (see
136
+ // syncKnowledgeSearchExposure below).
137
+ if (!event.streamingBehavior) syncKnowledgeSearchExposure(ctx);
130
138
  // Only intercept genuine interactive keystrokes. Programmatic sends originate
131
139
  // from this extension (source 'extension') — e.g. the Alt+P pop handler forwards
132
140
  // a popped message as 'steer'. Without this guard that forward would be captured
@@ -344,13 +352,34 @@ export default async function (pi: ExtensionAPI) {
344
352
 
345
353
  // Create and register the Research Knowledge Search tool. It is native-free at
346
354
  // construction (the vector/ML stack loads lazily on first execute) and self-guards when
347
- // Knowledge Mode is 'none' (its execute returns a "store disabled" miss). Registering it
348
- // unconditionally lets a Knowledge Mode change via /research-config take effect without a
349
- // Pi restart — availability to the agent and the /knowledge-store command below both key
350
- // off live config, not this startup binding. The pi API has no unregisterTool, so a
351
- // startup-gated registration could never be added back after enabling mid-session anyway.
355
+ // Knowledge Mode is 'none' (its execute returns a "store disabled" miss).
356
+ //
357
+ // pi 0.99 exposes tool `exposure`, which is how this tool is keyed to LIVE
358
+ // Knowledge Mode: while the mode is 'none' it is re-registered as `hidden`, so
359
+ // it is neither declared to the model nor listed in the prompt, and a mode
360
+ // change via /research-config takes effect on the next prompt without a pi
361
+ // restart (pi has no unregisterTool; re-registering with `exposure: 'hidden'`
362
+ // is the documented withdrawal, and a later re-register back to `direct`
363
+ // activates the tool like a new one). The tool object itself is kept as the
364
+ // base so the /knowledge-store command below can still invoke it directly.
352
365
  const researchKnowledgeSearchTool: ToolDefinition = createResearchKnowledgeSearchTool('pi');
353
- pi.registerTool(researchKnowledgeSearchTool);
366
+ let knowledgeSearchExposure: 'direct' | 'hidden' | undefined;
367
+ const syncKnowledgeSearchExposure = (ctx?: ExtensionContext): void => {
368
+ try {
369
+ const mode = getConfig(ctx?.cwd ?? (pi as any).cwd, 'pi').KNOWLEDGE_STORE_MODE;
370
+ const want: 'direct' | 'hidden' = mode === 'none' ? 'hidden' : 'direct';
371
+ if (want === knowledgeSearchExposure) return;
372
+ pi.registerTool({ ...researchKnowledgeSearchTool, exposure: want });
373
+ knowledgeSearchExposure = want;
374
+ logger.debug(`[pi-research] research_knowledge_search exposure → ${want}`);
375
+ } catch (err) {
376
+ logger.debug('[pi-research] could not sync research_knowledge_search exposure:', err);
377
+ }
378
+ };
379
+ syncKnowledgeSearchExposure();
380
+ pi.on('session_start', async (_event: any, ctx: ExtensionContext) => {
381
+ syncKnowledgeSearchExposure(ctx);
382
+ });
354
383
 
355
384
  // Alt+P — Pop queued steering messages out of the research queue and steer pi with them.
356
385
  pi.registerShortcut(Key.alt('p'), {
@@ -435,7 +464,7 @@ export default async function (pi: ExtensionAPI) {
435
464
  { query: text, depth: config.DEFAULT_RESEARCH_DEPTH },
436
465
  ctx.signal,
437
466
  undefined,
438
- ctx,
467
+ asToolExecContext(ctx),
439
468
  );
440
469
 
441
470
  const output = extractResultText(result);
@@ -525,7 +554,7 @@ export default async function (pi: ExtensionAPI) {
525
554
  { queries: [text] },
526
555
  ctx.signal,
527
556
  undefined,
528
- ctx,
557
+ asToolExecContext(ctx),
529
558
  );
530
559
 
531
560
  const output = extractResultText(result);
@@ -149,7 +149,7 @@ var init_user_agent = __esm({
149
149
  });
150
150
 
151
151
  // src/web-research/scraper-types.ts
152
- var MAX_HTML_SIZE, MAX_PDF_SIZE, INTERNAL_NETWORK_PATTERNS, FILTERED_TAGS, BOT_PATTERNS, IMAGE_LINK_PATTERN, MARKDOWN_IMAGE_PATTERN;
152
+ var MAX_HTML_SIZE, MAX_PDF_SIZE, INTERNAL_NETWORK_PATTERNS, FILTERED_TAGS, BOT_PATTERNS, IMAGE_LINK_PATTERN, MARKDOWN_IMAGE_PATTERN, EMPTY_FRAGMENT_LINK_PATTERN;
153
153
  var init_scraper_types = __esm({
154
154
  "src/web-research/scraper-types.ts"() {
155
155
  "use strict";
@@ -215,6 +215,7 @@ var init_scraper_types = __esm({
215
215
  ];
216
216
  IMAGE_LINK_PATTERN = /\[([^\]]*)\]\((data:image\/[^)]+|[^)\s]+\.(?:svg|png|jpe?g|gif|webp|bmp|ico)(?:\?[^)]*)?)\)/gi;
217
217
  MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((?:data:image\/[^)]+|[^)\s]+)\)/gi;
218
+ EMPTY_FRAGMENT_LINK_PATTERN = /\[\]\(#[^)\s]*(?:\s+"[^"\n]*")?\)/g;
218
219
  }
219
220
  });
220
221
 
@@ -1559,6 +1560,7 @@ var init_browser_error_utils = __esm({
1559
1560
  // src/web-research/scraper-utils.ts
1560
1561
  var scraper_utils_exports = {};
1561
1562
  __export(scraper_utils_exports, {
1563
+ cleanConvertedMarkdown: () => cleanConvertedMarkdown,
1562
1564
  createJsMarkdownConverter: () => createJsMarkdownConverter,
1563
1565
  createNativeMarkdownConverter: () => createNativeMarkdownConverter,
1564
1566
  disposeSsrfSafeFetcher: () => disposeSsrfSafeFetcher,
@@ -1569,7 +1571,9 @@ __export(scraper_utils_exports, {
1569
1571
  isBenignScrapeFailure: () => isBenignScrapeFailure,
1570
1572
  isSsrfBlockError: () => isSsrfBlockError,
1571
1573
  ssrfSafeLookup: () => ssrfSafeLookup,
1574
+ stripEmptyFragmentLinks: () => stripEmptyFragmentLinks,
1572
1575
  stripImageLinks: () => stripImageLinks,
1576
+ trimFrontMatter: () => trimFrontMatter,
1573
1577
  validateContent: () => validateContent,
1574
1578
  validateUrlForSSRF: () => validateUrlForSSRF,
1575
1579
  validateUrlForSSRFSync: () => validateUrlForSSRFSync
@@ -1785,6 +1789,34 @@ function isMappedLoopback(ip) {
1785
1789
  const mapped = extractMappedIpv4(ip.toLowerCase().replace(/^\[|\]$/g, ""));
1786
1790
  return mapped !== null && /^127\./.test(mapped);
1787
1791
  }
1792
+ function stripEmptyFragmentLinks(markdown) {
1793
+ return markdown.replace(EMPTY_FRAGMENT_LINK_PATTERN, "");
1794
+ }
1795
+ function trimFrontMatter(markdown) {
1796
+ if (!markdown.startsWith("---\n")) return markdown;
1797
+ const close = markdown.indexOf("\n---\n", 3);
1798
+ if (close === -1 || close > MAX_FRONT_MATTER_CHARS) return markdown;
1799
+ const entries = [];
1800
+ for (const line of markdown.slice(4, close).split("\n")) {
1801
+ const m = FRONT_MATTER_KEY.exec(line);
1802
+ if (m) entries.push({ key: m[1].toLowerCase(), lines: [line] });
1803
+ else if (entries.length) entries[entries.length - 1].lines.push(line);
1804
+ }
1805
+ const kept = [];
1806
+ for (const slot of FRONT_MATTER_SLOTS) {
1807
+ const entry = slot.map((key) => entries.find((e) => e.key === key)).find((e) => e !== void 0);
1808
+ if (entry) kept.push(...entry.lines);
1809
+ }
1810
+ const rest = markdown.slice(close + 5).replace(/^\n+/, "");
1811
+ return kept.length ? `---
1812
+ ${kept.join("\n")}
1813
+ ---
1814
+
1815
+ ${rest}` : rest;
1816
+ }
1817
+ function cleanConvertedMarkdown(markdown) {
1818
+ return stripEmptyFragmentLinks(stripImageLinks(trimFrontMatter(markdown))).replace(/\n{3,}/g, "\n\n").trim();
1819
+ }
1788
1820
  function stripImageLinks(markdown) {
1789
1821
  return markdown.replace(MARKDOWN_IMAGE_PATTERN, "").replace(IMAGE_LINK_PATTERN, "$1").replace(/\n{3,}/g, "\n\n").trim();
1790
1822
  }
@@ -1798,7 +1830,7 @@ function createNativeMarkdownConverter(nativeModule) {
1798
1830
  codeBlockStyle: nativeModule.CodeBlockStyle.Backticks,
1799
1831
  wrap: false
1800
1832
  });
1801
- return stripImageLinks(result.content ?? "");
1833
+ return cleanConvertedMarkdown(result.content ?? "");
1802
1834
  };
1803
1835
  }
1804
1836
  function createJsMarkdownConverter() {
@@ -1809,7 +1841,7 @@ function createJsMarkdownConverter() {
1809
1841
  });
1810
1842
  return async (html) => {
1811
1843
  const markdown = converter.translate(html);
1812
- return stripImageLinks(markdown);
1844
+ return cleanConvertedMarkdown(markdown);
1813
1845
  };
1814
1846
  }
1815
1847
  function validateContent(html, markdown, url) {
@@ -1848,7 +1880,7 @@ function validateContent(html, markdown, url) {
1848
1880
  throw error;
1849
1881
  }
1850
1882
  }
1851
- var cachedSsrfFetcher;
1883
+ var cachedSsrfFetcher, FRONT_MATTER_SLOTS, MAX_FRONT_MATTER_CHARS, FRONT_MATTER_KEY;
1852
1884
  var init_scraper_utils = __esm({
1853
1885
  "src/web-research/scraper-utils.ts"() {
1854
1886
  "use strict";
@@ -1858,6 +1890,23 @@ var init_scraper_utils = __esm({
1858
1890
  init_scraper_types();
1859
1891
  init_browser_error_utils();
1860
1892
  cachedSsrfFetcher = void 0;
1893
+ FRONT_MATTER_SLOTS = [
1894
+ ["title", "meta-og:title", "meta-citation_title"],
1895
+ ["meta-description", "description", "meta-og:description", "meta-twitter:description"],
1896
+ ["meta-author", "meta-article:author", "meta-citation_author", "meta-dc.creator", "meta-dcterms.creator"],
1897
+ [
1898
+ "meta-article:published_time",
1899
+ "meta-citation_publication_date",
1900
+ "meta-citation_date",
1901
+ "meta-dcterms.date",
1902
+ "meta-dc.date",
1903
+ "meta-date",
1904
+ "meta-dcterms.created"
1905
+ ],
1906
+ ["meta-article:modified_time", "meta-dcterms.modified"]
1907
+ ];
1908
+ MAX_FRONT_MATTER_CHARS = 2e4;
1909
+ FRONT_MATTER_KEY = /^([\w.:-]+):(?:\s|$)/;
1861
1910
  }
1862
1911
  });
1863
1912
 
@@ -2745,12 +2794,19 @@ var ConfigSchema = Type.Object({
2745
2794
  * (default: 5; 1 = every query). Configured via env/config file; not surfaced
2746
2795
  * in the config TUI. */
2747
2796
  YOUTUBE_QUERY_EVERY_N: Type.Number({ minimum: 1, maximum: 100, default: 5 }),
2748
- /** Max queries per `search` call in quick (depth 0) research (default: 5, range: 1-10).
2797
+ /** Max queries per `search` call in quick (depth 0) research (default: 3, range: 1-10).
2749
2798
  * Applies to every quick run — the pi extension tool, the CLI, and the SDK's
2750
2799
  * `--depth 0` — since all three share the same researcher session factory. The
2751
2800
  * ceiling is 10 so quick mode can never exceed the old 5–10 prompt guidance.
2752
2801
  * Configured via env/config file; not surfaced in the config TUI. */
2753
- QUICK_MAX_QUERIES: Type.Number({ minimum: 1, maximum: 10, default: 5 }),
2802
+ QUICK_MAX_QUERIES: Type.Number({ minimum: 1, maximum: 10, default: 3 }),
2803
+ /** Max URLs scraped per `scrape` call in quick (depth 0) research (default: 5,
2804
+ * range: 1-20). Quick mode is for single-fact lookups, where the deep default
2805
+ * (MAX_SCRAPE_URLS, 8) pulls in pages the question does not need; its per-batch
2806
+ * budget is therefore separate. Batches are still capped by MAX_SCRAPE_BATCHES.
2807
+ * Applies to every quick run (pi extension tool, CLI, SDK `--depth 0`).
2808
+ * Configured via env/config file; not surfaced in the config TUI. */
2809
+ QUICK_MAX_SCRAPE_URLS: Type.Number({ minimum: 1, maximum: 20, default: 5 }),
2754
2810
  /** Health check timeout in milliseconds (default: 10000ms) */
2755
2811
  HEALTH_CHECK_TIMEOUT_MS: Type.Number({ minimum: 2e3, maximum: 12e4, default: 1e4 }),
2756
2812
  /** Default timeout for browser page operations like search (default: 45000ms) */
@@ -142,7 +142,14 @@ export class QuickResearchOrchestrator {
142
142
  }
143
143
  }
144
144
 
145
- const researcherPromptTemplate = loadPrompt('researcher');
145
+ // Quick mode has its own, much shorter system prompt. The deep researcher
146
+ // prompt is written for a multi-round, sibling-coordinated, "exhaustive
147
+ // detail" report; reusing it here (with two placeholders blanked) fought
148
+ // every one of quick mode's design goals: it asked for a report where the
149
+ // deliverable is an answer, it advertised scrape rounds 2+ and a Session URL
150
+ // Pool quick runs cannot have, and "omitting information is a failure"
151
+ // pushed volume on a single-fact lookup.
152
+ const researcherPromptTemplate = loadPrompt('quick-researcher');
146
153
  // Resolve the model here (same deterministic inputs createResearcherSession
147
154
  // below will resolve again) purely to learn whether prompt caching will be
148
155
  // active, so the batch count told to the model in the prompt below matches
@@ -155,6 +162,10 @@ export class QuickResearchOrchestrator {
155
162
  const maxScrapeBatchesDisplay = maxScrapeBatches > 99 ? 'unlimited' : maxScrapeBatches.toString();
156
163
 
157
164
  const maxQueries = this.config.QUICK_MAX_QUERIES;
165
+ // The quick per-batch URL budget: the same value createResearcherSession
166
+ // hands the scrape tool below, so the number the model reads is the number
167
+ // the tool enforces.
168
+ const maxScrapeUrls = this.config.QUICK_MAX_SCRAPE_URLS;
158
169
  const quickEvidenceSection =
159
170
  '## Search\n' +
160
171
  `You have access to the \`search\` tool. You get EXACTLY ONE search call — make it count.\n` +
@@ -163,8 +174,8 @@ export class QuickResearchOrchestrator {
163
174
  `For roughly one in ${this.config.YOUTUBE_QUERY_EVERY_N} of your queries, append the word 'youtube' (e.g. "<topic> explained youtube") — DuckDuckGo rarely surfaces YouTube otherwise, and YouTube links let you read video transcripts.\n` +
164
175
  'Your goal is to gather a focused, high-quality pool of initial links.\n\n' +
165
176
  '## Scrape\n' +
166
- `After searching, scrape the best sources using the \`scrape\` tool (up to ${maxScrapeBatchesDisplay} batches, up to ${this.config.MAX_SCRAPE_URLS} URLs each).\n` +
167
- 'Prioritize primary sources and authoritative data.';
177
+ `After searching, scrape the sources that can answer the question using the \`scrape\` tool (up to ${maxScrapeBatchesDisplay} batches, up to ${maxScrapeUrls} URLs each).\n` +
178
+ 'Prioritize primary sources and authoritative data. Stop once the answer is sourced.';
168
179
 
169
180
  // Consume queued steering messages before starting
170
181
  consumeQueuedMessages(this.options.sessionId);
@@ -187,14 +198,11 @@ export class QuickResearchOrchestrator {
187
198
  // prefix providers key on, so back-to-back quick runs (and every follow-up
188
199
  // turn within this session) reuse it via prompt-cache reads.
189
200
  const prompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
190
- .replace('{{extra_tool_guidelines}}', '- `search`: Perform broad web searches (Round 1 only).')
201
+ .replace('{{extra_tool_guidelines}}', '- `search`: one web search call, executed up front.')
191
202
  // Config-driven, mirroring researcher-executor: the prompt's per-batch
192
- // target can never drift from the cap the scrape tool enforces.
203
+ // cap can never drift from the cap the scrape tool enforces.
193
204
  // replaceAll — the template states the budget more than once.
194
- .replaceAll('{{max_scrape_urls}}', String(this.config.MAX_SCRAPE_URLS))
195
- // Quick research has no research lead and no next round — see
196
- // RESEARCHER_DIGEST_SECTION. Its report IS the deliverable.
197
- .replace('{{digest_section}}', '')
205
+ .replaceAll('{{max_scrape_urls}}', String(maxScrapeUrls))
198
206
  .trim();
199
207
 
200
208
  // `$`-bearing dynamic content (the user query, store descriptions, steering)
@@ -219,6 +227,7 @@ export class QuickResearchOrchestrator {
219
227
  excludeTools: resolveExcludedTools(this.options.excludeTools),
220
228
  config: this.config,
221
229
  maxSearchQueries: this.config.QUICK_MAX_QUERIES,
230
+ maxScrapeUrls: this.config.QUICK_MAX_SCRAPE_URLS,
222
231
  getGlobalState: (): SystemResearchState => ({
223
232
  version: 1,
224
233
  researchId: this.options.researchId,
@@ -45,6 +45,8 @@ export interface CreateResearcherSessionOptions {
45
45
  config?: Config;
46
46
  /** Overrides the search tool's query cap (default 30). Quick research passes QUICK_MAX_QUERIES. */
47
47
  maxSearchQueries?: number;
48
+ /** Overrides the scrape tool's per-call URL cap (default MAX_SCRAPE_URLS). Quick research passes QUICK_MAX_SCRAPE_URLS. */
49
+ maxScrapeUrls?: number;
48
50
  }
49
51
 
50
52
  export interface ResolvedResearcherSession {
@@ -68,6 +70,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
68
70
  excludeTools = [],
69
71
  config,
70
72
  maxSearchQueries,
73
+ maxScrapeUrls,
71
74
  } = options;
72
75
 
73
76
  if (!systemPrompt || typeof systemPrompt !== 'string') {
@@ -104,6 +107,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
104
107
  onSearchProgress: onSearchProgress,
105
108
  onUrlScrapeResult: onUrlScrapeResult,
106
109
  maxSearchQueries,
110
+ maxScrapeUrls,
107
111
  getTokensUsed: () => {
108
112
  if (!sessionRef.session) return 0;
109
113