@lincoln504/pi-research 1.0.3 → 1.0.5
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 +256 -51
- package/dist/prompts/researcher.md +1 -1
- package/docs/CONFIGURATION.md +2 -2
- package/docs/KNOWLEDGE-STORE.md +5 -3
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/skills/pi-research/references/configuration.md +2 -2
- package/src/cli.ts +78 -31
- package/src/core/llm/model-registry-factory.ts +17 -6
- package/src/knowledge/store.ts +17 -7
- package/src/orchestration/deep-research-orchestrator.ts +38 -12
- package/src/orchestration/headless-observer.ts +45 -0
- package/src/orchestration/quick-research-orchestrator.ts +5 -4
- package/src/orchestration/research-orchestration-service.ts +3 -2
- package/src/orchestration/research-synthesis-service.ts +11 -3
- package/src/orchestration/researcher-executor.ts +1 -1
- package/src/orchestration/session-state.ts +33 -1
- package/src/prompts/researcher.md +1 -1
- package/src/sdk.ts +20 -1
- package/src/security/cisa-kev.ts +3 -0
- package/src/security/github-advisories.ts +5 -0
- package/src/security/nvd-client.ts +3 -0
- package/src/security/osv-client.ts +3 -0
- package/src/tools/youtube-transcript.ts +6 -2
- package/src/utils/shared-links.ts +26 -0
- package/src/web-research/scraper-utils.ts +10 -3
- package/src/web-research/web-scraper.ts +95 -4
- package/src/youtube/index.ts +7 -3
- package/src/youtube/transcript-client.ts +2 -0
|
@@ -58,7 +58,7 @@ Write your report immediately after scraping is complete, or as soon as a tool r
|
|
|
58
58
|
## Guidelines
|
|
59
59
|
|
|
60
60
|
- **Available Tools**:
|
|
61
|
-
- `scrape`: Fetch and read web pages (primary tool). Focus your energy here.
|
|
61
|
+
- `scrape`: Fetch and read web pages (primary tool). Focus your energy here. Do NOT scrape YouTube video links (watch / youtu.be / shorts) — a watch page returns YouTube's app shell, not the video's content; read videos with `youtube_transcript`. Scraping a YouTube channel or playlist page to DISCOVER video links is fine.
|
|
62
62
|
- `youtube_transcript`: Read the captions/transcript of YouTube videos from your source list — it is the `scrape` for video. ONE call only, batching up to a few of the most relevant YouTube links. Cite these as `Source: YouTube Transcript`.
|
|
63
63
|
- `stackexchange`: Use ONLY for Stack Overflow or Stack Exchange URLs.
|
|
64
64
|
- `security_search`: Query NVD, CVE, OSV, CISA databases.
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -75,7 +75,7 @@ Research
|
|
|
75
75
|
| `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
|
|
76
76
|
| `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. |
|
|
77
77
|
| `PI_RESEARCH_WORKER_CONCURRENCY` | `2` | 1–10 | Tasks per worker process. |
|
|
78
|
-
| `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one. On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and evaluator keep using the session model. Accepts `provider/id` or a bare model id. |
|
|
78
|
+
| `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and evaluator keep using the session model. Accepts `provider/id` or a bare model id. |
|
|
79
79
|
| `PI_RESEARCH_DISABLED_TOOLS` | _(none)_ | — | Comma-separated research tools to disable for a run (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `grep`). Removed from every researcher's toolset and named in the coordinator/evaluator prompt. |
|
|
80
80
|
| `PI_RESEARCH_REPORT_EXPORT_ENABLED` (TUI) | `false` | — | Front-ends write a Markdown report to disk and surface its path. |
|
|
81
81
|
| `PI_RESEARCH_REPORT_EXPORT_DIR` | _(smart cwd)_ | — | Pin exported reports to a fixed directory, bypassing the cwd-relative resolution. Useful for the agent skill, which runs from the host agent's arbitrary directory. |
|
|
@@ -132,7 +132,7 @@ API keys
|
|
|
132
132
|
|
|
133
133
|
| Variable | Description |
|
|
134
134
|
|----------|-------------|
|
|
135
|
-
| `PI_RESEARCH_API_KEY` / `PI_RESEARCH_PROVIDER` | Explicit LLM credentials for SDK / CLI mode (not needed when pi's configuration
|
|
135
|
+
| `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`. |
|
|
136
136
|
| `STACKEXCHANGE_API_KEY` | Raises the Stack Exchange tool's limit from 300/day to 10,000/day. Obtain at <https://stackapps.com/apps/oauth>. |
|
|
137
137
|
| `GITHUB_TOKEN` | Raises the security tool's GitHub Advisory limit from 60/hr to 5000/hr (any default-scope token). |
|
|
138
138
|
| `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. |
|
package/docs/KNOWLEDGE-STORE.md
CHANGED
|
@@ -174,9 +174,11 @@ built with, the store is migrated according to `PI_RESEARCH_MIGRATION_STRATEGY`:
|
|
|
174
174
|
| `drop` | The old table is discarded and a fresh one created. Fast; no backup. |
|
|
175
175
|
| `re-embed` | Every stored document is re-embedded with the new model into a new table, preserving history. Slowest. |
|
|
176
176
|
|
|
177
|
-
If
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
If `re-embed` fails, pi-research falls back to `backup`. A failed `backup` (or
|
|
178
|
+
`drop`) aborts the migration instead: the store stays on the old model and the
|
|
179
|
+
next open retries — data is never dropped unless `drop` was chosen explicitly.
|
|
180
|
+
Changing the model from the `/research-config` menu always clears the current
|
|
181
|
+
store and starts fresh.
|
|
180
182
|
|
|
181
183
|
### Managing the store
|
|
182
184
|
|
package/package.json
CHANGED
|
@@ -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.0.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.0.5", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
|
|
6
6
|
allowed-tools: Bash(node:*)
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -15,8 +15,8 @@ to configure (no research runs).
|
|
|
15
15
|
export PI_RESEARCH_MODEL=openai/gpt-4o # provider/model-id
|
|
16
16
|
```
|
|
17
17
|
2. **Global config** `~/.pi/research/config.env` (same KEY=VALUE keys; read at startup).
|
|
18
|
-
3. **pi configuration** (if you use `pi`): the API key comes from
|
|
19
|
-
automatically — `PI_RESEARCH_MODEL` must still be set (env or `config.env`).
|
|
18
|
+
3. **pi configuration** (if you use `pi`): the API key comes from your pi
|
|
19
|
+
configuration automatically — `PI_RESEARCH_MODEL` must still be set (env or `config.env`).
|
|
20
20
|
|
|
21
21
|
Real env vars always win. Optional CLI-only overlay `~/.pi/research/cli.env` layers
|
|
22
22
|
over `config.env` for the CLI only. Precedence:
|
package/src/cli.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { validateInitialLink, MAX_INITIAL_LINKS } from './utils/url-utils.ts';
|
|
|
39
39
|
import { exportResearchReport, appendExportMessage } from './utils/research-export.ts';
|
|
40
40
|
import { getConfig, getGlobalConfigDir, getGlobalEnvFilePath, getInterfaceEnvFilePath, saveConfig, resetConfig, describeKnowledgeStoreMode, isProjectScopedKey, parseDotEnv } from './config.ts';
|
|
41
41
|
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
42
|
+
import { buildModelRegistry, safeGetAvailable } from './core/llm/model-registry-factory.ts';
|
|
42
43
|
|
|
43
44
|
// ---------------------------------------------------------------------------
|
|
44
45
|
// Constants
|
|
@@ -226,13 +227,39 @@ function fileExists(p: string): boolean {
|
|
|
226
227
|
}
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Are keys usable from pi's configuration? Answered by pi's OWN semantics, not
|
|
232
|
+
* by file presence: build pi's model registry (two small JSON reads — no
|
|
233
|
+
* services, network, or native deps) and ask for the authed subset. That check
|
|
234
|
+
* (`hasConfiguredAuth`) covers every source the registry consults at call
|
|
235
|
+
* time: an auth.json provider entry (api-key or OAuth), a per-provider apiKey
|
|
236
|
+
* embedded in models.json (env-template aware), and provider env vars like
|
|
237
|
+
* OPENAI_API_KEY. File presence alone is neither necessary (keys can live in
|
|
238
|
+
* models.json / env with no auth.json) nor sufficient (auth.json can be `{}`).
|
|
239
|
+
*/
|
|
240
|
+
function piKeysAvailable(piAuthPresent: boolean, provider?: string): boolean {
|
|
241
|
+
try {
|
|
242
|
+
const available = safeGetAvailable(buildModelRegistry(undefined, undefined));
|
|
243
|
+
if (!provider) return available.length > 0;
|
|
244
|
+
// Provider known (from the configured provider/model-id): keys must exist
|
|
245
|
+
// for THAT provider — a key for some other provider would pass pre-flight
|
|
246
|
+
// and then die on the first LLM call ("No API key found for <provider>").
|
|
247
|
+
return available.some((m) => m.provider === provider);
|
|
248
|
+
} catch {
|
|
249
|
+
// Cannot inspect (e.g. malformed models.json) — fall back to file
|
|
250
|
+
// presence: pre-flight must never block a possibly-working setup; the run
|
|
251
|
+
// itself reports the precise error.
|
|
252
|
+
return piAuthPresent;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
229
256
|
export interface CredentialDetection {
|
|
230
257
|
/** Where credentials will come from once initResearchSDK runs. */
|
|
231
258
|
source: 'explicit-env' | 'pi-config' | 'none';
|
|
232
259
|
apiKeyConfigured: boolean;
|
|
233
260
|
provider?: string;
|
|
234
261
|
model?: string;
|
|
235
|
-
modelFrom: 'PI_RESEARCH_MODEL' | 'config.env' | 'unset';
|
|
262
|
+
modelFrom: '--model' | 'PI_RESEARCH_MODEL' | 'config.env' | 'unset';
|
|
236
263
|
piAuthPresent: boolean;
|
|
237
264
|
piModelsPresent: boolean;
|
|
238
265
|
/** A short, human-readable problem string, or undefined when ready. */
|
|
@@ -242,8 +269,17 @@ export interface CredentialDetection {
|
|
|
242
269
|
/**
|
|
243
270
|
* Detect how the SDK will authenticate WITHOUT initializing it. Used both for
|
|
244
271
|
* the `status` command and for the fast-fail pre-flight before every run.
|
|
272
|
+
*
|
|
273
|
+
* `explicitModel` is a per-run `--model` flag. It must participate in the
|
|
274
|
+
* pre-flight: it outranks PI_RESEARCH_MODEL/config for the run, so (a) it
|
|
275
|
+
* satisfies the model-required check on its own, and (b) when no explicit
|
|
276
|
+
* PI_RESEARCH_PROVIDER is set, provider-aware key detection derives the
|
|
277
|
+
* provider from the FLAG's model — otherwise a configured model on provider X
|
|
278
|
+
* greenlights a `--model` run on keyless provider Y (or vice versa wrongly
|
|
279
|
+
* blocks a valid `--model` run). An explicit PI_RESEARCH_PROVIDER still wins:
|
|
280
|
+
* it names the provider the configured key belongs to.
|
|
245
281
|
*/
|
|
246
|
-
function detectCredentials(): CredentialDetection {
|
|
282
|
+
function detectCredentials(explicitModel?: string): CredentialDetection {
|
|
247
283
|
const paths = resolvedConfigPaths();
|
|
248
284
|
const piAuthPresent = fileExists(paths.piAuth);
|
|
249
285
|
const piModelsPresent = fileExists(paths.piModels);
|
|
@@ -253,11 +289,14 @@ function detectCredentials(): CredentialDetection {
|
|
|
253
289
|
const modelEnv = process.env['PI_RESEARCH_MODEL'];
|
|
254
290
|
const modelConfig = getConfig(process.cwd(), 'cli').RESEARCH_MODEL;
|
|
255
291
|
|
|
256
|
-
const model = modelEnv ?? modelConfig;
|
|
257
|
-
const modelFrom = modelEnv ? 'PI_RESEARCH_MODEL' : modelConfig ? 'config.env' : 'unset';
|
|
292
|
+
const model = explicitModel ?? modelEnv ?? modelConfig;
|
|
293
|
+
const modelFrom = explicitModel ? '--model' : modelEnv ? 'PI_RESEARCH_MODEL' : modelConfig ? 'config.env' : 'unset';
|
|
258
294
|
// Provider is either explicit, or inferred from a "provider/id" model string.
|
|
259
295
|
const provider =
|
|
260
296
|
providerEnv ?? (model && model.includes('/') ? model.slice(0, model.indexOf('/')) : undefined);
|
|
297
|
+
// Provider-aware when the configured model names one: a key for a DIFFERENT
|
|
298
|
+
// provider must not greenlight this run.
|
|
299
|
+
const piKeysUsable = piKeysAvailable(piAuthPresent, provider);
|
|
261
300
|
|
|
262
301
|
// --- Explicit API-key path -------------------------------------------------
|
|
263
302
|
if (apiKey) {
|
|
@@ -286,7 +325,7 @@ function detectCredentials(): CredentialDetection {
|
|
|
286
325
|
piModelsPresent,
|
|
287
326
|
problem:
|
|
288
327
|
'PI_RESEARCH_API_KEY is set but no model is configured. Set PI_RESEARCH_MODEL to a ' +
|
|
289
|
-
'"provider/model-id" (e.g. openai/gpt-4o).',
|
|
328
|
+
'"provider/model-id" (e.g. openai/gpt-4o), or pass --model <provider/id> for a single run.',
|
|
290
329
|
};
|
|
291
330
|
}
|
|
292
331
|
return {
|
|
@@ -300,13 +339,13 @@ function detectCredentials(): CredentialDetection {
|
|
|
300
339
|
};
|
|
301
340
|
}
|
|
302
341
|
|
|
303
|
-
// --- pi configuration path (key from auth.json)
|
|
304
|
-
if (
|
|
305
|
-
// Keys come from
|
|
306
|
-
// the standalone CLI / agent skill run only on the configured
|
|
307
|
-
// never follow the model selected inside the pi extension, and
|
|
308
|
-
// falling back to the registry's first authed entry picks a model
|
|
309
|
-
// chose. Fail fast here (pre-flight, before any SDK init).
|
|
342
|
+
// --- pi configuration path (key from auth.json, models.json, or provider env) ---
|
|
343
|
+
if (piKeysUsable) {
|
|
344
|
+
// Keys come from pi's configuration, but the MODEL must be configured
|
|
345
|
+
// explicitly: the standalone CLI / agent skill run only on the configured
|
|
346
|
+
// model — they never follow the model selected inside the pi extension, and
|
|
347
|
+
// silently falling back to the registry's first authed entry picks a model
|
|
348
|
+
// nobody chose. Fail fast here (pre-flight, before any SDK init).
|
|
310
349
|
if (!model) {
|
|
311
350
|
return {
|
|
312
351
|
source: 'pi-config',
|
|
@@ -317,15 +356,16 @@ function detectCredentials(): CredentialDetection {
|
|
|
317
356
|
piAuthPresent,
|
|
318
357
|
piModelsPresent,
|
|
319
358
|
problem:
|
|
320
|
-
'pi credentials were found (
|
|
359
|
+
'pi credentials were found (in pi\'s configuration), but no research model is configured. ' +
|
|
321
360
|
'The standalone CLI / agent skill run only on an explicitly configured model — they do not ' +
|
|
322
361
|
'follow the model selected inside the pi extension. Set PI_RESEARCH_MODEL to a ' +
|
|
323
|
-
'"provider/model-id" (in the environment, or in config.env / cli.env below)
|
|
362
|
+
'"provider/model-id" (in the environment, or in config.env / cli.env below), or pass ' +
|
|
363
|
+
'--model <provider/id> for a single run.',
|
|
324
364
|
};
|
|
325
365
|
}
|
|
326
366
|
return {
|
|
327
367
|
source: 'pi-config',
|
|
328
|
-
apiKeyConfigured: true, // key lives in
|
|
368
|
+
apiKeyConfigured: true, // key lives in pi's configuration; the SDK reads it at call time
|
|
329
369
|
provider,
|
|
330
370
|
model,
|
|
331
371
|
modelFrom,
|
|
@@ -346,7 +386,8 @@ function detectCredentials(): CredentialDetection {
|
|
|
346
386
|
problem:
|
|
347
387
|
'No model or API key is configured for pi-research. Provide credentials via environment ' +
|
|
348
388
|
'variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use ' +
|
|
349
|
-
|
|
389
|
+
"pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider " +
|
|
390
|
+
'apiKey in ~/.pi/agent/models.json).',
|
|
350
391
|
};
|
|
351
392
|
}
|
|
352
393
|
|
|
@@ -370,8 +411,8 @@ function configBlock(det: CredentialDetection, extraNote?: string): string {
|
|
|
370
411
|
lines.push(` • api key configured: ${det.apiKeyConfigured}`);
|
|
371
412
|
lines.push(` • provider: ${det.provider ?? '(unset — inferred from a provider/model-id model)'}`);
|
|
372
413
|
lines.push(` • model: ${det.model ? `${det.model} [from: ${det.modelFrom}]` : '(not set — set PI_RESEARCH_MODEL, required)'}`);
|
|
373
|
-
lines.push(` • pi auth.json:
|
|
374
|
-
lines.push(` • pi models.json:
|
|
414
|
+
lines.push(` • pi config (auth.json): ${det.piAuthPresent ? 'present' : 'absent'}`);
|
|
415
|
+
lines.push(` • pi config (models.json): ${det.piModelsPresent ? 'present' : 'absent'}`);
|
|
375
416
|
if (extraNote) lines.push(` • note: ${extraNote}`);
|
|
376
417
|
return lines.join('\n');
|
|
377
418
|
}
|
|
@@ -394,7 +435,9 @@ interface ResearchArgs {
|
|
|
394
435
|
* markdown report to stdout, and always shuts down (even on failure).
|
|
395
436
|
*/
|
|
396
437
|
async function cmdResearch(args: ResearchArgs): Promise<number> {
|
|
397
|
-
|
|
438
|
+
// Pre-flight with the per-run --model flag folded in: it satisfies the
|
|
439
|
+
// model-required check and steers the provider-aware key detection.
|
|
440
|
+
const det = detectCredentials(args.model);
|
|
398
441
|
if (det.problem) {
|
|
399
442
|
if (args.json) toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG }));
|
|
400
443
|
else toStderr(`\nError: ${det.problem}\n\n${configBlock(det)}\n`);
|
|
@@ -416,8 +459,8 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
|
|
|
416
459
|
|
|
417
460
|
const depth: 0 | 1 | 2 | 3 =
|
|
418
461
|
(args.depth ?? getConfig(process.cwd(), 'cli').DEFAULT_RESEARCH_DEPTH) as 0 | 1 | 2 | 3;
|
|
419
|
-
//
|
|
420
|
-
const runModel =
|
|
462
|
+
// det.model already reflects --model-first precedence (detectCredentials above).
|
|
463
|
+
const runModel = det.model;
|
|
421
464
|
toStderr(`[pi-research] starting research (depth ${depth})${runModel ? ` with ${runModel}` : ''}…\n`);
|
|
422
465
|
|
|
423
466
|
// Stream concise lifecycle progress to stderr so a long run is never silent.
|
|
@@ -763,8 +806,12 @@ function reportError(err: unknown, what: string, json?: boolean): number {
|
|
|
763
806
|
lower.includes('unauthorized') ||
|
|
764
807
|
lower.includes('api key');
|
|
765
808
|
|
|
809
|
+
// Rate limits win over config-shaped matches: a provider 429 often mentions
|
|
810
|
+
// "api key" in its body (e.g. "rate limit exceeded for this api key"), and
|
|
811
|
+
// classifying that as a setup problem sends the user to reconfigure a
|
|
812
|
+
// perfectly working key instead of waiting out the limit.
|
|
766
813
|
const isRateLimit = lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests');
|
|
767
|
-
const exitCode = isConfigError ? EXIT.CONFIG : EXIT.SOFTWARE;
|
|
814
|
+
const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
|
|
768
815
|
|
|
769
816
|
// --json: emit a structured error on stdout so a machine consumer never has to
|
|
770
817
|
// special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
|
|
@@ -777,15 +824,15 @@ function reportError(err: unknown, what: string, json?: boolean): number {
|
|
|
777
824
|
return exitCode;
|
|
778
825
|
}
|
|
779
826
|
|
|
780
|
-
|
|
781
|
-
toStderr(`\nError: pi-research ${what} failed: ${msg}\n\n${configBlock(detectCredentials())}\n`);
|
|
782
|
-
return exitCode;
|
|
783
|
-
}
|
|
784
|
-
// Rate limits are operational, not fatal setup problems.
|
|
827
|
+
// Rate limits are operational, not fatal setup problems — checked FIRST.
|
|
785
828
|
if (isRateLimit) {
|
|
786
829
|
toStderr(`\nError: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.\n ${msg}\n`);
|
|
787
830
|
return exitCode;
|
|
788
831
|
}
|
|
832
|
+
if (isConfigError) {
|
|
833
|
+
toStderr(`\nError: pi-research ${what} failed: ${msg}\n\n${configBlock(detectCredentials())}\n`);
|
|
834
|
+
return exitCode;
|
|
835
|
+
}
|
|
789
836
|
toStderr(`\nError: pi-research ${what} failed: ${msg}\n`);
|
|
790
837
|
if (process.env['PI_RESEARCH_DEBUG'] === 'true' && err instanceof Error && err.stack) {
|
|
791
838
|
toStderr('\n' + err.stack + '\n');
|
|
@@ -842,7 +889,7 @@ USAGE
|
|
|
842
889
|
COMMANDS
|
|
843
890
|
research "<query>" Run multi-agent web research (default depth: 1).
|
|
844
891
|
--depth <0-3> 0 = quick, 1 = normal (default), 2 = deep, 3 = ultra.
|
|
845
|
-
--model <provider/id>
|
|
892
|
+
--model <provider/id> Model for this run (outranks PI_RESEARCH_MODEL; satisfies the required-model check).
|
|
846
893
|
--exclude-tools <a,b> Disable internal tools (e.g. security_search,stackexchange).
|
|
847
894
|
--initial-links <url ...> Seed URLs to investigate first; requires a query (-- ends options).
|
|
848
895
|
--config <path> Use this config file instead of the base config.env.
|
|
@@ -879,9 +926,9 @@ CONFIGURE
|
|
|
879
926
|
pi config (keys): ${p.piAuth}
|
|
880
927
|
pi config (models): ${p.piModels}
|
|
881
928
|
|
|
882
|
-
A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id"
|
|
883
|
-
the agent skill run only on that configured
|
|
884
|
-
selected inside the pi extension.
|
|
929
|
+
A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id" (or pass
|
|
930
|
+
--model per run). This CLI and the agent skill run only on that configured
|
|
931
|
+
model — they do not follow the model selected inside the pi extension.
|
|
885
932
|
|
|
886
933
|
The pi extension has its own optional overlay file:
|
|
887
934
|
pi extension: ${p.piIfaceEnv}
|
|
@@ -123,12 +123,13 @@ export function buildModelRegistry(apiKey?: string, provider?: string): ModelReg
|
|
|
123
123
|
);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
// No explicit key: use the user's pi
|
|
126
|
+
// No explicit key: use the user's pi configuration when EITHER file exists.
|
|
127
127
|
// ModelRegistry.create() reads embedded apiKeys from models.json providers
|
|
128
|
-
// directly (via providerRequestConfigs), so
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
128
|
+
// directly (via providerRequestConfigs), so custom providers with embedded
|
|
129
|
+
// keys work with no auth.json at all — auth.json presence must not gate
|
|
130
|
+
// models.json (AuthStorage.create tolerates a missing file). Env provider
|
|
131
|
+
// keys (e.g. OPENAI_API_KEY) are honored by AuthStorage in every branch.
|
|
132
|
+
if (fs.existsSync(authPath) || fs.existsSync(modelsJsonPath)) {
|
|
132
133
|
const authStorage = AuthStorage.create(authPath);
|
|
133
134
|
return ModelRegistry.create(
|
|
134
135
|
authStorage,
|
|
@@ -152,7 +153,17 @@ export function constructMinimalModel(provider: string, modelId: string, _apiKey
|
|
|
152
153
|
provider,
|
|
153
154
|
id: modelId,
|
|
154
155
|
name: modelId,
|
|
155
|
-
|
|
156
|
+
// Map to a REAL pi-ai api id — "<provider>-conversations" is not one, and a
|
|
157
|
+
// keyed no-registry setup would pass pre-flight then die on the first call.
|
|
158
|
+
// Unknown providers default to the OpenAI-compatible protocol, which is what
|
|
159
|
+
// virtually every third-party endpoint (openrouter, groq, cerebras, …) speaks.
|
|
160
|
+
api: ({
|
|
161
|
+
openai: 'openai-completions',
|
|
162
|
+
anthropic: 'anthropic-messages',
|
|
163
|
+
google: 'google-generative-ai',
|
|
164
|
+
gemini: 'google-generative-ai',
|
|
165
|
+
mistral: 'mistral-conversations',
|
|
166
|
+
} as Record<string, string>)[provider] ?? 'openai-completions',
|
|
156
167
|
baseUrl: '', // Provider-specific base URLs are handled by pi-ai internal registry
|
|
157
168
|
reasoning: false,
|
|
158
169
|
input: ['text'],
|
package/src/knowledge/store.ts
CHANGED
|
@@ -267,10 +267,12 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
267
267
|
if (strategy !== 'drop' && strategy !== 'backup') {
|
|
268
268
|
logger.warn('[store] Falling back to backup strategy after migration failure');
|
|
269
269
|
await this.handleModelChange(storedModel || 'unknown', this.options.modelName, 'backup');
|
|
270
|
-
} else if (strategy === 'backup') {
|
|
271
|
-
logger.warn('[store] Falling back to drop strategy after backup failure');
|
|
272
|
-
await this.handleModelChange(storedModel || 'unknown', this.options.modelName, 'drop');
|
|
273
270
|
} else {
|
|
271
|
+
// 'backup' failure must ABORT, never escalate to 'drop': backup is the
|
|
272
|
+
// data-preserving strategy, and silently destroying every row after a
|
|
273
|
+
// (possibly transient) failure is the opposite of what the user asked
|
|
274
|
+
// for. The store stays on the old model; the next open() retries.
|
|
275
|
+
// 'drop' failure has nowhere further to fall.
|
|
274
276
|
throw new Error(errorMsg, { cause: err });
|
|
275
277
|
}
|
|
276
278
|
}
|
|
@@ -332,8 +334,16 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
332
334
|
throw new Error('Table not connected');
|
|
333
335
|
}
|
|
334
336
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
+
// Best-effort: the count is only used for logging and the result summary, so a
|
|
338
|
+
// transient countRows() failure must not abort the backup (previously it threw
|
|
339
|
+
// while the fallback ladder could still escalate to a data-destroying drop).
|
|
340
|
+
let count = -1;
|
|
341
|
+
try {
|
|
342
|
+
count = await this.table.countRows();
|
|
343
|
+
} catch (err) {
|
|
344
|
+
logger.warn(`[store] countRows failed before backup (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
337
347
|
// Close current table handle
|
|
338
348
|
this.table = null;
|
|
339
349
|
|
|
@@ -355,9 +365,9 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
355
365
|
this.table = await this.createTable();
|
|
356
366
|
// Table name unchanged (still canonical 'knowledge'), but persist to be safe.
|
|
357
367
|
await this.saveManifest();
|
|
358
|
-
logger.info(`[store] Migration complete: ${count} documents backed up, fresh table created with model ${newModel}`);
|
|
368
|
+
logger.info(`[store] Migration complete: ${count >= 0 ? count : 'unknown count of'} documents backed up, fresh table created with model ${newModel}`);
|
|
359
369
|
|
|
360
|
-
return { strategy: 'backup', success: true, documentsProcessed: count };
|
|
370
|
+
return { strategy: 'backup', success: true, documentsProcessed: Math.max(0, count) };
|
|
361
371
|
}
|
|
362
372
|
|
|
363
373
|
private async migrationDrop(_oldModel: string, newModel: string): Promise<MigrationResult> {
|
|
@@ -9,7 +9,7 @@ import { type Model } from '@earendil-works/pi-ai';
|
|
|
9
9
|
import { logger } from '../logger.ts';
|
|
10
10
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
11
11
|
import { metrics } from '../utils/metrics.ts';
|
|
12
|
-
import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages } from './session-state.ts';
|
|
12
|
+
import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages, getFailedResearchers, getResearcherFailureReasons } from './session-state.ts';
|
|
13
13
|
import { MAX_EXTRA_ROUNDS_WITH_STEERING } from '../constants.ts';
|
|
14
14
|
import {
|
|
15
15
|
getMaxRounds,
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
import type {
|
|
18
18
|
ResearchObserver,
|
|
19
19
|
} from '../core/interfaces/observer-interfaces.ts';
|
|
20
|
-
import { HeadlessObserver, type HeadlessObserverOptions } from './headless-observer.ts';
|
|
20
|
+
import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
|
|
21
21
|
import {
|
|
22
22
|
ServiceNames,
|
|
23
23
|
type IResearchOrchestration,
|
|
@@ -61,11 +61,12 @@ export class DeepResearchOrchestrator {
|
|
|
61
61
|
constructor(private options: DeepResearchOrchestratorOptions) {
|
|
62
62
|
this.config = options.config || getConfig(options.ctx.cwd);
|
|
63
63
|
|
|
64
|
-
// Resolve observer: if options were provided instead of an instance, create the instance
|
|
64
|
+
// Resolve observer: if options were provided instead of an instance, create the instance.
|
|
65
|
+
// Either way, wrap it so a throwing observer callback can never fail the run.
|
|
65
66
|
if (options.observer && typeof (options.observer as any).onProgress === 'function' && !(options.observer instanceof HeadlessObserver)) {
|
|
66
|
-
this.observer = new HeadlessObserver(options.observer as HeadlessObserverOptions);
|
|
67
|
+
this.observer = makeSafeObserver(new HeadlessObserver(options.observer as HeadlessObserverOptions));
|
|
67
68
|
} else {
|
|
68
|
-
this.observer = options.observer as ResearchObserver
|
|
69
|
+
this.observer = options.observer ? makeSafeObserver(options.observer as ResearchObserver) : undefined;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
if (options.orchestrationService) {
|
|
@@ -478,15 +479,40 @@ export class DeepResearchOrchestrator {
|
|
|
478
479
|
}
|
|
479
480
|
|
|
480
481
|
let result = finalReport.content || '';
|
|
481
|
-
|
|
482
|
-
//
|
|
482
|
+
|
|
483
|
+
// ZERO reports collected AND researchers actually failed — the run attempted
|
|
484
|
+
// research and produced nothing groundable. This gate is deliberately
|
|
485
|
+
// independent of the synthesis text: with the default single-researcher plan
|
|
486
|
+
// the fast-stop threshold (MAX_FAILED_RESEARCHERS = 2) never trips, so a lone
|
|
487
|
+
// researcher that exhausts its retries (e.g. a provider rate limit) reaches
|
|
488
|
+
// synthesis with no source material. Whether the LLM then returns an empty
|
|
489
|
+
// string OR chatty refusal prose ("no information was found"), that is a
|
|
490
|
+
// FAILED run: throw, and surface the recorded root causes so the user sees
|
|
491
|
+
// the 429/model error, not a shrug (and never a source-free report shipped
|
|
492
|
+
// as success). The failed-researcher condition keeps the designed
|
|
493
|
+
// immediate-synthesis path intact: a coordinator that chose to answer
|
|
494
|
+
// directly (no delegation, no failures) is not a failed run.
|
|
495
|
+
const synthesisCheckService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
496
|
+
const hasReports = synthesisCheckService.hasReports(researchId);
|
|
497
|
+
const failedIds = getFailedResearchers(this.options.sessionId, researchId);
|
|
498
|
+
if (!hasReports && failedIds.length > 0) {
|
|
499
|
+
const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
|
|
500
|
+
const causes = failedIds
|
|
501
|
+
.map((id) => `researcher ${id}: ${reasons[id] ?? 'no usable report'}`)
|
|
502
|
+
.join('; ');
|
|
503
|
+
throw new Error(
|
|
504
|
+
`Research produced no report — no source material was collected. Causes: ${causes}`
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
483
508
|
if (!result.trim()) {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${
|
|
487
|
-
result =
|
|
509
|
+
if (hasReports) {
|
|
510
|
+
// Content is empty but reports exist — build a fallback synthesis.
|
|
511
|
+
logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisCheckService.getReportCount(researchId)} reports`);
|
|
512
|
+
result = synthesisCheckService.buildFallbackSynthesis(researchId, this.currentRound);
|
|
488
513
|
} else {
|
|
489
|
-
|
|
514
|
+
// No reports, no recorded failures, and nothing to ship either.
|
|
515
|
+
throw new Error('Research produced no report — no source material was collected.');
|
|
490
516
|
}
|
|
491
517
|
}
|
|
492
518
|
|
|
@@ -12,6 +12,51 @@ import type { ResearchPlan } from '../core/interfaces/research-plan-types.ts';
|
|
|
12
12
|
|
|
13
13
|
export type { HeadlessObserverOptions };
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Wrap an observer so a throwing (or rejecting) callback can never corrupt a
|
|
17
|
+
* research run. Observer methods are fire-and-forget notifications, but the
|
|
18
|
+
* orchestrators invoke them inline at load-bearing points (researcher
|
|
19
|
+
* completion, evaluation decisions, synthesis start) — an exception from
|
|
20
|
+
* user-supplied SDK/TUI observer code would otherwise surface as a *research*
|
|
21
|
+
* failure. HeadlessObserver already isolates its own onProgress callback; this
|
|
22
|
+
* extends the same isolation to raw ResearchObserver implementations. Sync
|
|
23
|
+
* throws are logged and swallowed; returned promises get a .catch so
|
|
24
|
+
* rejections never become unhandled.
|
|
25
|
+
*/
|
|
26
|
+
export function makeSafeObserver<T extends object>(observer: T): T {
|
|
27
|
+
return new Proxy(observer, {
|
|
28
|
+
get(target, prop) {
|
|
29
|
+
// Guard the ACCESS too, and resolve getters against the target (not the
|
|
30
|
+
// proxy): an observer exposing a method via an accessor that touches a
|
|
31
|
+
// private field would otherwise throw at `observer?.onX?.(...)` property
|
|
32
|
+
// access — outside the call-site try/catch below — which is exactly the
|
|
33
|
+
// failure this wrapper exists to absorb.
|
|
34
|
+
let value: unknown;
|
|
35
|
+
try {
|
|
36
|
+
value = Reflect.get(target, prop, target);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
logger.warn(`[observer] Ignored error reading observer.${String(prop)}:`, err);
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (typeof value !== 'function') return value;
|
|
42
|
+
return (...args: unknown[]) => {
|
|
43
|
+
try {
|
|
44
|
+
const out = value.apply(target, args);
|
|
45
|
+
if (out && typeof (out as Promise<unknown>).then === 'function') {
|
|
46
|
+
return (out as Promise<unknown>).catch((err: unknown) => {
|
|
47
|
+
logger.warn(`[observer] Ignored rejection from observer.${String(prop)}:`, err);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
logger.warn(`[observer] Ignored error from observer.${String(prop)}:`, err);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
}) as T;
|
|
58
|
+
}
|
|
59
|
+
|
|
15
60
|
export class HeadlessObserver implements ResearchObserver {
|
|
16
61
|
constructor(private options: HeadlessObserverOptions = {}) {}
|
|
17
62
|
|
|
@@ -20,7 +20,7 @@ import { createResearcherSession } from './researcher.ts';
|
|
|
20
20
|
import { ensureAssistantResponse, parseCitations } from '../utils/text-utils.ts';
|
|
21
21
|
import { getMaxScrapeBatches } from '../constants.ts';
|
|
22
22
|
import type { ResearchObserver } from '../core/interfaces/observer-interfaces.ts';
|
|
23
|
-
import { HeadlessObserver, type HeadlessObserverOptions } from './headless-observer.ts';
|
|
23
|
+
import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
|
|
24
24
|
import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
|
|
25
25
|
import { ServiceNames, type IWriterQueue, type IKnowledgeStoreService, type IResearchSynthesisService, type IResearchOrchestration } from '../core/service-interfaces.ts';
|
|
26
26
|
import type { ResearchSessionService } from './research-session-service.ts';
|
|
@@ -51,11 +51,12 @@ export class QuickResearchOrchestrator {
|
|
|
51
51
|
constructor(private options: QuickResearchOrchestratorOptions) {
|
|
52
52
|
this.config = options.config || getConfig(options.ctx.cwd);
|
|
53
53
|
|
|
54
|
-
// Resolve observer: if options were provided instead of an instance, create the instance
|
|
54
|
+
// Resolve observer: if options were provided instead of an instance, create the instance.
|
|
55
|
+
// Either way, wrap it so a throwing observer callback can never fail the run.
|
|
55
56
|
if (options.observer && typeof (options.observer as any).onProgress === 'function' && !(options.observer instanceof HeadlessObserver)) {
|
|
56
|
-
this.observer = new HeadlessObserver(options.observer as HeadlessObserverOptions);
|
|
57
|
+
this.observer = makeSafeObserver(new HeadlessObserver(options.observer as HeadlessObserverOptions));
|
|
57
58
|
} else {
|
|
58
|
-
this.observer = options.observer as ResearchObserver
|
|
59
|
+
this.observer = options.observer ? makeSafeObserver(options.observer as ResearchObserver) : undefined;
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -358,8 +358,9 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
358
358
|
logger.debug(`[ResearchOrchestrationService] Researcher ${id} cancelled (aborted).`);
|
|
359
359
|
} else {
|
|
360
360
|
logger.error(`[ResearchOrchestrationService] Researcher ${id} failed: ${errMsg}`);
|
|
361
|
-
// Record failure for stopping logic
|
|
362
|
-
|
|
361
|
+
// Record failure (with its root cause) for stopping logic and for the
|
|
362
|
+
// zero-report failure surfaced at synthesis time.
|
|
363
|
+
recordResearcherFailure(sessionId, researchId, id, errMsg);
|
|
363
364
|
// Notify observer
|
|
364
365
|
observer?.onResearcherFailure?.(id, errMsg);
|
|
365
366
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { parseCitations } from '../utils/text-utils.ts';
|
|
13
13
|
import { normalizeCitations, formatCitedLinks, type GlobalCitation } from '../utils/citation-utils.ts';
|
|
14
14
|
import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
|
|
15
|
-
import { getScrapedLinks, normalizeUrl } from '../utils/shared-links.ts';
|
|
15
|
+
import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
|
|
16
16
|
import { logger } from '../logger.ts';
|
|
17
17
|
import { ServiceLifecycle, type IService } from '../core/service-registry.ts';
|
|
18
18
|
import { ServiceNames } from '../core/interfaces/service-names.ts';
|
|
@@ -188,9 +188,17 @@ export class ResearchSynthesisService implements IService {
|
|
|
188
188
|
if (globalCitations.length === 0) {
|
|
189
189
|
const scraped = getScrapedLinks(sessionId);
|
|
190
190
|
if (scraped.length > 0) {
|
|
191
|
-
|
|
191
|
+
// Label per actual provenance: the pool also contains transcript watch
|
|
192
|
+
// URLs the youtube_transcript tool registered — calling those "Scrape"
|
|
193
|
+
// misattributes the report's real source material.
|
|
194
|
+
globalCitations = scraped.map((url, i) => ({
|
|
195
|
+
id: i + 1,
|
|
196
|
+
url,
|
|
197
|
+
description: '',
|
|
198
|
+
source: isTranscribedLink(sessionId, url) ? 'YouTube Transcript' : 'Scrape',
|
|
199
|
+
}));
|
|
192
200
|
logger.warn(
|
|
193
|
-
`[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape provenance.`,
|
|
201
|
+
`[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape/transcript provenance.`,
|
|
194
202
|
);
|
|
195
203
|
}
|
|
196
204
|
}
|
|
@@ -69,7 +69,7 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
69
69
|
// use), recording under ctx.sessionId would file this no-links skip under a
|
|
70
70
|
// different id than shouldStopResearch() checks, so the fast-stop guard would
|
|
71
71
|
// under-count exactly the broad-search-failure case it exists to catch.
|
|
72
|
-
recordResearcherFailure(sessionId, researchId, id);
|
|
72
|
+
recordResearcherFailure(sessionId, researchId, id, 'no initial search results or historical links (search produced nothing usable)');
|
|
73
73
|
metrics.increment('researcher_skipped_total', 1, { mode: 'deep', complexity: String(complexity), reason: 'no_initial_links' });
|
|
74
74
|
observer?.onResearcherFailure?.(id, 'No initial search results or historical links available');
|
|
75
75
|
return;
|