@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/README.md +1 -1
- package/dist/cli.mjs +163 -43
- package/dist/thread-worker.mjs +25 -10
- package/docs/ARCHITECTURE.md +17 -14
- package/docs/CHANGELOG.md +64 -0
- package/docs/CONFIGURATION.md +3 -2
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +58 -12
- package/src/config.ts +6 -3
- package/src/constants.ts +21 -13
- package/src/infrastructure/browser/thread-worker.mjs +25 -10
- package/src/knowledge/embedder-init.ts +17 -1
- package/src/knowledge/store.ts +64 -1
- package/src/knowledge/writer-queue.ts +28 -0
- package/src/orchestration/quick-research-orchestrator.ts +6 -6
- package/src/orchestration/research-orchestration-service.ts +13 -0
- package/src/orchestration/research-synthesis-service.ts +4 -2
- package/src/orchestration/researcher-executor.ts +8 -6
- package/src/tools/research-tool-definition.ts +8 -1
- package/src/utils/citation-utils.ts +4 -2
- package/src/utils/text-utils.ts +17 -3
- package/src/web-research/scraper-utils.ts +58 -12
- package/src/web-research/web-scraper.ts +13 -8
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
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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`).
|
|
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.
|
|
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.
|
|
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
|
|
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,
|
|
257
|
-
: [
|
|
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 ===
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* (`
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1657
|
+
cachedSsrfFetcher = null;
|
|
1644
1658
|
}
|
|
1645
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/src/knowledge/store.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
16
|
+
import { recordLlmUsage } from '../utils/llm-usage.ts';
|
|
17
17
|
import { logger } from '../logger.ts';
|
|
18
18
|
import { getConfig, type Config } from '../config.ts';
|
|
19
19
|
import { createResearcherSession } from './researcher.ts';
|
|
@@ -238,13 +238,13 @@ export class QuickResearchOrchestrator {
|
|
|
238
238
|
|
|
239
239
|
const rawUsage = msg['usage'] as any;
|
|
240
240
|
if (rawUsage) {
|
|
241
|
-
const { tokens, cost } =
|
|
242
|
-
|
|
241
|
+
const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
|
|
242
|
+
component: 'quick_researcher',
|
|
243
|
+
complexity: 0,
|
|
244
|
+
observer,
|
|
245
|
+
});
|
|
243
246
|
if (tokens > 0 || cost > 0) {
|
|
244
|
-
metrics.increment('llm_tokens_total', tokens, { component: 'quick_researcher', complexity: '0' });
|
|
245
|
-
metrics.increment('llm_cost_total', cost, { component: 'quick_researcher', complexity: '0' });
|
|
246
247
|
observer?.onResearcherProgress?.('quick', undefined, tokens, cost);
|
|
247
|
-
observer?.onTokensConsumed?.(tokens, cost);
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
} else if (event.type === 'tool_execution_start') {
|