@lincoln504/pi-research 1.3.9 → 1.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,70 @@ 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.11] - 2026-08-14
9
+
10
+ Alongside the revert below, a four-way independent audit (docs-vs-code
11
+ consistency, orchestration/TUI/citations, the knowledge store, and CLI/config
12
+ precedence) swept the areas least recently re-checked, and an independent
13
+ fresh-eyes review of the resulting delta caught two follow-on defects (fixed
14
+ below). Regression tests cover the highest-severity fixes — the `--config`
15
+ credential drop (mutation-verified), the revalidation touch, the tool-name and
16
+ initial-links validation, the exit-78 message split, and the citation marker —
17
+ plus live end-to-end verification of the `--config` and validation behavior;
18
+ the remaining fixes are covered by the existing suites they run under.
19
+
20
+ ### Fixed
21
+
22
+ - **`--config` silently dropped credentials stored in the base `config.env`.** With an explicit `--config <path>`, the env bridge layered only `cli.env` + the named file — the global base was never read. Typed config keys survived (getConfig reads the base independently), but the env-only keys the bridge exists for — `PI_RESEARCH_API_KEY`, `PI_RESEARCH_PROVIDER`, and the security-API keys — vanished, so `research "q" --config ci.env` exited 78 ("no model or API key") on a correctly configured machine, contradicting the flag's own help text. The global base now always layers underneath (base → cli.env → named file). Reproduced live before the fix; regression test fails against the pre-fix code.
23
+ - **Two sessions configured with different embedding models could permanently destroy knowledge-store data.** Every session opens the same physical table, and a model mismatch migrates it aside as a backup — so two sessions with different `PI_RESEARCH_EMBEDDING_MODEL` values alternate migrations, and the backup pruner's keep-only-newest rule deleted the only surviving copy of the first model's data after just two alternations, silently. Superseded backups are now retained until they age past `CACHE_TTL_DAYS` (a backup older than the TTL holds only rows eviction would have deleted anyway), so alternation can no longer destroy data inside the retention window.
24
+ - **A stable page became permanently "stale" once `KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS` was set.** The over-age read is treated as a miss and re-scraped, but the re-ingest's content-hash dedup skipped the write without refreshing the timestamp — so the entry could never satisfy the serve-age gate again (a live scrape on every run, for exactly the content that never changes) and TTL eviction eventually deleted an entry re-verified byte-identical possibly hours earlier. The unchanged-content skip now touches the current generation's timestamp — guarded by a just-in-time newest-generation re-check so a concurrent process's newer ingest can't be outranked by the touch.
25
+ - **An interrupted `knowledge` search could report success.** `cmdKnowledge` wired the abort signal but never consulted the cancellation latch on the resolve path — its own comment named "resolve normally and report ok:true/exit 0 for an interrupted run" as the failure being prevented, and only the throwing half was handled. It now mirrors `cmdResearch`: exit 128+N and a `cancelled: true` JSON field.
26
+ - **Cancelled `--json` research output nondeterministically lost its diagnostics.** The signal handler's fire-and-forget shutdown resets the SDK globals that `researchers`/`trackedErrors` are read from; reading them at JSON-emit time raced it. They are now snapshotted as soon as the run resolves, which shrinks the race window to near zero (a shutdown that completes before the run's continuation is even scheduled can still win — strictly narrower than before).
27
+ - **Fresh-store init could burn all 5 retries against a never-initialized embedder.** The model-mismatch local fallback embedder is constructed uninitialized, and only the migration branch warmed it — the no-existing-table branch went straight to `createTable()`, which throws "embedder dimension unknown", failing every retry identically. The fresh-table branch now warms the embedder the same way.
28
+ - **Headless pi-extension runs leaked per-run session state for the process lifetime.** The non-TUI cleanup path cleared steering messages but never called `endResearchSession`, so every run left its failure-count entries and AbortController in the module-level session map (the TUI and SDK paths both free them).
29
+ - **A run that failed while queued for a run slot fired no terminal observer event.** Capacity exhaustion or cancel-while-queued rethrew before any orchestrator existed, so an observer that had just been told `run_queued` never received `onError` — violating the "exactly one of onComplete/onError" contract. The terminal callback now fires on both paths.
30
+ - **The rebuilt report carried a stray `##`/`**`/`>` line when the model wrote a decorated CITED LINKS header.** The header detector deliberately tolerates markdown prefixes, but both body-slicing consumers cut at the header *text*, leaving the tolerated marker dangling at the end of the body. Both now slice at the start of the header line.
31
+ - **`--exclude-tools` accepted unknown tool names silently** (a typo excluded nothing for the whole billed run — the repository's own test fixture had exactly this bug, excluding a tool named `security` that does not exist); names are now validated against the canonical set. The fresh-eyes review then caught the validator omitting `read` — excluding researchers' local-file reads is a legitimate hardening move that had always worked and would have started failing on the CLI; `read` is in the valid set. **A bare `--initial-links` with zero URLs** likewise proceeded silently and now fails fast like every other value-taking flag.
32
+ - **The exit-78 message conflated "nothing configured" with "model configured, key missing".** SKILL.md tells agents to relay the message verbatim, so users who had set `PI_RESEARCH_MODEL` were pointed at the wrong knob. The two cases now produce distinct messages.
33
+ - **`PI_RESEARCH_*` boolean/enum env vars silently mis-parsed with surrounding whitespace** (`'true '` read as false); both parsers now trim like the numeric parser always has. **On a model-load timeout, the still-loading ONNX pipeline now gets disposed** when it eventually resolves instead of leaking one native session per retry.
34
+
35
+ ### Documentation
36
+
37
+ - `.env.example` corrections: `PI_RESEARCH_ALLOW_LOOPBACK_SCRAPE` described a wider hole than the loopback-only scope it actually has (the same overstatement 1.3.9 fixed in CONFIGURATION.md); `PI_RESEARCH_LLM_TIMEOUT_MS` still showed the old 10-minute ceiling (raised to 30 in the schema); the "exhaustive reference" claim is honored again by documenting `PLAYWRIGHT_BROWSERS_PATH`, `XDG_CACHE_HOME`, and the skill launcher's `PI_RESEARCH_BIN`/`PI_RESEARCH_PATH`.
38
+ - ARCHITECTURE.md's "Key design decisions" still said researchers "can read and grep files" — stale since grep became default-excluded, and wrong outright since 1.3.10 made the exclusion unconditional. Rewritten, along with the tool table's "off by default" wording, the extension event list, and the fixed "≤3 videos" phrasing (configurable 1–5). README's "read and search files" corrected to read-only-files. CONFIGURATION.md now states that provider-native key variables are honored only as real environment variables, and the CLI help finally mentions `--version`/`-v` (implemented all along).
39
+
40
+ ### Removed
41
+
42
+ - **The 1.3.10 researcher `max_tokens` cap (`PI_RESEARCH_RESEARCHER_MAX_TOKENS`) is reverted.** It solved a non-problem and created a real risk. The 402 it "fixed" only fires when an OpenRouter balance is nearly exhausted, and that error already names the remedy — it is the provider's top-up prompt, not a client defect. Meanwhile the cap re-introduced the exact failure class `PLANNING_MAX_TOKENS` was raised to escape in 1.2.x: reasoning tokens count against `max_tokens` on OpenAI-compatible APIs, so with `PI_RESEARCH_LLM_THINKING_LEVEL` raised — or a model that cannot fully disable thinking — a 16k ceiling can be consumed before or during the report and truncate it. It also second-guessed the model catalog: pi sessions everywhere else honor the catalog `maxTokens`, and a user who wants a different ceiling can already set one per model via `modelOverrides` in `~/.pi/agent/models.json`, which covers every session this package creates. Researcher sessions once again inherit the model's catalog ceiling unmodified.
43
+
44
+ ## [1.3.10] - 2026-08-14
45
+
46
+ The post-release verification round for 1.3.9 kept digging and found that the
47
+ fetch scrape path — the fast primary transport in front of the stealth browser —
48
+ had been dead in production on every supported Node version, hidden completely by
49
+ the browser fallback. Three more researcher-session defects surfaced from the same
50
+ investigation, two of them demonstrated live against a real provider. Each fix
51
+ carries a regression test verified to fail against the pre-fix code (the
52
+ accounting fix pins the deep-researcher site; the quick site shares the same
53
+ helper call), and the fetch fix carries the first test of that layer that uses a
54
+ real socket instead of a mocked `fetch`. An independent fresh-eyes review of the
55
+ whole delta then caught two follow-on defects, fixed below.
56
+
57
+ ### Fixed
58
+
59
+ - **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.
60
+ - **Researchers sent the model's full ~230k catalog ceiling as `max_tokens` — and OpenRouter 402'd every call on a low balance.** The researcher session inherited the model's catalog `maxTokens` as its per-request output cap. Providers that budget-check `max_tokens` up front rejected the call before running it: observed live as `402: You requested up to 231037 tokens, but can only afford 24467` on every researcher, failing entire runs whose real cost was cents. Researcher output per call — tool calls plus a cited report — is nowhere near that. New `PI_RESEARCH_RESEARCHER_MAX_TOKENS` knob (default 16384, range 1024–131072), clamped to the model's real ceiling, applied in the shared session factory so deep and quick sub-agents are both covered. *(Reverted in 1.3.11 — see above.)*
61
+ - **`--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.)
62
+ - **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.
63
+ - **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.
64
+ - **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.
65
+
66
+ ### Verified
67
+
68
+ - Full suites green after the changes: 191 unit files / 2457 tests, integration 53+8 skipped and 103, lint, both type-checks, dependency rules.
69
+ - 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).
70
+ - 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.
71
+
8
72
  ## [1.3.9] - 2026-08-14
9
73
 
10
74
  A whole-tree audit sweep. Six defects, each found by looking at what the code
@@ -69,7 +69,7 @@ Research
69
69
  | `PI_RESEARCH_MAX_RESEARCHERS` (TUI) | `3` | 1–5 | Parallel researchers. |
70
70
  | `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` (TUI) `[project]` | `1` | 1–3 | Depth for `/research` and the CLI when `--depth` is omitted (1=normal, 2=deep, 3=ultra). |
71
71
  | `PI_RESEARCH_MAX_SCRAPE_BATCHES` (TUI) | `2` | 0–99 | Scrape batches per researcher (0 = unlimited). |
72
- | `PI_RESEARCH_MAX_GATHERING_CALLS` | `12` | 1–100 | Shared web-gathering calls per researcher (`search` + `security_search` + `stackexchange` + `youtube_transcript`). Local `grep` has its own separate budget. |
72
+ | `PI_RESEARCH_MAX_GATHERING_CALLS` | `12` | 1–100 | Shared web-gathering calls per researcher (`search` + `security_search` + `stackexchange` + `youtube_transcript`). |
73
73
  | `PI_RESEARCH_MAX_CONCURRENT_SCRAPES` | `3` | 1–20 | Concurrent URLs fetched per scrape batch. |
74
74
  | `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
75
75
  | `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
@@ -135,7 +135,7 @@ API keys
135
135
 
136
136
  | Variable | Description |
137
137
  |----------|-------------|
138
- | `PI_RESEARCH_API_KEY` / `PI_RESEARCH_PROVIDER` | Explicit LLM credentials for SDK / CLI mode (not needed when pi's configuration supplies the key). On the CLI / agent skill both can also live in `config.env` / `cli.env`. The provider is required alongside the key, or inferred from a `provider/model-id` `PI_RESEARCH_MODEL`. |
138
+ | `PI_RESEARCH_API_KEY` / `PI_RESEARCH_PROVIDER` | Explicit LLM credentials for SDK / CLI mode (not needed when pi's configuration supplies the key). On the CLI / agent skill both can also live in `config.env` / `cli.env`. The provider is required alongside the key, or inferred from a `provider/model-id` `PI_RESEARCH_MODEL`. Note: provider-native variables (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, …) are honored only as **real environment variables** — placing one in `config.env` / `cli.env` does nothing (the files bridge only `PI_RESEARCH_*` keys plus `STACKEXCHANGE_API_KEY` / `GITHUB_TOKEN` / `NVD_API_KEY`). |
139
139
  | `STACKEXCHANGE_API_KEY` | Raises the Stack Exchange tool's limit from 300/day to 10,000/day. Obtain at <https://stackapps.com/apps/oauth>. |
140
140
  | `GITHUB_TOKEN` | Raises the security tool's GitHub Advisory limit from 60/hr to 5000/hr (any default-scope token). |
141
141
  | `NVD_API_KEY` | Raises the security tool's NVD limit ~10× and tightens request spacing. Request at <https://nvd.nist.gov/developers/request-an-api-key>. Recommended when using severity-filtered security searches: those issue a second (CVSS v2) NVD query to catch v2-only CVEs, which roughly doubles request time against the 6 s/request unauthenticated throttle. |
@@ -158,6 +158,7 @@ Diagnostics & platform
158
158
  | `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` | _(unset)_ | Set `1` during `npm install` to skip the camoufox browser download (fetched lazily on first use instead; standard Playwright convention). |
159
159
  | `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
160
  | `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
+ | `PI_RESEARCH_BIN` (alias `PI_RESEARCH_PATH`) | _(auto)_ | Agent-skill launcher only: explicit path to the pi-research engine binary when auto-resolution (PATH → local install → npx) should be bypassed. See [skills/pi-research/references/configuration.md](../skills/pi-research/references/configuration.md). |
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`). |
162
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. |
163
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). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.3.9",
3
+ "version": "1.3.11",
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.9", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.3.11", "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
@@ -44,6 +44,7 @@ import { exportResearchReport, appendExportMessage } from './utils/research-expo
44
44
  import { getConfig, getGlobalConfigDir, getGlobalEnvFilePath, getInterfaceEnvFilePath, getStateDir, saveConfig, resetConfig, describeKnowledgeStoreMode, isProjectScopedKey, parseDotEnv } from './config.ts';
