@lincoln504/pi-research 1.0.2 → 1.0.4

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 CHANGED
@@ -63,7 +63,7 @@ pi install npm:@lincoln504/pi-research
63
63
 
64
64
  See [Agent skill](docs/AGENT-SKILL.md) or [SDK](docs/SDK.md) for standalone (no pi extension) installation.
65
65
 
66
- Then configure a model and key — set `PI_RESEARCH_MODEL` (e.g. `openai/gpt-4o`) plus `PI_RESEARCH_API_KEY` and `PI_RESEARCH_PROVIDER`, either as environment variables or in `~/.pi/research/config.env`. See [Configuration](docs/CONFIGURATION.md). (The pi extension needs no key — it uses pi's own auth.)
66
+ The pi extension works out of the box — it runs on your pi session's model and pi's own configuration. Standalone use (agent skill / CLI / SDK) requires a configured model: set `PI_RESEARCH_MODEL` (e.g. `openai/gpt-4o`) as an environment variable or in `~/.pi/research/config.env`, plus `PI_RESEARCH_API_KEY` if you don't use pi. See [Configuration](docs/CONFIGURATION.md).
67
67
 
68
68
  The first install pulls the stealth browser engine, which takes a few minutes.
69
69
 
package/dist/cli.mjs CHANGED
@@ -3252,7 +3252,7 @@ function buildModelRegistry(apiKey, provider) {
3252
3252
  fs5.existsSync(modelsJsonPath) ? modelsJsonPath : void 0
3253
3253
  );
3254
3254
  }
