@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.
@@ -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.
@@ -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 — `~/.pi/agent/auth.json` — 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`. |
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. |
@@ -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 the chosen strategy fails, pi-research falls back to `backup`, then to `drop`,
178
- rather than leaving the store in a broken state. Changing the model from the
179
- `/research-config` menu always clears the current store and starts fresh.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "A set of web research tools (search, scrape, and others) and research agent system that uses them safely and efficiently.",
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.0.3", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
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 `~/.pi/agent/auth.json`
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 (piAuthPresent) {
305
- // Keys come from auth.json, but the MODEL must be configured explicitly:
306
- // the standalone CLI / agent skill run only on the configured model — they
307
- // never follow the model selected inside the pi extension, and silently
308
- // falling back to the registry's first authed entry picks a model nobody
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 (auth.json), but no research model is configured. ' +
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 auth.json; SDK reads it
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
- 'pi, via ~/.pi/agent/auth.json.',
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: ${det.piAuthPresent ? 'present' : 'absent'}`);
374
- lines.push(` • pi models.json: ${det.piModelsPresent ? 'present' : 'absent'}`);
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
- const det = detectCredentials();
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
- // An explicit --model wins over configured/PI_RESEARCH_MODEL for this run.
420
- const runModel = args.model ?? det.model;
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
- if (isConfigError) {
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> Override the model for this run.
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". This CLI and
883
- the agent skill run only on that configured model — they do not follow the model
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 auth storage and model list if available.
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 glm-coding and other custom
129
- // providers with embedded keys are visible to hasConfiguredAuth() without
130
- // needing to duplicate them into AuthStorage.
131
- if (fs.existsSync(authPath)) {
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
- api: provider === 'openai' ? 'openai-completions' : (provider + '-conversations' as any),
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'],
@@ -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
- const count = await this.table.countRows();
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 | undefined;
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
- // If content is empty but we have reports, build a fallback synthesis
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
- const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
485
- if (synthesisService.hasReports(researchId)) {
486
- logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisService.getReportCount(researchId)} reports`);
487
- result = synthesisService.buildFallbackSynthesis(researchId, this.currentRound);
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
- result = 'Research completed but no summary was generated.';
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 | undefined;
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
- recordResearcherFailure(sessionId, researchId, id);
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
- globalCitations = scraped.map((url, i) => ({ id: i + 1, url, description: '', source: 'Scrape' }));
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;