45
45
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
46
46
  import { buildModelRegistry, safeGetAvailable } from './core/llm/model-registry-factory.ts';
47
+ import { RESEARCH_TOOL_NAMES } from './constants.ts';
47
48
 
48
49
  // ---------------------------------------------------------------------------
49
50
  // Constants
@@ -234,7 +235,8 @@ export class ConfigFileError extends Error {}
234
235
  * the real environment, so real shell exports always win.
235
236
  */
236
237
  export function bridgeConfigEnv(explicitConfigPath?: string): { path: string; loaded: boolean } {
237
- const basePath = explicitConfigPath ?? getGlobalEnvFilePath();
238
+ const globalBasePath = getGlobalEnvFilePath();
239
+ const basePath = explicitConfigPath ?? globalBasePath;
238
240
  const cliOverlayPath = getInterfaceEnvFilePath('cli');
239
241
  let loaded = false;
240
242
  // Merge base then overlay into one map FIRST (overlay wins), and only then
@@ -252,13 +254,18 @@ export function bridgeConfigEnv(explicitConfigPath?: string): { path: string; lo
252
254
  // the user passed on this exact invocation is the most specific intent, and the
253
255
  // ambient cli.env overlay silently overriding it re-created the very failure
254
256
  // this bridge exists to fix (a key set in `--config ci.env` simply not applying).
257
+ // The GLOBAL base always stays underneath: this bridge exists for env-only keys
258
+ // (PI_RESEARCH_API_KEY & co.) that getConfig never carries, so dropping the base
259
+ // here silently dropped credentials stored only in config.env — `--config extra.env`
260
+ // exit-78'd on a correctly configured machine while the help text promised
261
+ // "base config.env keys it does not set still apply".
255
262
  const layers = explicitConfigPath !== undefined
256
- ? [cliOverlayPath, basePath]
257
- : [basePath, cliOverlayPath];
263
+ ? [globalBasePath, cliOverlayPath, explicitConfigPath]
264
+ : [globalBasePath, cliOverlayPath];
258
265
  for (const filePath of layers) {
259
266
  // Only the EXPLICIT --config file fails loudly (see ConfigFileError above);
260
267
  // the ambient layers keep their missing/unreadable tolerance unchanged.
261
- const isExplicit = explicitConfigPath !== undefined && filePath === basePath;
268
+ const isExplicit = explicitConfigPath !== undefined && filePath === explicitConfigPath;
262
269
  try {
263
270
  if (!fs.existsSync(filePath)) {
264
271
  if (isExplicit) throw new ConfigFileError(`--config file not found: ${filePath}`);
@@ -495,11 +502,20 @@ async function detectCredentials(explicitModel?: string): Promise<CredentialDete
495
502
  modelFrom,
496
503
  piAuthPresent,
497
504
  piModelsPresent,
498
- problem:
499
- 'No model or API key is configured for pi-research. Provide credentials via environment ' +
500
- 'variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use ' +
501
- "pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider " +
502
- 'apiKey in ~/.pi/agent/models.json).',
505
+ // A model configured with no usable key is a different (and misleading to
506
+ // conflate) gap than nothing configured at all: SKILL.md tells agents to relay
507
+ // this text verbatim, so "No model … is configured" pointed users who HAD set
508
+ // PI_RESEARCH_MODEL at the wrong knob.
509
+ problem: model
510
+ ? `A model is configured (${model}) but no API key is available for ` +
511
+ `${provider ? `provider "${provider}"` : 'its provider'}. Provide one via the ` +
512
+ 'PI_RESEARCH_API_KEY environment variable (with PI_RESEARCH_PROVIDER), or, if you use pi, ' +
513
+ "via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider " +
514
+ 'apiKey in ~/.pi/agent/models.json).'
515
+ : 'No model or API key is configured for pi-research. Provide credentials via environment ' +
516
+ 'variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use ' +
517
+ "pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider " +
518
+ 'apiKey in ~/.pi/agent/models.json).',
503
519
  };
504
520
  }
505
521
 
@@ -604,6 +620,14 @@ export async function cmdResearch(args: ResearchArgs): Promise<number> {
604
620
  ...(args.initialLinks ? { initialLinks: args.initialLinks } : {}),
605
621
  }, abortController.signal);
606
622
 
623
+ // Snapshot the run diagnostics NOW, before any further await: on a cancelled
624
+ // run, onSignal's fire-and-forget safeShutdown runs concurrently with this
625
+ // tail and resets these SDK globals — reading them at JSON-emit time made
626
+ // `researchers`/`trackedErrors` silently vanish from a cancelled-but-
627
+ // successful payload depending on who won the race.
628
+ const outcome = getLastResearcherOutcome();
629
+ const errorReport = getLastErrorReport();
630
+
607
631
  // Optionally persist the report to a file (opt-in via
608
632
  // PI_RESEARCH_REPORT_EXPORT_ENABLED). The SDK is a pure library and does not
609
633
  // write files; the CLI front-end does, mirroring the pi tool path.
@@ -636,8 +660,7 @@ export async function cmdResearch(args: ResearchArgs): Promise<number> {
636
660
  // (ISSUES-ANALYSIS-2026-07-09.md, Symptom 4): a caller reading only stdout/JSON
637
661
  // had no way to distinguish "thin report, little material out there" from "thin
638
662
  // report because most researchers died mid-run" without opening the log file.
639
- const outcome = getLastResearcherOutcome();
640
- const errorReport = getLastErrorReport();
663
+ // (outcome/errorReport were snapshotted right after the run resolved — see above.)
641
664
  toStdout(pretty({
642
665
  ok: true,
643
666
  depth,
@@ -719,11 +742,20 @@ export async function cmdKnowledge(queries: string[], json?: boolean): Promise<n
719
742
  await safeShutdown();
720
743
  }
721
744
 
745
+ // Same latch as cmdResearch's post-run check: the abort signal's checks inside
746
+ // searchKnowledge are point-in-time, so a Ctrl-C landing after the last one lets
747
+ // the search resolve normally — without this, an interrupted run raced onSignal's
748
+ // flushAndExit and could report ok:true / exit 0. The rationale comment above
749
+ // named exactly this failure; only the throwing half was previously handled.
750
+ if (cancellationRequested) {
751
+ exit = cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED;
752
+ }
753
+
722
754
  if (json) {
723
755
  // `ok: true` makes `ok` a reliable success/failure discriminant across every
724
756
  // knowledge JSON variant (disabled-store above, reportError's ok:false below,
725
757
  // and this success shape) — additive only, every existing field is unchanged.
726
- toStdout(pretty({ ok: true, ...result }));
758
+ toStdout(pretty({ ok: true, ...(cancellationRequested ? { cancelled: true } : {}), ...result }));
727
759
  } else {
728
760
  toStdout((result.text.endsWith('\n') ? result.text : result.text + '\n'));
729
761
  }
@@ -1158,6 +1190,7 @@ COMMANDS
1158
1190
  --dry-run | --json Plan only; JSON output.
1159
1191
 
1160
1192
  help, --help, -h Show this help.
1193
+ --version, -v Print the pi-research version.
1161
1194
 
1162
1195
  CONFIGURE
1163
1196
  Credentials are resolved in this order (first match wins):
@@ -1372,6 +1405,15 @@ export function parseArgs(argv: string[]): ParsedArgs {
1372
1405
  } else if (a === '--exclude-tools') {
1373
1406
  const v = takeOptionValue(rest, ++i, '--exclude-tools');
1374
1407
  excludeTools = v.split(',').map((s) => s.trim()).filter(Boolean);
1408
+ // Unknown names fail fast: the merge downstream is a blind Set union, so a
1409
+ // typo ("securty_search") silently excluded nothing while the user believed
1410
+ // the tool was off for the whole (billed) run.
1411
+ const unknown = excludeTools.filter((t) => !RESEARCH_TOOL_NAMES.includes(t));
1412
+ if (unknown.length > 0) {
1413
+ throw new UsageError(
1414
+ `--exclude-tools: unknown tool name${unknown.length === 1 ? '' : 's'} ` +
1415
+ `${unknown.map((t) => `"${t}"`).join(', ')}. Valid names: ${RESEARCH_TOOL_NAMES.join(', ')}.`);
1416
+ }
1375
1417
  if (excludeTools.length === 0) excludeTools = undefined;
1376
1418
  } else if (a === '--initial-links') {
1377
1419
  // Consume until the next flag or end. Validate each entry: these are templated
@@ -1392,6 +1434,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
1392
1434
  }
1393
1435
  j++;
1394
1436
  }
1437
+ // Every other value-taking flag fails fast on a missing value; a bare
1438
+ // `--initial-links` (immediately followed by another flag or end of args)
1439
+ // silently proceeding with zero links was the one exception.
1440
+ if (j === i + 1) throw new UsageError('--initial-links requires at least one URL.');
1395
1441
  i = j - 1;
1396
1442
  } else if (a === '--config') {
1397
1443
  configPath = takeOptionValue(rest, ++i, '--config');
package/src/config.ts CHANGED
@@ -158,7 +158,8 @@ export const ConfigSchema = Type.Object({
158
158
  /**
159
159
  * Comma-separated list of research tools to DISABLE for every researcher in a run
160
160
  * (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
161
- * search, scrape, security_search, stackexchange, youtube_transcript, grep.
161
+ * search, scrape, security_search, stackexchange, youtube_transcript, grep, read
162
+ * (RESEARCH_TOOL_NAMES in constants.ts is the canonical list).
162
163
  * Disable-only: the list is merged into the per-run excludeTools stream, so disabled
163
164
  * tools are both removed from each researcher's toolset AND named in the
164
165
  * coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
@@ -1214,7 +1215,9 @@ function parseEnvNumber(env: Record<string, string | undefined>, key: string, de
1214
1215
  }
1215
1216
 
1216
1217
  function parseEnvBool(env: Record<string, string | undefined>, key: string, def: boolean): boolean {
1217
- const v = env[key];
1218
+ // Trim like parseEnvNumber does: file values arrive trimmed via parseDotEnv, but a
1219
+ // real env var `PI_RESEARCH_X='true '` otherwise silently read false.
1220
+ const v = env[key]?.trim();
1218
1221
  if (v === undefined || v === '') return def;
1219
1222
  // Strict by design: only the literal 'true' (any case) is truthy; every other value is false.
1220
1223
  // This is intentional and asserted in config.test.ts ('1'/'yes' → false) — do not loosen it.
@@ -1237,7 +1240,7 @@ function parseEnvEnum<T extends string>(
1237
1240
  allowed: readonly T[],
1238
1241
  def: T,
1239
1242
  ): T {
1240
- const raw = env[key];
1243
+ const raw = env[key]?.trim();
1241
1244
  if (raw === undefined || raw === '') return def;
1242
1245
  if ((allowed as readonly string[]).includes(raw)) return raw as T;
1243
1246
  logger.warn(`[config] Invalid ${key}="${raw}" (expected one of: ${allowed.join(', ')}). Using default "${def}".`);
package/src/constants.ts CHANGED
@@ -182,25 +182,33 @@ export const MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
182
182
  */
183
183
  export const DEFAULT_EXCLUDED_TOOLS: readonly string[] = ['grep'];
184
184
 
185
+ /**
186
+ * The complete set of excludable research tool names — the vocabulary
187
+ * `--exclude-tools` / `excludeTools` / PI_RESEARCH_DISABLED_TOOLS accept.
188
+ * Kept here (next to DEFAULT_EXCLUDED_TOOLS) so validation and the exclusion
189
+ * machinery cannot drift; the DISABLED_TOOLS doc in config.ts names the same set.
190
+ * `read` is a researcher tool too (local file reads) — excluding it is a
191
+ * legitimate hardening move, and omitting it from this list broke exactly that
192
+ * on the CLI when validation was introduced.
193
+ */
194
+ export const RESEARCH_TOOL_NAMES: readonly string[] = [
195
+ 'search', 'scrape', 'security_search', 'stackexchange', 'youtube_transcript', 'grep', 'read',
196
+ ];
197
+
185
198
  /**
186
199
  * Resolve the effective researcher exclusion list.
187
200
  *
188
- * An EMPTY array is "no preference", not "exclude nothing". The two front-ends
189
- * express no-preference differently — the pi extension always builds an array
190
- * (`[...new Set([...])]`, often empty) while the CLI omits the key entirely — so a
191
- * plain truthiness test made the same user input mean opposite things: grep was
192
- * available in the extension and excluded on the CLI, regardless of depth.
193
- *
194
- * `configDisabled` (PI_RESEARCH_DISABLED_TOOLS) is strictly ADDITIVE. Folding it in
195
- * as a replacement meant disabling any unrelated tool silently ENABLED grep, so a
196
- * subtract-only setting could grant a capability.
201
+ * EVERY input is strictly ADDITIVE on top of DEFAULT_EXCLUDED_TOOLS. Both
202
+ * subtract-only surfaces — `--exclude-tools` / `excludeTools` and
203
+ * PI_RESEARCH_DISABLED_TOOLS (`configDisabled`) — are documented as "disable
204
+ * tools", so neither may GRANT a capability as a side effect. Each has held the
205
+ * replacement semantics at some point, with the same consequence both times:
206
+ * excluding any unrelated tool silently enabled local-filesystem grep for web
207
+ * researchers.
197
208
  */
198
209
  export function resolveExcludedTools(
199
210
  callerExclusions: readonly string[] | undefined,
200
211
  configDisabled: readonly string[] = [],
201
212
  ): string[] {
202
- const base = callerExclusions && callerExclusions.length > 0
203
- ? callerExclusions
204
- : DEFAULT_EXCLUDED_TOOLS;
205
- return [...new Set([...base, ...configDisabled])];
213
+ return [...new Set([...DEFAULT_EXCLUDED_TOOLS, ...(callerExclusions ?? []), ...configDisabled])];
206
214
  }
@@ -1512,8 +1512,9 @@ __export(scraper_utils_exports, {
1512
1512
  extractDomain: () => extractDomain,
1513
1513
  formatErrorWithCause: () => formatErrorWithCause,
1514
1514
  getRandomUserAgent: () => getRandomUserAgent,
1515
- getSsrfSafeDispatcher: () => getSsrfSafeDispatcher,
1515
+ getSsrfSafeFetcher: () => getSsrfSafeFetcher,
1516
1516
  isBenignScrapeFailure: () => isBenignScrapeFailure,
1517
+ isSsrfBlockError: () => isSsrfBlockError,
1517
1518
  ssrfSafeLookup: () => ssrfSafeLookup,
1518
1519
  stripImageLinks: () => stripImageLinks,
1519
1520
  validateContent: () => validateContent,
@@ -1633,16 +1634,29 @@ function ssrfSafeLookup(hostname, options, callback, resolveImpl = dnsCb.lookup)
1633
1634
  return callback(null, safe[0].address, safe[0].family);
1634
1635
  });
1635
1636
  }
1636
- async function getSsrfSafeDispatcher() {
1637
- if (cachedSsrfDispatcher !== void 0) return cachedSsrfDispatcher;
1637
+ function isSsrfBlockError(error) {
1638
+ let e = error;
1639
+ for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
1640
+ if (e.code === "ESSRFBLOCKED" || e.message.includes("not allowed")) {
1641
+ return true;
1642
+ }
1643
+ e = e.cause;
1644
+ }
1645
+ return false;
1646
+ }
1647
+ async function getSsrfSafeFetcher() {
1648
+ if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
1638
1649
  try {
1639
- const { Agent } = await import("undici");
1640
- cachedSsrfDispatcher = new Agent({ connect: { lookup: ssrfSafeLookup } });
1650
+ const { Agent, fetch: undiciFetch } = await import("undici");
1651
+ cachedSsrfFetcher = {
1652
+ fetch: undiciFetch,
1653
+ dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
1654
+ };
1641
1655
  } catch (e) {
1642
1656
  logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
1643
- cachedSsrfDispatcher = null;
1657
+ cachedSsrfFetcher = null;
1644
1658
  }
1645
- return cachedSsrfDispatcher;
1659
+ return cachedSsrfFetcher;
1646
1660
  }
1647
1661
  function formatErrorWithCause(err) {
1648
1662
  try {
@@ -1776,7 +1790,7 @@ function validateContent(html, markdown, url) {
1776
1790
  throw error;
1777
1791
  }
1778
1792
  }
1779
- var cachedSsrfDispatcher;
1793
+ var cachedSsrfFetcher;
1780
1794
  var init_scraper_utils = __esm({
1781
1795
  "src/web-research/scraper-utils.ts"() {
1782
1796
  "use strict";
@@ -1785,7 +1799,7 @@ var init_scraper_utils = __esm({
1785
1799
  init_logger();
1786
1800
  init_scraper_types();
1787
1801
  init_browser_error_utils();
1788
- cachedSsrfDispatcher = void 0;
1802
+ cachedSsrfFetcher = void 0;
1789
1803
  }
1790
1804
  });
1791
1805
 
@@ -2566,7 +2580,8 @@ var ConfigSchema = Type.Object({
2566
2580
  /**
2567
2581
  * Comma-separated list of research tools to DISABLE for every researcher in a run
2568
2582
  * (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
2569
- * search, scrape, security_search, stackexchange, youtube_transcript, grep.
2583
+ * search, scrape, security_search, stackexchange, youtube_transcript, grep, read
2584
+ * (RESEARCH_TOOL_NAMES in constants.ts is the canonical list).
2570
2585
  * Disable-only: the list is merged into the per-run excludeTools stream, so disabled
2571
2586
  * tools are both removed from each researcher's toolset AND named in the
2572
2587
  * coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
@@ -42,7 +42,23 @@ export async function loadPipelineWithTimeout(
42
42
  interOpNumThreads: 1,
43
43
  },
44
44
  });
45
- return await withTimeout(pipelinePromise, timeoutMs, errorMessage);
45
+ // withTimeout races but cannot cancel: on timeout the underlying pipeline()
46
+ // keeps loading in the background and its eventual ONNX session would leak
47
+ // (the caller's catch disposes this.pipeline, which was never assigned).
48
+ // Dispose the late arrival when it eventually resolves — one orphaned native
49
+ // session per retry adds up on a slow disk.
50
+ let timedOut = true;
51
+ try {
52
+ const p = await withTimeout(pipelinePromise, timeoutMs, errorMessage);
53
+ timedOut = false;
54
+ return p;
55
+ } finally {
56
+ if (timedOut) {
57
+ pipelinePromise
58
+ .then((late) => (late as unknown as { dispose?: () => Promise<void> }).dispose?.())
59
+ .catch(() => { /* load failed after the timeout — nothing to dispose */ });
60
+ }
61
+ }
46
62
  });
47
63
 
48
64
  return { pipeline: loadedPipeline, errorMessage };
@@ -290,6 +290,15 @@ export class KnowledgeStore implements IKnowledgeStore {
290
290
  logger.warn('[store] Failed to read table schema:', schemaErr);
291
291
  }
292
292
  } else {
293
+ // Same warm-up as the migration branch above: createTable() throws
294
+ // "embedder dimension unknown" on a never-initialized embedder. This
295
+ // branch (no existing table — fresh dbDir or first-ever run) hit exactly
296
+ // that when the embedding factory handed back its model-mismatch local
297
+ // fallback, which is constructed but never initialize()d: every one of
298
+ // the caller's init retries burned against the same cached instance.
299
+ if (this.options.embedder.getDimension() === null) {
300
+ await this.withEmbedderReconnect(embedder => embedder.embedMany([' ']));
301
+ }
293
302
  this.table = await this.createTable();
294
303
  }
295
304
 
@@ -787,6 +796,49 @@ export class KnowledgeStore implements IKnowledgeStore {
787
796
  }
788
797
  }
789
798
 
799
+ /**
800
+ * Refresh the timestamp of one CURRENT generation for url+type — the
801
+ * revalidation "touch" for the writer-queue's content-unchanged skip. Without
802
+ * it a stable page kept its original timestamp forever: with
803
+ * KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS set, the serve-age gate then treated the
804
+ * entry as permanently stale (a live re-scrape on every run, for exactly the
805
+ * content that never changes), and TTL eviction eventually deleted an entry
806
+ * that had been re-verified byte-identical possibly hours earlier.
807
+ */
808
+ async touchByUrlAndType(url: string, ingestionType: string, generationTs: number, newTs: number): Promise<void> {
809
+ return this.trackOperation('touchByUrlAndType', undefined, async () => {
810
+ let retryCount = 0;
811
+ const MAX_RETRIES = 5;
812
+ const BASE_DELAY = 100;
813
+ const scopeFilter = this.getScopeFilter();
814
+ while (retryCount <= MAX_RETRIES) {
815
+ const table = await this.getFreshTable();
816
+ try {
817
+ const escapedUrl = url.replace(/'/g, "''");
818
+ const escapedType = ingestionType.replace(/'/g, "''");
819
+ // valuesSql with integer-coerced literals: timestamp is an Int64 column,
820
+ // and both inputs are integer-coerced so the SQL can never carry
821
+ // anything but digits.
822
+ await table.update({
823
+ where: `url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND timestamp = ${Math.floor(generationTs)} AND (${scopeFilter})`,
824
+ valuesSql: { timestamp: String(Math.floor(newTs)) },
825
+ });
826
+ logger.log(`[store] Refreshed timestamp for unchanged ${ingestionType} chunks of ${url}`);
827
+ return;
828
+ } catch (err) {
829
+ if (isLanceCommitConflict(err)) {
830
+ retryCount++;
831
+ if (retryCount > MAX_RETRIES) throw err;
832
+ const delay = Math.floor(Math.random() * (BASE_DELAY * Math.pow(2, retryCount - 1)));
833
+ await new Promise(r => setTimeout(r, delay));
834
+ continue;
835
+ }
836
+ throw err;
837
+ }
838
+ }
839
+ });
840
+ }
841
+
790
842
  async findDocumentsByUrl(url: string): Promise<StoreDocument[]> {
791
843
  // Coordinate with close(): findByUrl runs via the writer-queue in the post-run
792
844
  // background window (link-description storage), exactly when a Ctrl+C-driven
@@ -1179,7 +1231,18 @@ export class KnowledgeStore implements IKnowledgeStore {
1179
1231
  backupDirs.map(async name => ({ name, ts: await this.backupDirTimestamp(name) }))
1180
1232
  );
1181
1233
  ranked.sort((a, b) => a.ts - b.ts);
1182
- for (const { name } of ranked.slice(0, -1)) {
1234
+ // Retain older backups until they age past the store's own TTL, not just
1235
+ // the single newest one. Two sessions configured with DIFFERENT embedding
1236
+ // models alternate migrations against the shared table (each open sees
1237
+ // "wrong model" → backup → fresh table), and keep-only-newest deleted the
1238
+ // only surviving copy of the first model's data after just two
1239
+ // alternations — silent, permanent loss. A backup older than ttlDays holds
1240
+ // only rows eviction would have deleted anyway, so time-based retention
1241
+ // matches the store's own data-retention contract.
1242
+ const ttlDays = this.options.ttlDays ?? 30;
1243
+ const backupCutoff = Math.trunc(Date.now() - (ttlDays * 24 * 60 * 60 * 1000));
1244
+ for (const { name, ts } of ranked.slice(0, -1)) {
1245
+ if (ts >= backupCutoff) continue;
1183
1246
  const fullPath = path.join(this.options.dbDir, name);
1184
1247
  logger.info(`[store] Removing superseded backup directory: ${name}`);
1185
1248
  await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
@@ -18,6 +18,9 @@ interface WriterQueueOptions {
18
18
  findByUrl: (url: string) => Promise<StoreDocument[]>;
19
19
  deleteByUrlAndType: (url: string, type: string, olderThan?: number) => Promise<void>;
20
20
  addDocuments: (docs: StoreDocument[]) => Promise<void>;
21
+ /** Revalidation touch for the content-unchanged skip (see that path). Optional
22
+ * so lightweight test doubles need not implement it. */
23
+ touchByUrlAndType?: (url: string, type: string, generationTs: number, newTs: number) => Promise<void>;
21
24
  };
22
25
  chunker: Chunker | null;
23
26
  }
@@ -199,6 +202,31 @@ export class WriterQueue implements IWriterQueue {
199
202
  metrics.increment('knowledge_ingest_stale_prune_failed_total', 1);
200
203
  });
201
204
  }
205
+ // The content was just re-fetched and verified byte-identical — record
206
+ // that as freshness. Without this touch, a stable page's timestamp never
207
+ // moved: the serve-age gate treated it as permanently stale (live scrape
208
+ // every run) and TTL eviction eventually deleted a revalidated entry.
209
+ //
210
+ // Re-check the newest generation first: a CONCURRENT process may have
211
+ // ingested changed content for this url+type since our dedup read, and
212
+ // stamping our (now-old) generation with a later timestamp would make the
213
+ // stale rows outrank the genuinely newer ones. The touch predicate is
214
+ // pinned to `timestamp = newestTs`, so the residual window is only the
215
+ // gap between this re-read and the update — self-healing on the next
216
+ // real scrape either way.
217
+ if (this.options.store.touchByUrlAndType) {
218
+ try {
219
+ const recheck = (await this.options.store.findByUrl(normalizedUrl))
220
+ .filter(c => c.metadata['ingestionType'] === incomingType);
221
+ const stillNewest = recheck.length > 0
222
+ && Math.max(...recheck.map(d => Number(d.timestamp) || 0)) === newestTs;
223
+ if (stillNewest) {
224
+ await this.options.store.touchByUrlAndType(normalizedUrl, incomingType, newestTs, Date.now());
225
+ }
226
+ } catch (err) {
227
+ logger.warn(`[writer-queue] Timestamp refresh for unchanged ${normalizedUrl} (${incomingType}) failed:`, err);
228
+ }
229
+ }
202
230
  logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) — content unchanged.`);
203
231
  return;
204
232
  }
@@ -13,7 +13,7 @@ import {
13
13
  import { type Model } from '@earendil-works/pi-ai';
14
14
  import { injectCurrentDate } from '../core/llm/inject-date.ts';
15
15
  import { loadPrompt } from '../core/llm/prompts.ts';
16
- import { extractUsage } from '../types/llm.ts';
16
+ import { recordLlmUsage } from '../utils/llm-usage.ts';
17
17
  import { logger } from '../logger.ts';
18
18
  import { getConfig, type Config } from '../config.ts';
19
19
  import { createResearcherSession } from './researcher.ts';
@@ -238,13 +238,13 @@ export class QuickResearchOrchestrator {
238
238
 
239
239
  const rawUsage = msg['usage'] as any;
240
240
  if (rawUsage) {
241
- const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
242
-
241
+ const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
242
+ component: 'quick_researcher',
243
+ complexity: 0,
244
+ observer,
245
+ });
243
246
  if (tokens > 0 || cost > 0) {
244
- metrics.increment('llm_tokens_total', tokens, { component: 'quick_researcher', complexity: '0' });
245
- metrics.increment('llm_cost_total', cost, { component: 'quick_researcher', complexity: '0' });
246
247
  observer?.onResearcherProgress?.('quick', undefined, tokens, cost);
247
- observer?.onTokensConsumed?.(tokens, cost);
248
248
  }
249
249
  }
250
250
  } else if (event.type === 'tool_execution_start') {