3255
- if (fs5.existsSync(authPath)) {
3255
+ if (fs5.existsSync(authPath) || fs5.existsSync(modelsJsonPath)) {
3256
3256
  const authStorage = AuthStorage.create(authPath);
3257
3257
  return ModelRegistry.create(
3258
3258
  authStorage,
@@ -3266,7 +3266,17 @@ function constructMinimalModel(provider, modelId, _apiKey) {
3266
3266
  provider,
3267
3267
  id: modelId,
3268
3268
  name: modelId,
3269
- api: provider === "openai" ? "openai-completions" : provider + "-conversations",
3269
+ // Map to a REAL pi-ai api id — "<provider>-conversations" is not one, and a
3270
+ // keyed no-registry setup would pass pre-flight then die on the first call.
3271
+ // Unknown providers default to the OpenAI-compatible protocol, which is what
3272
+ // virtually every third-party endpoint (openrouter, groq, cerebras, …) speaks.
3273
+ api: {
3274
+ openai: "openai-completions",
3275
+ anthropic: "anthropic-messages",
3276
+ google: "google-generative-ai",
3277
+ gemini: "google-generative-ai",
3278
+ mistral: "mistral-conversations"
3279
+ }[provider] ?? "openai-completions",
3270
3280
  baseUrl: "",
3271
3281
  // Provider-specific base URLs are handled by pi-ai internal registry
3272
3282
  reasoning: false,
@@ -19812,6 +19822,7 @@ function getPiState(piSessionId) {
19812
19822
  if (!state) {
19813
19823
  state = {
19814
19824
  failures: /* @__PURE__ */ new Map(),
19825
+ failureReasons: /* @__PURE__ */ new Map(),
19815
19826
  order: [],
19816
19827
  panels: /* @__PURE__ */ new Map(),
19817
19828
  aborts: /* @__PURE__ */ new Map(),
@@ -19883,6 +19894,7 @@ function refreshAllSessions(piSessionId) {
19883
19894
  for (const id of state.order) {
19884
19895
  if (!validIds.includes(id)) {
19885
19896
  state.failures.delete(id);
19897
+ state.failureReasons.delete(id);
19886
19898
  state.panels.delete(id);
19887
19899
  }
19888
19900
  }
@@ -19914,6 +19926,7 @@ function endResearchSession(piSessionId, researchId) {
19914
19926
  const state = piSessions.get(sid);
19915
19927
  if (!state) return;
19916
19928
  state.failures.delete(researchId);
19929
+ state.failureReasons.delete(researchId);
19917
19930
  state.panels.delete(researchId);
19918
19931
  state.aborts.delete(researchId);
19919
19932
  const index = state.order.indexOf(researchId);
@@ -19931,6 +19944,7 @@ function endResearchSession(piSessionId, researchId) {
19931
19944
  piSessions.delete(sid);
19932
19945
  } else {
19933
19946
  state.failures.clear();
19947
+ state.failureReasons.clear();
19934
19948
  state.aborts.clear();
19935
19949
  }
19936
19950
  }
@@ -19952,12 +19966,23 @@ function clearAllSessionState() {
19952
19966
  clearAllSharedLinks();
19953
19967
  logger.log("[session-state] All session state cleared");
19954
19968
  }
19955
- function recordResearcherFailure(piSessionId, researchId, researcherId) {
19969
+ function recordResearcherFailure(piSessionId, researchId, researcherId, reason) {
19956
19970
  const sid = normalizeSessionId(piSessionId);
19957
19971
  const state = getPiState(sid);
19958
19972
  const failures = state.failures.get(researchId) || [];
19959
19973
  failures.push(researcherId);
19960
19974
  state.failures.set(researchId, failures);
19975
+ if (reason) {
19976
+ const reasons = state.failureReasons.get(researchId) ?? /* @__PURE__ */ new Map();
19977
+ if (!reasons.has(researcherId)) reasons.set(researcherId, reason);
19978
+ state.failureReasons.set(researchId, reasons);
19979
+ }
19980
+ }
19981
+ function getResearcherFailureReasons(piSessionId, researchId) {
19982
+ const sid = normalizeSessionId(piSessionId);
19983
+ const state = getPiState(sid);
19984
+ const reasons = state.failureReasons.get(researchId);
19985
+ return reasons ? Object.fromEntries(reasons) : {};
19961
19986
  }
19962
19987
  function getFailedResearchers(piSessionId, researchId) {
19963
19988
  const sid = normalizeSessionId(piSessionId);
@@ -19974,9 +19999,13 @@ function getResearchStopMessage(piSessionId, researchId) {
19974
19999
  const sid = normalizeSessionId(piSessionId);
19975
20000
  const failed = getFailedResearchers(sid, researchId);
19976
20001
  const count = failed.length;
20002
+ const state = getPiState(sid);
20003
+ const reasons = state.failureReasons.get(researchId);
20004
+ const recorded = reasons && reasons.size > 0 ? ["Recorded errors:", ...[...reasons].map(([id, r]) => `\u2022 researcher ${id}: ${r}`), ""] : [];
19977
20005
  return [
19978
20006
  `Research stopped: ${count} researcher(s) did not return a usable report: ${failed.join(", ")}.`,
19979
20007
  "",
20008
+ ...recorded,
19980
20009
  "Most likely cause \u2014 the research model could not produce grounded results:",
19981
20010
  '\u2022 Try a more capable research model. Very small or "thinking-only" models often',
19982
20011
  " emit no final text after their tool calls, which fails every researcher.",
@@ -20026,7 +20055,7 @@ ${currentPlan.allQueries.map((q) => `- ${q}`).join("\n")}
20026
20055
  const researcherPromptTemplate = loadPrompt("researcher");
20027
20056
  if (initialLinks.length === 0 && historicalUrls.length === 0) {
20028
20057
  logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results or historical links; skipping.`);
20029
- recordResearcherFailure(sessionId, researchId, id);
20058
+ recordResearcherFailure(sessionId, researchId, id, "no initial search results or historical links (search produced nothing usable)");
20030
20059
  metrics.increment("researcher_skipped_total", 1, { mode: "deep", complexity: String(complexity), reason: "no_initial_links" });
20031
20060
  observer?.onResearcherFailure?.(id, "No initial search results or historical links available");
20032
20061
  return;
@@ -20966,7 +20995,12 @@ var DeepResearchOrchestrator = class {
20966
20995
  logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisService2.getReportCount(researchId)} reports`);
20967
20996
  result = synthesisService2.buildFallbackSynthesis(researchId, this.currentRound);
20968
20997
  } else {
20969
- result = "Research completed but no summary was generated.";
20998
+ const failedIds = getFailedResearchers(this.options.sessionId, researchId);
20999
+ const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
21000
+ const causes = failedIds.map((id) => `researcher ${id}: ${reasons[id] ?? "no usable report"}`).join("; ");
21001
+ throw new Error(
21002
+ "Research produced no report \u2014 no source material was collected" + (causes ? `. Causes: ${causes}` : ".")
21003
+ );
20970
21004
  }
20971
21005
  }
20972
21006
  try {
@@ -21268,7 +21302,7 @@ var ResearchOrchestrationService = class {
21268
21302
  logger.debug(`[ResearchOrchestrationService] Researcher ${id} cancelled (aborted).`);
21269
21303
  } else {
21270
21304
  logger.error(`[ResearchOrchestrationService] Researcher ${id} failed: ${errMsg}`);
21271
- recordResearcherFailure(sessionId, researchId, id);
21305
+ recordResearcherFailure(sessionId, researchId, id, errMsg);
21272
21306
  observer?.onResearcherFailure?.(id, errMsg);
21273
21307
  }
21274
21308
  }
@@ -22000,14 +22034,19 @@ async function _doInit(options = {}) {
22000
22034
  }
22001
22035
  globalApiKey = options.apiKey || process.env["PI_RESEARCH_API_KEY"];
22002
22036
  let parsedProvider = options.provider || process.env["PI_RESEARCH_PROVIDER"];
22003
- if (!parsedProvider && options.model) {
22004
- if (typeof options.model === "string") {
22005
- const slashIdx = options.model.indexOf("/");
22037
+ if (options.model) {
22038
+ const explicit = typeof options.model === "string" ? options.model : `${options.model.provider}/${options.model.id}`;
22039
+ globalConfig = { ...globalConfig, RESEARCH_MODEL: explicit };
22040
+ }
22041
+ const effectiveModel = options.model ?? globalConfig.RESEARCH_MODEL;
22042
+ if (!parsedProvider && effectiveModel) {
22043
+ if (typeof effectiveModel === "string") {
22044
+ const slashIdx = effectiveModel.indexOf("/");
22006
22045
  if (slashIdx > 0) {
22007
- parsedProvider = options.model.slice(0, slashIdx);
22046
+ parsedProvider = effectiveModel.slice(0, slashIdx);
22008
22047
  }
22009
- } else if (options.model.provider) {
22010
- parsedProvider = options.model.provider;
22048
+ } else if (effectiveModel.provider) {
22049
+ parsedProvider = effectiveModel.provider;
22011
22050
  }
22012
22051
  }
22013
22052
  if (globalApiKey && !parsedProvider) {
@@ -22015,7 +22054,16 @@ async function _doInit(options = {}) {
22015
22054
  }
22016
22055
  globalRegistry = buildModelRegistry(globalApiKey, parsedProvider);
22017
22056
  try {
22018
- globalModel = resolveModel(globalRegistry, typeof options.model === "string" ? options.model : void 0, parsedProvider, globalApiKey);
22057
+ if (effectiveModel && typeof effectiveModel === "object" && effectiveModel.id) {
22058
+ globalModel = effectiveModel;
22059
+ } else {
22060
+ globalModel = resolveModel(
22061
+ globalRegistry,
22062
+ typeof effectiveModel === "string" ? effectiveModel : void 0,
22063
+ parsedProvider,
22064
+ globalApiKey
22065
+ );
22066
+ }
22019
22067
  globalContainer = getServiceContainer();
22020
22068
  registerCoreServices(globalContainer);
22021
22069
  registerInfrastructureServices(globalContainer);
@@ -22465,6 +22513,7 @@ Total cost: ${costStr}`;
22465
22513
 
22466
22514
  // src/cli.ts
22467
22515
  init_config();
22516
+ init_model_registry_factory();
22468
22517
  import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
22469
22518
  function readPkgVersion() {
22470
22519
  try {
@@ -22583,6 +22632,15 @@ function fileExists(p) {
22583
22632
  return false;
22584
22633
  }
22585
22634
  }
22635
+ function piKeysAvailable(piAuthPresent, provider) {
22636
+ try {
22637
+ const available = safeGetAvailable(buildModelRegistry(void 0, void 0));
22638
+ if (!provider) return available.length > 0;
22639
+ return available.some((m) => m.provider === provider);
22640
+ } catch {
22641
+ return piAuthPresent;
22642
+ }
22643
+ }
22586
22644
  function detectCredentials() {
22587
22645
  const paths = resolvedConfigPaths();
22588
22646
  const piAuthPresent = fileExists(paths.piAuth);
@@ -22594,6 +22652,7 @@ function detectCredentials() {
22594
22652
  const model = modelEnv ?? modelConfig;
22595
22653
  const modelFrom = modelEnv ? "PI_RESEARCH_MODEL" : modelConfig ? "config.env" : "unset";
22596
22654
  const provider = providerEnv ?? (model && model.includes("/") ? model.slice(0, model.indexOf("/")) : void 0);
22655
+ const piKeysUsable = piKeysAvailable(piAuthPresent, provider);
22597
22656
  if (apiKey) {
22598
22657
  if (!provider) {
22599
22658
  return {
@@ -22629,11 +22688,23 @@ function detectCredentials() {
22629
22688
  piModelsPresent
22630
22689
  };
22631
22690
  }
22632
- if (piAuthPresent) {
22691
+ if (piKeysUsable) {
22692
+ if (!model) {
22693
+ return {
22694
+ source: "pi-config",
22695
+ apiKeyConfigured: true,
22696
+ provider,
22697
+ model,
22698
+ modelFrom,
22699
+ piAuthPresent,
22700
+ piModelsPresent,
22701
+ problem: `pi credentials were found (in pi's configuration), but no research model is configured. The standalone CLI / agent skill run only on an explicitly configured model \u2014 they do not follow the model selected inside the pi extension. Set PI_RESEARCH_MODEL to a "provider/model-id" (in the environment, or in config.env / cli.env below).`
22702
+ };
22703
+ }
22633
22704
  return {
22634
22705
  source: "pi-config",
22635
22706
  apiKeyConfigured: true,
22636
- // key lives in auth.json; SDK reads it
22707
+ // key lives in pi's configuration; the SDK reads it at call time
22637
22708
  provider,
22638
22709
  model,
22639
22710
  modelFrom,
@@ -22649,7 +22720,7 @@ function detectCredentials() {
22649
22720
  modelFrom,
22650
22721
  piAuthPresent,
22651
22722
  piModelsPresent,
22652
- problem: "No model or API key is configured for pi-research. Provide credentials via environment variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use pi, via ~/.pi/agent/auth.json."
22723
+ problem: "No model or API key is configured for pi-research. Provide credentials via environment variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider apiKey in ~/.pi/agent/models.json)."
22653
22724
  };
22654
22725
  }
22655
22726
  function configBlock(det, extraNote) {
@@ -22659,14 +22730,14 @@ function configBlock(det, extraNote) {
22659
22730
  lines.push(` \u2022 base config file: ${paths.configEnv}`);
22660
22731
  lines.push(` \u2022 cli overlay: ${paths.cliIfaceEnv} (optional; overrides base for this CLI / agent skill)`);
22661
22732
  lines.push(` \u2022 env vars: PI_RESEARCH_API_KEY / PI_RESEARCH_PROVIDER / PI_RESEARCH_MODEL`);
22662
- lines.push(` \u2022 pi auth storage: ${paths.piAuth}`);
22663
- lines.push(` \u2022 pi models: ${paths.piModels}`);
22733
+ lines.push(` \u2022 pi config (keys): ${paths.piAuth}`);
22734
+ lines.push(` \u2022 pi config (models): ${paths.piModels}`);
22664
22735
  lines.push("");
22665
22736
  lines.push("Detected:");
22666
22737
  lines.push(` \u2022 credential source: ${det.source}`);
22667
22738
  lines.push(` \u2022 api key configured: ${det.apiKeyConfigured}`);
22668
- lines.push(` \u2022 provider: ${det.provider ?? "(unset)"}`);
22669
- lines.push(` \u2022 model: ${det.model ?? "(unset)"} [from: ${det.modelFrom}]`);
22739
+ lines.push(` \u2022 provider: ${det.provider ?? "(unset \u2014 inferred from a provider/model-id model)"}`);
22740
+ lines.push(` \u2022 model: ${det.model ? `${det.model} [from: ${det.modelFrom}]` : "(not set \u2014 set PI_RESEARCH_MODEL, required)"}`);
22670
22741
  lines.push(` \u2022 pi auth.json: ${det.piAuthPresent ? "present" : "absent"}`);
22671
22742
  lines.push(` \u2022 pi models.json: ${det.piModelsPresent ? "present" : "absent"}`);
22672
22743
  if (extraNote) lines.push(` \u2022 note: ${extraNote}`);
@@ -22923,8 +22994,9 @@ uninstall: ${BINARY_NAME} skill uninstall
22923
22994
  } catch (err) {
22924
22995
  return reportError(err, "skill install", s.json);
22925
22996
  }
22997
+ const skillModel = process.env["PI_RESEARCH_MODEL"] ?? getConfig(process.cwd(), "cli").RESEARCH_MODEL;
22926
22998
  if (s.json) {
22927
- toStdout(pretty({ command: "skill", action: "install", dryRun: !!s.dryRun, results: results2 }));
22999
+ toStdout(pretty({ command: "skill", action: "install", dryRun: !!s.dryRun, results: results2, modelConfigured: Boolean(skillModel) }));
22928
23000
  } else {
22929
23001
  toStdout(`pi-research agent skill \u2014 ${s.dryRun ? "install (dry run)" : "install"}
22930
23002
 
@@ -22934,6 +23006,15 @@ uninstall: ${BINARY_NAME} skill uninstall
22934
23006
  toStdout(`
22935
23007
  The skill activates automatically \u2014 ask the agent to research something.
22936
23008
  `);
23009
+ if (!skillModel) {
23010
+ toStdout(
23011
+ `
23012
+ Required: set PI_RESEARCH_MODEL=provider/model-id in ${resolvedConfigPaths().configEnv}
23013
+ (or as an env var) \u2014 the skill runs only on this configured model, not your pi
23014
+ session model. Verify with: ${BINARY_NAME} status
23015
+ `
23016
+ );
23017
+ }
22937
23018
  }
22938
23019
  return results2.some((r) => r.status === "error") ? EXIT.SOFTWARE : EXIT.OK;
22939
23020
  }
@@ -23003,7 +23084,7 @@ function reportError(err, what, json) {
23003
23084
  const lower = msg2.toLowerCase();
23004
23085
  const isConfigError = lower.includes("no llm model available") || lower.includes("not found in pi") || lower.includes("no api key") || lower.includes("provider must be specified") || lower.includes("invalid model string") || lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("api key");
23005
23086
  const isRateLimit = lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests");
23006
- const exitCode = isConfigError ? EXIT.CONFIG : EXIT.SOFTWARE;
23087
+ const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
23007
23088
  if (json) {
23008
23089
  const payload = { ok: false, error: msg2, exitCode };
23009
23090
  if (process.env["PI_RESEARCH_DEBUG"] === "true" && err instanceof Error && err.stack) {
@@ -23012,18 +23093,18 @@ function reportError(err, what, json) {
23012
23093
  toStdout(pretty(payload));
23013
23094
  return exitCode;
23014
23095
  }
23015
- if (isConfigError) {
23096
+ if (isRateLimit) {
23016
23097
  toStderr(`
23017
- Error: pi-research ${what} failed: ${msg2}
23018
-
23019
- ${configBlock(detectCredentials())}
23098
+ Error: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.
23099
+ ${msg2}
23020
23100
  `);
23021
23101
  return exitCode;
23022
23102
  }
23023
- if (isRateLimit) {
23103
+ if (isConfigError) {
23024
23104
  toStderr(`
23025
- Error: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.
23026
- ${msg2}
23105
+ Error: pi-research ${what} failed: ${msg2}
23106
+
23107
+ ${configBlock(detectCredentials())}
23027
23108
  `);
23028
23109
  return exitCode;
23029
23110
  }
@@ -23091,11 +23172,15 @@ COMMANDS
23091
23172
  CONFIGURE
23092
23173
  Credentials are resolved in this order (first match wins):
23093
23174
 
23094
- env vars: PI_RESEARCH_API_KEY PI_RESEARCH_PROVIDER PI_RESEARCH_MODEL
23095
- cli overlay: ${p.cliIfaceEnv} (optional; this CLI / agent skill only)
23096
- base config: ${p.configEnv}
23097
- pi auth: ${p.piAuth}
23098
- pi models: ${p.piModels}
23175
+ env vars: PI_RESEARCH_API_KEY PI_RESEARCH_PROVIDER PI_RESEARCH_MODEL
23176
+ cli overlay: ${p.cliIfaceEnv} (optional; this CLI / agent skill only)
23177
+ base config: ${p.configEnv}
23178
+ pi config (keys): ${p.piAuth}
23179
+ pi config (models): ${p.piModels}
23180
+
23181
+ A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id". This CLI and
23182
+ the agent skill run only on that configured model \u2014 they do not follow the model
23183
+ selected inside the pi extension.
23099
23184
 
23100
23185
  The pi extension has its own optional overlay file:
23101
23186
  pi extension: ${p.piIfaceEnv}
@@ -10,7 +10,8 @@ pi-research software.
10
10
  Already running the `pi` extension (`pi install npm:@lincoln504/pi-research`)? You
11
11
  already have the engine — install the skill into your other agents with
12
12
  `/research-config` → Install in External Agents (see [Installation flow](#installation-flow))
13
- and skip the rest.
13
+ and skip the install commands below. The model step still applies to you: the
14
+ skill runs on its own configured `PI_RESEARCH_MODEL`, not your pi session's model.
14
15
 
15
16
  For standalone use without pi, install the engine globally, then link the skill into
16
17
  every coding agent detected on this machine:
@@ -24,6 +25,18 @@ pi-research skill install # link the skill into Claude, Codex, Op
24
25
  different skill in the slot, and records what it created so `pi-research skill uninstall`
25
26
  removes exactly that. Run `pi-research skill status` to see where it is installed.
26
27
 
28
+ Then configure the model research runs on — the skill and standalone CLI use only
29
+ this explicitly configured model (they never follow the model selected inside the
30
+ pi extension) and refuse to start without one:
31
+
32
+ ```sh
33
+ # ~/.pi/research/config.env (or export as an env var)
34
+ PI_RESEARCH_MODEL=provider/model-id
35
+ ```
36
+
37
+ If you use `pi`, the API key comes from your pi configuration (`~/.pi/agent/auth.json`) automatically; otherwise
38
+ also set `PI_RESEARCH_API_KEY` (same file or env var). See [Configuration](CONFIGURATION.md).
39
+
27
40
  On Windows, run `pi-research` from `cmd` or use `pi-research.cmd`: PowerShell's default
28
41
  execution policy (`Restricted`) blocks npm's `.ps1` shims ("running scripts is
29
42
  disabled"); or run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` once.
@@ -114,8 +127,8 @@ Manual. Symlink the directory into any agent's skills folder yourself:
114
127
  ### Prerequisites
115
128
 
116
129
  - Node.js >= 22.19.0
117
- - `pi-research` installed where the launcher can find it, plus a model + API key.
118
- See [Configuration](CONFIGURATION.md).
130
+ - `pi-research` installed where the launcher can find it, plus a configured model
131
+ (`PI_RESEARCH_MODEL`) + API key. See [Configuration](CONFIGURATION.md).
119
132
 
120
133
  ```bash
121
134
  npm install -g @lincoln504/pi-research
@@ -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` | _(session model)_ | — | Model override for researcher sub-agents (deep and quick) and knowledge synthesis. In the pi extension the coordinator and evaluator keep using the session model; on the standalone CLI/SDK, when no `--model`/`model` option is given, this also selects the session model itself. 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. 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. |
@@ -128,11 +128,11 @@ See the [knowledge store doc](KNOWLEDGE-STORE.md) for what each value does.
128
128
  | `PI_RESEARCH_EMBEDDING_MODEL_INIT_TIMEOUT_MS` | `300000` | 10000–600000 | Embedding-model initialization timeout (first-time download can be slow). |
129
129
  | `PI_RESEARCH_WEBGPU_REPROBE` | _(unset)_ | — | Set `1` to discard the cached WebGPU-viability verdict and probe again on next use. |
130
130
 
131
- API keys (all optional)
131
+ 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 using pi's own auth). Provider is required alongside the key. |
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/SDK.md CHANGED
@@ -13,6 +13,13 @@ run the `pi` extension, which keeps its own private copy your scripts can't impo
13
13
  npm install @lincoln504/pi-research
14
14
  ```
15
15
 
16
+ Then pick the model: pass `model` to `initResearchSDK`, or set `PI_RESEARCH_MODEL`
17
+ (env or `~/.pi/research/config.env`). The SDK never follows the model selected
18
+ inside the pi extension; only when neither is set does it fall back to the first
19
+ available model in your pi registry. The API key comes from your pi
20
+ configuration (`~/.pi/agent/auth.json`) automatically, or from the `apiKey`
21
+ option / `PI_RESEARCH_API_KEY` env var.
22
+
16
23
  `src/sdk.ts` is a library for scripts, CI, and custom tooling. It is configured
17
24
  from code, not from a global overlay file — there is no `sdk.env`. It reads the
18
25
  base `~/.pi/research/config.env` as a baseline, and everything is
@@ -83,7 +90,7 @@ extension and the CLI / agent skill do it when `PI_RESEARCH_REPORT_EXPORT_ENABLE
83
90
 
84
91
  | Option | Description |
85
92
  |--------|-------------|
86
- | `model` | `"provider/id"` string or a `Model` object. Omit to use the first available pi model. |
93
+ | `model` | `"provider/id"` string or a `Model` object. Omit to use the configured `PI_RESEARCH_MODEL`; only when neither is set does the SDK fall back to the first available pi model. |
87
94
  | `apiKey` / `provider` | Explicit credentials (provider required with apiKey). |
88
95
  | `config` | `Partial<Config>` overrides, applied over the base/defaults. |
89
96
  | `ignoreGlobalConfig` | Skip `config.env` entirely — defaults + `process.env` + `config` only. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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,
@@ -39,8 +39,8 @@ model/key is missing.
39
39
  ### Prerequisites
40
40
 
41
41
  - Node.js >= 22.19.0
42
- - `pi-research` installed somewhere the launcher can find it, and a model +
43
- API key configured. See [`references/configuration.md`](references/configuration.md).
42
+ - `pi-research` installed somewhere the launcher can find it, and a configured
43
+ model (`PI_RESEARCH_MODEL`) + API key. See [`references/configuration.md`](references/configuration.md).
44
44
 
45
45
  ```bash
46
46
  npm install -g @lincoln504/pi-research
@@ -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.2", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.0.4", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
@@ -91,10 +91,13 @@ On exit 78 the engine prints exact configure locations to stderr — relay verba
91
91
  ```
92
92
  config file: ~/.pi/research/config.env
93
93
  env vars: PI_RESEARCH_API_KEY PI_RESEARCH_PROVIDER PI_RESEARCH_MODEL
94
- pi auth: ~/.pi/agent/auth.json
95
- pi models: ~/.pi/agent/models.json
94
+ pi config (keys): ~/.pi/agent/auth.json (supplies the API key only)
95
+ pi config (models): ~/.pi/agent/models.json
96
96
  ```
97
97
 
98
+ `PI_RESEARCH_MODEL` (a `provider/model-id`) is always required — pi's configuration
99
+ supplies only the key, and the skill never uses the model selected inside the pi extension.
100
+
98
101
  Run `status` for the resolved paths on this machine. Full reference:
99
102
  [`references/configuration.md`](references/configuration.md).
100
103
 
@@ -1,6 +1,8 @@
1
1
  ## Configuration
2
2
 
3
- The skill drives the **pi-research** engine, which needs a model + API key.
3
+ The skill drives the **pi-research** engine, which needs an explicitly configured
4
+ model (`PI_RESEARCH_MODEL`) + API key — it runs only on that configured model and
5
+ never follows the model selected inside the pi extension.
4
6
  Run `node "<SKILL_DIR>/scripts/run.mjs" status` to print what's detected and where
5
7
  to configure (no research runs).
6
8
 
@@ -13,7 +15,8 @@ to configure (no research runs).
13
15
  export PI_RESEARCH_MODEL=openai/gpt-4o # provider/model-id
14
16
  ```
15
17
  2. **Global config** `~/.pi/research/config.env` (same KEY=VALUE keys; read at startup).
16
- 3. **pi auth** (if you use `pi`): `~/.pi/agent/auth.json` + `~/.pi/agent/models.json`.
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`).
17
20
 
18
21
  Real env vars always win. Optional CLI-only overlay `~/.pi/research/cli.env` layers
19
22
  over `config.env` for the CLI only. Precedence:
@@ -23,7 +26,7 @@ over `config.env` for the CLI only. Precedence:
23
26
 
24
27
  | Variable | Default | Description |
25
28
  |----------|---------|-------------|
26
- | `PI_RESEARCH_MODEL` | session model | Model override for all research sub-tasks. |
29
+ | `PI_RESEARCH_MODEL` | _(required)_ | The `provider/model-id` all research runs on. |
27
30
  | `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` | `1` | Default depth (1–3) when `--depth` omitted. |
28
31
  | `PI_RESEARCH_MAX_RESEARCHERS` | `3` | Parallel researchers (1–5). |
29
32
  | `PI_RESEARCH_TIMEOUT_MS` | `300000` | Per-researcher timeout (180000–1800000). |
@@ -70,7 +73,7 @@ model/key; message lists the fix) · `70` runtime error (network/provider/intern
70
73
  `export PI_RESEARCH_BIN=/path/to/pi-research/dist/cli.mjs` (exact CLI entry;
71
74
  checked first — also the escape hatch for unusual Windows shim layouts).
72
75
  - **"No model or API key" (78 from engine):** set the three env vars (or config /
73
- pi auth), then re-run `status`.
76
+ pi's configuration), then re-run `status`.
74
77
  - **Rate limit (70):** provider returned 429 — wait and retry.
75
78
  - **GPU/embedding errors:** set `PI_RESEARCH_EMBEDDING_DEVICE=cpu` (headless/no-GPU).
76
79
 
@@ -139,10 +139,11 @@ function notInstalled() {
139
139
  " # or, with pi: pi install npm:@lincoln504/pi-research",
140
140
  " # or point at a copy: export PI_RESEARCH_PATH=/path/to/pi-research",
141
141
  "",
142
- "After installing, configure a model + API key. Locations:",
142
+ "After installing, configure the model (PI_RESEARCH_MODEL is required) + API key.",
143
+ "Locations:",
143
144
  ` \u2022 env vars: PI_RESEARCH_API_KEY / PI_RESEARCH_PROVIDER / PI_RESEARCH_MODEL`,
144
145
  ` \u2022 global config file: ${join(home, ".pi", "research", "config.env")}`,
145
- ` \u2022 pi auth storage: ${join(home, ".pi", "agent", "auth.json")}`,
146
+ ` \u2022 pi config (keys): ${join(home, ".pi", "agent", "auth.json")} (supplies the API key only)`,
146
147
  "",
147
148
  "Run `status` once installed to verify detection.",
148
149
  ""
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
@@ -196,7 +197,7 @@ export interface ResolvedConfigPaths {
196
197
  cliIfaceEnv: string;
197
198
  /** Per-interface overlay for the pi extension (~/.pi/research/pi.env). */
198
199
  piIfaceEnv: string;
199
- /** pi auth storage (~/.pi/agent/auth.json). */
200
+ /** pi's key file (~/.pi/agent/auth.json), part of pi's configuration. */
200
201
  piAuth: string;
201
202
  /** pi model definitions (~/.pi/agent/models.json). */
202
203
  piModels: string;
@@ -226,6 +227,32 @@ 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';
@@ -258,6 +285,9 @@ function detectCredentials(): CredentialDetection {
258
285
  // Provider is either explicit, or inferred from a "provider/id" model string.
259
286
  const provider =
260
287
  providerEnv ?? (model && model.includes('/') ? model.slice(0, model.indexOf('/')) : undefined);
288
+ // Provider-aware when the configured model names one: a key for a DIFFERENT
289
+ // provider must not greenlight this run.
290
+ const piKeysUsable = piKeysAvailable(piAuthPresent, provider);
261
291
 
262
292
  // --- Explicit API-key path -------------------------------------------------
263
293
  if (apiKey) {
@@ -300,12 +330,32 @@ function detectCredentials(): CredentialDetection {
300
330
  };
301
331
  }
302
332
 
303
- // --- pi auth storage path --------------------------------------------------
304
- if (piAuthPresent) {
305
- // The SDK will resolve a model from the registry (models.json + PI_RESEARCH_MODEL).
333
+ // --- pi configuration path (key from auth.json, models.json, or provider env) ---
334
+ if (piKeysUsable) {
335
+ // Keys come from pi's configuration, but the MODEL must be configured
336
+ // explicitly: the standalone CLI / agent skill run only on the configured
337
+ // model — they never follow the model selected inside the pi extension, and
338
+ // silently falling back to the registry's first authed entry picks a model
339
+ // nobody chose. Fail fast here (pre-flight, before any SDK init).
340
+ if (!model) {
341
+ return {
342
+ source: 'pi-config',
343
+ apiKeyConfigured: true,
344
+ provider,
345
+ model,
346
+ modelFrom,
347
+ piAuthPresent,
348
+ piModelsPresent,
349
+ problem:
350
+ 'pi credentials were found (in pi\'s configuration), but no research model is configured. ' +
351
+ 'The standalone CLI / agent skill run only on an explicitly configured model — they do not ' +
352
+ 'follow the model selected inside the pi extension. Set PI_RESEARCH_MODEL to a ' +
353
+ '"provider/model-id" (in the environment, or in config.env / cli.env below).',
354
+ };
355
+ }
306
356
  return {
307
357
  source: 'pi-config',
308
- apiKeyConfigured: true, // key lives in auth.json; SDK reads it
358
+ apiKeyConfigured: true, // key lives in pi's configuration; the SDK reads it at call time
309
359
  provider,
310
360
  model,
311
361
  modelFrom,
@@ -326,7 +376,8 @@ function detectCredentials(): CredentialDetection {
326
376
  problem:
327
377
  'No model or API key is configured for pi-research. Provide credentials via environment ' +
328
378
  'variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use ' +
329
- 'pi, via ~/.pi/agent/auth.json.',
379
+ "pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider " +
380
+ 'apiKey in ~/.pi/agent/models.json).',
330
381
  };
331
382
  }
332
383
 
@@ -342,14 +393,14 @@ function configBlock(det: CredentialDetection, extraNote?: string): string {
342
393
  lines.push(` • base config file: ${paths.configEnv}`);
343
394
  lines.push(` • cli overlay: ${paths.cliIfaceEnv} (optional; overrides base for this CLI / agent skill)`);
344
395
  lines.push(` • env vars: PI_RESEARCH_API_KEY / PI_RESEARCH_PROVIDER / PI_RESEARCH_MODEL`);
345
- lines.push(` • pi auth storage: ${paths.piAuth}`);
346
- lines.push(` • pi models: ${paths.piModels}`);
396
+ lines.push(` • pi config (keys): ${paths.piAuth}`);
397
+ lines.push(` • pi config (models): ${paths.piModels}`);
347
398
  lines.push('');
348
399
  lines.push('Detected:');
349
400
  lines.push(` • credential source: ${det.source}`);
350
401
  lines.push(` • api key configured: ${det.apiKeyConfigured}`);
351
- lines.push(` • provider: ${det.provider ?? '(unset)'}`);
352
- lines.push(` • model: ${det.model ?? '(unset)'} [from: ${det.modelFrom}]`);
402
+ lines.push(` • provider: ${det.provider ?? '(unset — inferred from a provider/model-id model)'}`);
403
+ lines.push(` • model: ${det.model ? `${det.model} [from: ${det.modelFrom}]` : '(not set — set PI_RESEARCH_MODEL, required)'}`);
353
404
  lines.push(` • pi auth.json: ${det.piAuthPresent ? 'present' : 'absent'}`);
354
405
  lines.push(` • pi models.json: ${det.piModelsPresent ? 'present' : 'absent'}`);
355
406
  if (extraNote) lines.push(` • note: ${extraNote}`);
@@ -657,12 +708,23 @@ async function cmdSkill(s: NonNullable<ParsedArgs['skill']>): Promise<number> {
657
708
  } catch (err) {
658
709
  return reportError(err, 'skill install', s.json);
659
710
  }
711
+ // The skill runs only on an explicitly configured model — if none is set yet,
712
+ // say so right here, at the moment of install (mirrors the TUI install action).
713
+ const skillModel =
714
+ process.env['PI_RESEARCH_MODEL'] ?? getConfig(process.cwd(), 'cli').RESEARCH_MODEL;
660
715
  if (s.json) {
661
- toStdout(pretty({ command: 'skill', action: 'install', dryRun: !!s.dryRun, results }));
716
+ toStdout(pretty({ command: 'skill', action: 'install', dryRun: !!s.dryRun, results, modelConfigured: Boolean(skillModel) }));
662
717
  } else {
663
718
  toStdout(`pi-research agent skill — ${s.dryRun ? 'install (dry run)' : 'install'}\n\n`);
664
719
  for (const r of results) toStdout(` ${r.tool.padEnd(10)} ${describeInstall(r)}\n`);
665
720
  toStdout(`\nThe skill activates automatically — ask the agent to research something.\n`);
721
+ if (!skillModel) {
722
+ toStdout(
723
+ `\nRequired: set PI_RESEARCH_MODEL=provider/model-id in ${resolvedConfigPaths().configEnv}\n` +
724
+ `(or as an env var) — the skill runs only on this configured model, not your pi\n` +
725
+ `session model. Verify with: ${BINARY_NAME} status\n`
726
+ );
727
+ }
666
728
  }
667
729
  return results.some((r) => r.status === 'error') ? EXIT.SOFTWARE : EXIT.OK;
668
730
  }
@@ -732,8 +794,12 @@ function reportError(err: unknown, what: string, json?: boolean): number {
732
794
  lower.includes('unauthorized') ||
733
795
  lower.includes('api key');
734
796
 
797
+ // Rate limits win over config-shaped matches: a provider 429 often mentions
798
+ // "api key" in its body (e.g. "rate limit exceeded for this api key"), and
799
+ // classifying that as a setup problem sends the user to reconfigure a
800
+ // perfectly working key instead of waiting out the limit.
735
801
  const isRateLimit = lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests');
736
- const exitCode = isConfigError ? EXIT.CONFIG : EXIT.SOFTWARE;
802
+ const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
737
803
 
738
804
  // --json: emit a structured error on stdout so a machine consumer never has to
739
805
  // special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
@@ -746,15 +812,15 @@ function reportError(err: unknown, what: string, json?: boolean): number {
746
812
  return exitCode;
747
813
  }
748
814
 
749
- if (isConfigError) {
750
- toStderr(`\nError: pi-research ${what} failed: ${msg}\n\n${configBlock(detectCredentials())}\n`);
751
- return exitCode;
752
- }
753
- // Rate limits are operational, not fatal setup problems.
815
+ // Rate limits are operational, not fatal setup problems — checked FIRST.
754
816
  if (isRateLimit) {
755
817
  toStderr(`\nError: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.\n ${msg}\n`);
756
818
  return exitCode;
757
819
  }
820
+ if (isConfigError) {
821
+ toStderr(`\nError: pi-research ${what} failed: ${msg}\n\n${configBlock(detectCredentials())}\n`);
822
+ return exitCode;
823
+ }
758
824
  toStderr(`\nError: pi-research ${what} failed: ${msg}\n`);
759
825
  if (process.env['PI_RESEARCH_DEBUG'] === 'true' && err instanceof Error && err.stack) {
760
826
  toStderr('\n' + err.stack + '\n');
@@ -842,11 +908,15 @@ COMMANDS
842
908
  CONFIGURE
843
909
  Credentials are resolved in this order (first match wins):
844
910
 
845
- env vars: PI_RESEARCH_API_KEY PI_RESEARCH_PROVIDER PI_RESEARCH_MODEL
846
- cli overlay: ${p.cliIfaceEnv} (optional; this CLI / agent skill only)
847
- base config: ${p.configEnv}
848
- pi auth: ${p.piAuth}
849
- pi models: ${p.piModels}
911
+ env vars: PI_RESEARCH_API_KEY PI_RESEARCH_PROVIDER PI_RESEARCH_MODEL
912
+ cli overlay: ${p.cliIfaceEnv} (optional; this CLI / agent skill only)
913
+ base config: ${p.configEnv}
914
+ pi config (keys): ${p.piAuth}
915
+ pi config (models): ${p.piModels}
916
+
917
+ A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id". This CLI and
918
+ the agent skill run only on that configured model — they do not follow the model
919
+ selected inside the pi extension.
850
920
 
851
921
  The pi extension has its own optional overlay file:
852
922
  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'],
@@ -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,
@@ -486,7 +486,22 @@ export class DeepResearchOrchestrator {
486
486
  logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisService.getReportCount(researchId)} reports`);
487
487
  result = synthesisService.buildFallbackSynthesis(researchId, this.currentRound);
488
488
  } else {
489
- result = 'Research completed but no summary was generated.';
489
+ // ZERO reports were collected — the run produced nothing groundable.
490
+ // With the default single-researcher plan the fast-stop threshold
491
+ // (MAX_FAILED_RESEARCHERS = 2) never trips, so a lone researcher that
492
+ // exhausts its retries (e.g. a provider rate limit) used to land here
493
+ // and return a hollow "no summary was generated" string on the SUCCESS
494
+ // path — exit 0. That is a FAILED run: throw, and surface the recorded
495
+ // root causes so the user sees the 429/model error, not a shrug.
496
+ const failedIds = getFailedResearchers(this.options.sessionId, researchId);
497
+ const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
498
+ const causes = failedIds
499
+ .map((id) => `researcher ${id}: ${reasons[id] ?? 'no usable report'}`)
500
+ .join('; ');
501
+ throw new Error(
502
+ 'Research produced no report — no source material was collected' +
503
+ (causes ? `. Causes: ${causes}` : '.')
504
+ );
490
505
  }
491
506
  }
492
507
 
@@ -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
  }
@@ -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;
@@ -26,6 +26,8 @@ const MAX_STEERING_MESSAGE_LENGTH = 2000;
26
26
  interface PiSessionState {
27
27
  /** Map of research run ID → array of failed researcher IDs */
28
28
  failures: Map<string, string[]>;
29
+ /** Map of research run ID → (researcher ID → first failure reason) */
30
+ failureReasons: Map<string, Map<string, string>>;
29
31
  /** Ordered list of research run IDs for TUI stacking (Index 0 = oldest/bottom-most) */
30
32
  order: string[];
31
33
  /** Registry of panel states for each research run */
@@ -70,6 +72,7 @@ function getPiState(piSessionId: string | undefined): PiSessionState {
70
72
  if (!state) {
71
73
  state = {
72
74
  failures: new Map(),
75
+ failureReasons: new Map(),
73
76
  order: [],
74
77
  panels: new Map(),
75
78
  aborts: new Map(),
@@ -392,6 +395,7 @@ export function refreshAllSessions(piSessionId: string | undefined): void {
392
395
  for (const id of state.order) {
393
396
  if (!validIds.includes(id)) {
394
397
  state.failures.delete(id);
398
+ state.failureReasons.delete(id);
395
399
  state.panels.delete(id);
396
400
  }
397
401
  }
@@ -454,6 +458,7 @@ export function startResearchSession(piSessionId: string | undefined, customRese
454
458
  const sid = normalizeSessionId(piSessionId);
455
459
  const state = getPiState(sid);
456
460
  state.failures.set(researchId, []);
461
+ state.failureReasons.set(researchId, new Map());
457
462
  return researchId;
458
463
  }
459
464
 
@@ -501,6 +506,7 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
501
506
  if (!state) return;
502
507
 
503
508
  state.failures.delete(researchId);
509
+ state.failureReasons.delete(researchId);
504
510
  state.panels.delete(researchId);
505
511
  state.aborts.delete(researchId);
506
512
 
@@ -525,6 +531,7 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
525
531
  } else {
526
532
  // Clear research-specific state but preserve the session and steering
527
533
  state.failures.clear();
534
+ state.failureReasons.clear();
528
535
  state.aborts.clear();
529
536
  }
530
537
  }
@@ -563,12 +570,28 @@ export function isBottomMostSession(piSessionId: string | undefined, researchId:
563
570
  /**
564
571
  * Record a researcher failure
565
572
  */
566
- export function recordResearcherFailure(piSessionId: string | undefined, researchId: string, researcherId: string): void {
573
+ export function recordResearcherFailure(piSessionId: string | undefined, researchId: string, researcherId: string, reason?: string): void {
567
574
  const sid = normalizeSessionId(piSessionId);
568
575
  const state = getPiState(sid);
569
576
  const failures = state.failures.get(researchId) || [];
570
577
  failures.push(researcherId);
571
578
  state.failures.set(researchId, failures);
579
+ if (reason) {
580
+ const reasons = state.failureReasons.get(researchId) ?? new Map<string, string>();
581
+ // Keep the FIRST reason per researcher — the root cause; retries repeat it.
582
+ if (!reasons.has(researcherId)) reasons.set(researcherId, reason);
583
+ state.failureReasons.set(researchId, reasons);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Get the recorded failure reason per failed researcher (first failure wins).
589
+ */
590
+ export function getResearcherFailureReasons(piSessionId: string | undefined, researchId: string): Record<string, string> {
591
+ const sid = normalizeSessionId(piSessionId);
592
+ const state = getPiState(sid);
593
+ const reasons = state.failureReasons.get(researchId);
594
+ return reasons ? Object.fromEntries(reasons) : {};
572
595
  }
573
596
 
574
597
  /**
@@ -610,9 +633,18 @@ export function getResearchStopMessage(piSessionId: string | undefined, research
610
633
  // search engine blocking" here mis-sends the user to debug their network; the
611
634
  // readiness probes do not even run a search, so a failure is not evidence of bot
612
635
  // blocking.
636
+ // Lead with the RECORDED errors when we have them — the actual root cause
637
+ // (e.g. a provider 429) beats generic guidance every time.
638
+ const state = getPiState(sid);
639
+ const reasons = state.failureReasons.get(researchId);
640
+ const recorded = reasons && reasons.size > 0
641
+ ? ['Recorded errors:', ...[...reasons].map(([id, r]) => `• researcher ${id}: ${r}`), '']
642
+ : [];
643
+
613
644
  return [
614
645
  `Research stopped: ${count} researcher(s) did not return a usable report: ${failed.join(', ')}.`,
615
646
  '',
647
+ ...recorded,
616
648
  'Most likely cause — the research model could not produce grounded results:',
617
649
  '• Try a more capable research model. Very small or "thinking-only" models often',
618
650
  ' emit no final text after their tool calls, which fails every researcher.',
@@ -23,7 +23,7 @@ import {
23
23
  } from '@earendil-works/pi-tui';
24
24
  import { setInteractiveTuiActive, initGlobalTuiController } from './tui/tui-controller.ts';
25
25
  import { normalizeSessionId } from './orchestration/session-state.ts';
26
- import { getConfig, saveConfig, resetConfig, getDbDir } from './config.ts';
26
+ import { getConfig, saveConfig, resetConfig, getDbDir, getGlobalEnvFilePath } from './config.ts';
27
27
  import { healthRegistry } from './healthcheck/index.ts';
28
28
  import { getService, clearService, tryGetServiceContainerFromCtx } from './core/service-registry.ts';
29
29
  import { ServiceNames, IKnowledgeStoreService } from './core/service-interfaces.ts';
@@ -648,6 +648,19 @@ async function installSkillAction(ctx: ExtensionContext, pi: ExtensionAPI): Prom
648
648
  }
649
649
  lines.push('', 'The skill activates automatically — just ask the agent to pi-research something.');
650
650
 
651
+ // The skill runs only on an explicitly configured model (it never follows the
652
+ // pi session model) — if none is configured yet, say so right here, at the
653
+ // moment of install, mirroring the CLI's `skill install` output.
654
+ const skillModel = process.env['PI_RESEARCH_MODEL'] ?? getConfig(ctx.cwd, 'cli').RESEARCH_MODEL;
655
+ if (!skillModel) {
656
+ lines.push(
657
+ '',
658
+ '**Required:** set `PI_RESEARCH_MODEL=provider/model-id` in ' +
659
+ `\`${getGlobalEnvFilePath()}\` (or as an env var) — the skill runs only on ` +
660
+ 'this configured model, not your pi session model.'
661
+ );
662
+ }
663
+
651
664
  pi.sendMessage({ customType: 'skill-install-result', content: lines.join('\n'), display: true });
652
665
  if (ctx.hasUI) {
653
666
  if (ok > 0) ctx.ui.notify(`Skill installed for ${ok} coding agent${ok === 1 ? '' : 's'}.`, 'info');
package/src/sdk.ts CHANGED
@@ -144,17 +144,21 @@ function _removeSignalHandlers(): void {
144
144
  * Options for initializing the Research SDK
145
145
  */
146
146
  export interface ResearchSDKOptions {
147
- /**
147
+ /**
148
148
  * The LLM model to use for coordination, planning, and evaluation.
149
- * If a string, it must match "provider/id" (e.g. "openai/gpt-4o").
150
- * If omitted, the first available model in pi's configuration will be used.
149
+ * If a string, it must match "provider/id" (e.g. "openai/gpt-4o"); a Model
150
+ * object is used as-is. If omitted, the configured RESEARCH_MODEL
151
+ * (PI_RESEARCH_MODEL / config.env, or the `config` option) is used; only
152
+ * when neither is set does the SDK fall back to the first available model
153
+ * in pi's configuration. The SDK never follows the model selected
154
+ * interactively inside the pi extension.
151
155
  */
152
156
  model?: string | Model<any>;
153
157
 
154
158
  /**
155
159
  * Optional explicit API key for the model.
156
160
  * If provided, `provider` must also be set.
157
- * If omitted, pi's global auth storage (~/.pi/agent/auth.json) is used.
161
+ * If omitted, the key from pi's configuration (~/.pi/agent/auth.json) is used.
158
162
  */
159
163
  apiKey?: string;
160
164
 
@@ -232,15 +236,31 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
232
236
  globalApiKey = options.apiKey || process.env['PI_RESEARCH_API_KEY'];
233
237
  let parsedProvider = options.provider || process.env['PI_RESEARCH_PROVIDER'];
234
238
 
235
- // Infer provider from model if not explicitly provided
236
- if (!parsedProvider && options.model) {
237
- if (typeof options.model === 'string') {
238
- const slashIdx = options.model.indexOf('/');
239
+ // An EXPLICIT model option must govern the entire run — coordinator (via
240
+ // ctx.model) AND researchers/synthesis (which resolve through
241
+ // RESEARCH_MODEL, where config would otherwise outrank the option and split
242
+ // the run across two models, with the report metadata naming the wrong one).
243
+ // Pin the config to the explicit choice. No-op when the option was itself
244
+ // seeded from the config (the CLI does that).
245
+ if (options.model) {
246
+ const explicit = typeof options.model === 'string'
247
+ ? options.model
248
+ : `${(options.model as Model<any>).provider}/${(options.model as Model<any>).id}`;
249
+ globalConfig = { ...globalConfig, RESEARCH_MODEL: explicit };
250
+ }
251
+
252
+ // Infer provider from the effective model if not explicitly provided. The
253
+ // effective model is the explicit option first, else the configured
254
+ // RESEARCH_MODEL — the same precedence the resolve below uses.
255
+ const effectiveModel = options.model ?? globalConfig.RESEARCH_MODEL;
256
+ if (!parsedProvider && effectiveModel) {
257
+ if (typeof effectiveModel === 'string') {
258
+ const slashIdx = effectiveModel.indexOf('/');
239
259
  if (slashIdx > 0) {
240
- parsedProvider = options.model.slice(0, slashIdx);
260
+ parsedProvider = effectiveModel.slice(0, slashIdx);
241
261
  }
242
- } else if ((options.model as any).provider) {
243
- parsedProvider = (options.model as any).provider;
262
+ } else if ((effectiveModel as any).provider) {
263
+ parsedProvider = (effectiveModel as any).provider;
244
264
  }
245
265
  }
246
266
 
@@ -252,8 +272,23 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
252
272
  globalRegistry = sharedBuildModelRegistry(globalApiKey, parsedProvider);
253
273
 
254
274
  try {
255
- // Resolve model using unified logic.
256
- globalModel = resolveModel(globalRegistry, typeof options.model === 'string' ? options.model : undefined, parsedProvider, globalApiKey);
275
+ // Resolve the model. Precedence: explicit option (a Model object is used
276
+ // as-is; it need not exist in the registry) → configured RESEARCH_MODEL →
277
+ // provider default → registry fallback. Seeding the resolver with the
278
+ // configured model is what keeps the coordinator (which runs on ctx.model)
279
+ // on the SAME model as the researchers/synthesis (which read
280
+ // RESEARCH_MODEL themselves) — without it a configured model would govern
281
+ // only the sub-agents and planning would silently run on the fallback.
282
+ if (effectiveModel && typeof effectiveModel === 'object' && (effectiveModel as any).id) {
283
+ globalModel = effectiveModel as Model<any>;
284
+ } else {
285
+ globalModel = resolveModel(
286
+ globalRegistry,
287
+ typeof effectiveModel === 'string' ? effectiveModel : undefined,
288
+ parsedProvider,
289
+ globalApiKey
290
+ );
291
+ }
257
292
 
258
293
  // Use the global container to ensure internal services can resolve dependencies
259
294
  globalContainer = getServiceContainer();