@companion-ai/feynman 0.5.16 → 0.5.18

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.
@@ -31,7 +31,7 @@ You are Feynman's evidence-gathering subagent.
31
31
  | Biomedical papers | `source: "pubmed"`, then `source: "europepmc"` for open-access full-text sections | `semanticscholar` for citation counts |
32
32
  | Citation graph | `source: "openalex"` with `openalex_citations:` / `openalex_references:` | `semanticscholar` citation counts to spot seminal work |
33
33
  | Conceptual or recent work that keyword search misses | `source: "openalex"` with a `semantic:` query prefix | `semanticscholar` with `sort: "relevance"` |
34
- | Web, docs, repos, grey literature | `web_search` (if the default provider fails or is rate-limited, retry with `provider: "parallel-mcp"`, which needs no key) | `fetch_content` on the best results |
34
+ | Web, docs, repos, grey literature | `web_search` (if the default provider fails or is rate-limited, retry with `provider` set to the plain string `parallel-mcp`, not a JSON-encoded list; it needs no key) | `fetch_content` on the best results |
35
35
  | Known paper ID | `source: "arxiv"` for arXiv IDs, `source: "crossref"` for DOIs | `fetch_content` on `arxiv.org/html/<id>` for full text |
36
36
 
37
37
  Run 2–4 reworded queries for each question (synonyms, the method's name, the problem's name) and merge the results. Do not trust one query's ranking; seminal papers often appear only under one phrasing or one sort order.
package/RELEASES.md CHANGED
@@ -6,6 +6,20 @@ GitHub release notes are generated from the matching `## vX.Y.Z` section in this
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## v0.5.18 - 2026-09-30
10
+
11
+ - **A rate-limited paper index no longer ends a research step.** When Semantic Scholar is rate-limited or down, a search is answered from OpenAlex, and an OpenAlex topic search that hits OpenAlex's anonymous rate limit or an outage is answered from Semantic Scholar. The result says first which index answered and why. Lookups by OpenAlex ID, DOI, author, venue, or citation still report the error, since the other index has no equivalent.
12
+ - **alphaXiv tools answer when alphaXiv cannot.** If your alphaXiv login expired or alphaXiv does not respond, `alpha_search` returns OpenAlex semantic search results and `alpha_get_paper` returns the arXiv metadata and abstract for an arXiv ID, each marked as a fallback. Run `feynman alpha login` to get alphaXiv's reports back.
13
+ - pi-web-access 0.35.0: OpenAI web search works when you signed in with ChatGPT (`feynman model login openai`), and Brave search keeps to its rate limits. The researcher is told to pass a search provider as a plain string, since some models sent `provider: "[\"parallel-mcp\"]"`, which `web_search` rejects; nicobailon/pi-web-access#491 makes `web_search` accept that form too.
14
+
15
+ ## v0.5.17 - 2026-09-30
16
+
17
+ - **`feynman model login openai` works again.** Since Pi 0.99, Sign in with ChatGPT needs a stable ID for the installation, which Feynman did not pass, so the login failed at once with "requires a device ID (UUID)". Feynman now shares the `deviceId` Pi's own `/login` keeps in `~/.feynman/agent/settings.json`.
18
+ - **Windows finds per-user Git Bash.** Pi looks for Git Bash only under Program Files and on PATH, but a per-user Git for Windows install puts only `Git\cmd` on PATH, so shell commands failed with "No bash shell found" (about one in five Windows installs that run tools). Feynman now finds that Git Bash and gives it to Pi. When there is no Bash at all, Feynman says so before the session starts, and `feynman doctor` reports the `bash` it found.
19
+ - Pressing Enter at the "paste the authorization code / redirect URL" login prompt no longer ends the login with "Missing authorization code"; Feynman asks again, and finishing in the browser still works.
20
+ - API keys are now masked as you paste them during setup, and a key with non-ASCII characters (for example typed with a non-Latin keyboard layout) is rejected with an explanation instead of failing every request with "Cannot convert argument to a ByteString".
21
+ - A rejected `SEMANTIC_SCHOLAR_API_KEY` now says so instead of `request failed: 403 Forbidden`.
22
+
9
23
  ## v0.5.16 - 2026-09-30
10
24
 
11
25
  - **Subagent research files land in the workspace.** When a workflow gave a subagent a relative output path such as `outputs/.drafts/<slug>-research-web.md`, pi-subagents saved it under `~/.feynman/sessions/subagent-artifacts/outputs/<run>/` instead, so the lead agent could not find the research notes, citations, or verification it asked for (about one in seven installs running research workflows hit this in the last week). Relative subagent outputs now resolve against the workspace, the researcher, reviewer, verifier, and writer no longer default to generic file names such as `research.md`, and `/deepresearch` and `/lit` name every subagent file under `outputs/.drafts/`. If you set `singleRunOutputBaseDir` in `~/.feynman/agent/extensions/subagent/config.json`, Feynman keeps your value.
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ import { ensureFeynmanHome, getDefaultSessionDir, getFeynmanAgentDir, getFeynman
20
20
  import { getLastPiStderr, launchPiChat, runPi } from "./pi/launch.js";
21
21
  import { installPiPackage, removePiPackage, listOptionalPackagePresets, normalizeOptionalPackagePresetName, resolvePackageSource, updatePiPackages, } from "./pi/packages.js";
22
22
  import { canonicalizeModelSpec, ensureFeynmanSettings, normalizeThinkingLevel, readJson, } from "./pi/settings.js";
23
- import { BUNDLED_PI_PACKAGES, buildPiEnv } from "./pi/runtime.js";
23
+ import { BUNDLED_PI_PACKAGES, buildPiEnv, findWindowsBash, WINDOWS_BASH_MISSING_NOTICE } from "./pi/runtime.js";
24
24
  import { getConfiguredServiceTier, normalizeServiceTier, setConfiguredServiceTier } from "./model/service-tier.js";
25
25
  import { authenticateModelProvider, getCurrentModelSpec, isLocalModelProvider, loginModelProvider, logoutModelProvider, printModelList, selectDefaultModel, setDefaultModelSpec, } from "./model/commands.js";
26
26
  import { buildModelStatusSnapshotFromRecords, getAuthenticatedModelRecords, isProClassModelSpec, getSupportedModelRecords, } from "./model/catalog.js";
@@ -681,6 +681,12 @@ async function runMain(input) {
681
681
  preLaunchNotice = buildLocalModelWorkflowNotice(effectiveSpec, command);
682
682
  }
683
683
  }
684
+ if (process.platform === "win32" && mode !== "rpc" && mode !== "json" && process.stdout.isTTY) {
685
+ const shellPath = readJson(feynmanSettingsPath).shellPath;
686
+ if (typeof shellPath !== "string" && !findWindowsBash()) {
687
+ preLaunchNotice = [preLaunchNotice, WINDOWS_BASH_MISSING_NOTICE].filter(Boolean).join("\n");
688
+ }
689
+ }
684
690
  await launchPiChat({
685
691
  ...piOptions,
686
692
  mode,
@@ -1,8 +1,10 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { exec as execCallback } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { dirname, resolve } from "node:path";
3
5
  import { promisify } from "node:util";
4
6
  import { readJson } from "../pi/settings.js";
5
- import { promptChoice, promptSelect, promptText } from "../setup/prompts.js";
7
+ import { promptChoice, promptSecret, promptSelect, promptText, validateApiKeyInput } from "../setup/prompts.js";
6
8
  import { openUrl } from "../system/open-url.js";
7
9
  import { printInfo, printSection, printSuccess, printWarning } from "../ui/terminal.js";
8
10
  import { buildModelStatusSnapshotFromRecords, chooseRecommendedModel, getAuthenticatedModelRecords, getAvailableModelRecords, getSupportedModelRecords, isProClassModelSpec, } from "./catalog.js";
@@ -624,7 +626,7 @@ async function configureApiKeyProvider(authPath, providerId) {
624
626
  if (provider.envVar) {
625
627
  printInfo(`Tip: to avoid writing secrets to disk, set ${provider.envVar} in your shell or .env.`);
626
628
  }
627
- const apiKey = await promptText("Paste API key (leave empty to use env var instead)", "");
629
+ const apiKey = await promptSecret("Paste API key (leave empty to use env var instead)", undefined, validateApiKeyInput);
628
630
  if (!apiKey) {
629
631
  if (provider.envVar) {
630
632
  printInfo(`Set ${provider.envVar} and rerun setup (or run \`feynman model list\`).`);
@@ -729,6 +731,17 @@ export async function authenticateModelProvider(authPath, settingsPath) {
729
731
  printInfo("Authentication cancelled.");
730
732
  return false;
731
733
  }
734
+ // Sign in with ChatGPT sends a stable installation ID. Pi's own /login keeps it
735
+ // as `deviceId` in the agent settings.json; share that one.
736
+ export function getOrCreateDeviceId(authPath) {
737
+ const settingsPath = resolve(dirname(authPath), "settings.json");
738
+ const settings = readJson(settingsPath);
739
+ if (typeof settings.deviceId === "string" && settings.deviceId)
740
+ return settings.deviceId;
741
+ const deviceId = randomUUID();
742
+ writeFileSync(settingsPath, `${JSON.stringify({ ...settings, deviceId }, null, 2)}\n`, "utf8");
743
+ return deviceId;
744
+ }
732
745
  export async function loginModelProvider(authPath, providerId, settingsPath) {
733
746
  if (providerId) {
734
747
  const resolvedProvider = await resolveModelProviderForCommand(authPath, providerId);
@@ -762,7 +775,13 @@ export async function loginModelProvider(authPath, providerId, settingsPath) {
762
775
  hint: option.description,
763
776
  })), undefined, prompt.signal);
764
777
  }
765
- return promptText(prompt.message, "", prompt.placeholder, prompt.signal);
778
+ if (prompt.type === "secret") {
779
+ return promptSecret(prompt.message, prompt.signal);
780
+ }
781
+ // An empty answer here fails the login with "Missing authorization code";
782
+ // keep asking, since the browser callback can still finish meanwhile.
783
+ const required = prompt.type === "manual_code" ? "Paste the redirect URL or code, or finish signing in in the browser." : undefined;
784
+ return promptText(prompt.message, "", prompt.placeholder, prompt.signal, required);
766
785
  },
767
786
  notify: (event) => {
768
787
  if (event.type === "auth_url") {
@@ -790,7 +809,7 @@ export async function loginModelProvider(authPath, providerId, settingsPath) {
790
809
  }
791
810
  },
792
811
  signal: abortController.signal,
793
- });
812
+ }, { getDeviceId: () => getOrCreateDeviceId(authPath) });
794
813
  printSuccess(`Model provider login complete: ${provider.id}`);
795
814
  await maybeSetRecommendedDefaultModel(settingsPath, authPath);
796
815
  return true;
@@ -1,6 +1,6 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
- import { delimiter, dirname, resolve } from "node:path";
3
+ import { delimiter, dirname, resolve, win32 } from "node:path";
4
4
  import { BROWSER_FALLBACK_PATHS, MERMAID_FALLBACK_PATHS, PANDOC_FALLBACK_PATHS, resolveExecutable, } from "../system/executables.js";
5
5
  import { getPostHogChildEnv } from "../telemetry/posthog.js";
6
6
  // Pi packages shipped as Feynman dependencies and loaded from their install
@@ -116,6 +116,31 @@ export function buildPiArgs(options) {
116
116
  }
117
117
  return args;
118
118
  }
119
+ // Pi looks for Git Bash only under Program Files and for bash.exe on PATH. A
120
+ // per-user Git for Windows install (%LOCALAPPDATA%\Programs\Git) puts only
121
+ // Git\cmd on PATH, so Pi reports "No bash shell found" although Git Bash is
122
+ // installed. `addDir` is the Git\bin directory to add to Pi's PATH in that case.
123
+ export function findWindowsBash(env = process.env, exists = existsSync) {
124
+ const pathDirs = (env.PATH ?? env.Path ?? "").split(";").filter(Boolean);
125
+ const known = [env.ProgramFiles, env["ProgramFiles(x86)"]]
126
+ .filter((dir) => Boolean(dir))
127
+ .map((dir) => win32.join(dir, "Git", "bin", "bash.exe"));
128
+ const found = [...known, ...pathDirs.map((dir) => win32.join(dir, "bash.exe"))].find(exists);
129
+ if (found)
130
+ return { path: found };
131
+ const gitRoots = [
132
+ // git.exe lives in Git\cmd or Git\mingw64\bin.
133
+ ...pathDirs.filter((dir) => exists(win32.join(dir, "git.exe"))).flatMap((dir) => [win32.dirname(dir), win32.dirname(win32.dirname(dir))]),
134
+ ...(env.LOCALAPPDATA ? [win32.join(env.LOCALAPPDATA, "Programs", "Git")] : []),
135
+ ];
136
+ for (const root of gitRoots) {
137
+ const bash = win32.join(root, "bin", "bash.exe");
138
+ if (exists(bash))
139
+ return { path: bash, addDir: win32.join(root, "bin") };
140
+ }
141
+ return undefined;
142
+ }
143
+ export const WINDOWS_BASH_MISSING_NOTICE = "No Bash found: Feynman runs shell commands with Bash, so they will fail. Install Git for Windows (https://git-scm.com/download/win), or set shellPath in ~/.feynman/agent/settings.json to another bash.exe.";
119
144
  export function buildPiEnv(options, executables) {
120
145
  const binPath = [getFeynmanCommandShimDir(options.feynmanAgentDir), resolve(options.appRoot, "node_modules", ".bin")].join(delimiter);
121
146
  const pandocPath = process.env.PANDOC_PATH ?? executables?.pandoc ?? resolveExecutable("pandoc", PANDOC_FALLBACK_PATHS);
@@ -123,7 +148,9 @@ export function buildPiEnv(options, executables) {
123
148
  const browserPath = process.env.PUPPETEER_EXECUTABLE_PATH ?? executables?.browser ?? resolveExecutable("google-chrome", BROWSER_FALLBACK_PATHS);
124
149
  return {
125
150
  ...process.env,
126
- PATH: `${binPath}${delimiter}${process.env.PATH ?? ""}`,
151
+ PATH: [binPath, process.env.PATH ?? "", process.platform === "win32" ? findWindowsBash()?.addDir : undefined]
152
+ .filter(Boolean)
153
+ .join(delimiter),
127
154
  FEYNMAN_VERSION: options.feynmanVersion,
128
155
  FEYNMAN_NODE_EXECUTABLE: process.execPath,
129
156
  FEYNMAN_BIN_PATH: getFeynmanCliBinPath(options.appRoot),
@@ -5,7 +5,7 @@ import { verifyAlphaAuthStatus } from "../alpha-auth-status.js";
5
5
  import { formatPiWebAccessDoctorLines, getPiWebAccessStatus } from "../pi/web-access.js";
6
6
  import { BROWSER_FALLBACK_PATHS, PANDOC_FALLBACK_PATHS, resolveExecutable } from "../system/executables.js";
7
7
  import { readJson } from "../pi/settings.js";
8
- import { validatePiInstallation } from "../pi/runtime.js";
8
+ import { findWindowsBash, validatePiInstallation, WINDOWS_BASH_MISSING_NOTICE } from "../pi/runtime.js";
9
9
  import { isTelemetryDisabled } from "../telemetry/posthog.js";
10
10
  import { printInfo, printPanel, printSection } from "../ui/terminal.js";
11
11
  import { getCurrentModelSpec } from "../model/commands.js";
@@ -163,6 +163,10 @@ export async function runDoctor(options) {
163
163
  }
164
164
  }
165
165
  console.log(`pandoc: ${pandocPath ?? "missing"}`);
166
+ if (process.platform === "win32") {
167
+ const shellPath = settings.shellPath;
168
+ console.log(`bash: ${typeof shellPath === "string" ? `${shellPath} (shellPath)` : findWindowsBash()?.path ?? `missing. ${WINDOWS_BASH_MISSING_NOTICE}`}`);
169
+ }
166
170
  console.log(`browser preview runtime: ${browserPath ?? "missing"}`);
167
171
  for (const line of formatPiWebAccessDoctorLines()) {
168
172
  console.log(line);
@@ -1,4 +1,4 @@
1
- import { confirm as clackConfirm, intro as clackIntro, isCancel, multiselect as clackMultiselect, outro as clackOutro, select as clackSelect, text as clackText, } from "@clack/prompts";
1
+ import { confirm as clackConfirm, intro as clackIntro, isCancel, multiselect as clackMultiselect, outro as clackOutro, password as clackPassword, select as clackSelect, text as clackText, } from "@clack/prompts";
2
2
  export class SetupCancelledError extends Error {
3
3
  constructor(message = "setup cancelled") {
4
4
  super(message);
@@ -36,17 +36,30 @@ export async function promptOutro(message) {
36
36
  ensureInteractiveTerminal();
37
37
  clackOutro(message);
38
38
  }
39
- export async function promptText(question, defaultValue = "", placeholder, signal) {
39
+ export async function promptText(question, defaultValue = "", placeholder, signal, requiredMessage) {
40
40
  ensureInteractiveTerminal();
41
41
  const value = guardCancelled(await clackText({
42
42
  message: question,
43
43
  initialValue: defaultValue || undefined,
44
44
  placeholder: placeholder ?? (defaultValue || undefined),
45
45
  signal,
46
+ validate: requiredMessage ? (input) => (input?.trim() ? undefined : requiredMessage) : undefined,
46
47
  }));
47
48
  const normalized = String(value ?? "").trim();
48
49
  return normalized || defaultValue;
49
50
  }
51
+ export async function promptSecret(question, signal, validate) {
52
+ ensureInteractiveTerminal();
53
+ const value = guardCancelled(await clackPassword({ message: question, signal, validate: validate ? (input) => validate((input ?? "").trim()) : undefined }));
54
+ return String(value ?? "").trim();
55
+ }
56
+ // API keys are printable ASCII. A key typed with a non-Latin keyboard layout
57
+ // fails every request with "Cannot convert argument to a ByteString".
58
+ export function validateApiKeyInput(value) {
59
+ return /^[\x21-\x7e]*$/.test(value)
60
+ ? undefined
61
+ : "API keys contain only ASCII letters, digits, and symbols. Check your keyboard input language and paste the key again.";
62
+ }
50
63
  export async function promptSelect(question, options, initialValue, signal) {
51
64
  ensureInteractiveTerminal();
52
65
  const selection = guardCancelled(await clackSelect({
@@ -13,6 +13,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { Type } from "typebox";
14
14
 
15
15
  import { extractPaperSections } from "./alpha-sections.js";
16
+ import { searchArxiv } from "./science-database-arxiv.js";
17
+ import { searchOpenAlex } from "./science-database-openalex.js";
16
18
 
17
19
  function formatText(value: unknown): string {
18
20
  if (typeof value === "string") return value;
@@ -21,11 +23,23 @@ function formatText(value: unknown): string {
21
23
 
22
24
  // alphaXiv's API has changed without notice before; when a call fails, point
23
25
  // the model at the other paper sources instead of leaving it on a broken tool.
24
- async function withPaperFallback<T>(run: () => Promise<T>): Promise<T> {
26
+ const ARXIV_ID = /(?:^|arxiv\.org\/(?:abs|pdf)\/|alphaxiv\.org\/(?:abs|overview)\/)(\d{4}\.\d{4,5}(?:v\d+)?|[a-z-]+(?:\.[A-Z]{2})?\/\d{7}(?:v\d+)?)/i;
27
+
28
+ // When alphaXiv does not answer (expired login, outage), answer from another
29
+ // paper source and say so first, instead of failing the research step.
30
+ async function withPaperFallback<T>(
31
+ run: () => Promise<T>,
32
+ fallback?: { source: string; run: () => Promise<Record<string, unknown>> },
33
+ ): Promise<T | Record<string, unknown>> {
25
34
  try {
26
35
  return await run();
27
36
  } catch (error) {
28
37
  const message = (error instanceof Error ? error.message : String(error)).replace("`alpha login`", "`feynman alpha login`");
38
+ if (fallback) {
39
+ try {
40
+ return { fallbackNote: `alphaXiv did not answer (${message}); these results are from ${fallback.source}.`, ...(await fallback.run()) };
41
+ } catch {}
42
+ }
29
43
  throw new Error(
30
44
  `${message}\nalphaXiv did not answer this call. Use feynman_science_database_search (source "arxiv" or "semanticscholar") or fetch_content on https://arxiv.org/abs/<id> instead.`,
31
45
  );
@@ -65,7 +79,10 @@ export function registerAlphaTools(pi: ExtensionAPI, signedIn = isLoggedIn()): v
65
79
  ),
66
80
  }),
67
81
  async execute(_toolCallId, params) {
68
- const result = await withPaperFallback(() => searchPapers(params.query, params.mode?.trim() || "semantic"));
82
+ const result = await withPaperFallback(() => searchPapers(params.query, params.mode?.trim() || "semantic"), {
83
+ source: "OpenAlex semantic search",
84
+ run: () => searchOpenAlex({ source: "openalex", query: `semantic: ${params.query}`, limit: 10 }),
85
+ });
69
86
  return { content: [{ type: "text", text: formatText(result) }], details: result };
70
87
  },
71
88
  });
@@ -87,7 +104,14 @@ export function registerAlphaTools(pi: ExtensionAPI, signedIn = isLoggedIn()): v
87
104
  sections: Type.Optional(paperSectionsSchema),
88
105
  }),
89
106
  async execute(_toolCallId, params) {
90
- const result = await withPaperFallback(() => getPaper(params.paper, { fullText: params.fullText }));
107
+ const arxivId = ARXIV_ID.exec(params.paper.trim())?.[1];
108
+ const result = await withPaperFallback(() => getPaper(params.paper, { fullText: params.fullText }), arxivId ? {
109
+ source: "arXiv metadata and abstract (no full text or sections)",
110
+ run: () => searchArxiv({ query: arxivId }),
111
+ } : undefined);
112
+ if ("fallbackNote" in result) {
113
+ return { content: [{ type: "text", text: formatText(result) }], details: result };
114
+ }
91
115
  const extracted = extractPaperSections(result.content, params.section, params.sections);
92
116
  const filteredResult = extracted.requested.length
93
117
  ? {
@@ -270,6 +270,9 @@ async function fetchSemanticScholar(url: URL): Promise<unknown> {
270
270
  try {
271
271
  return await fetchJson(url, headers);
272
272
  } catch (error) {
273
+ if (apiKey && error instanceof ScienceDatabaseRequestError && error.status === 403) {
274
+ throw new ScienceDatabaseRequestError("Semantic Scholar rejected SEMANTIC_SCHOLAR_API_KEY (HTTP 403). Check the key, or unset it to use the shared pool; search with source openalex meanwhile.", 403);
275
+ }
273
276
  if (!(error instanceof ScienceDatabaseRequestError) || error.status !== 429) throw error;
274
277
  if (attempt >= 1) {
275
278
  throw new ScienceDatabaseRequestError(apiKey
@@ -377,19 +380,54 @@ async function searchSemanticScholar(params: ScienceDatabaseSearchParams): Promi
377
380
  };
378
381
  }
379
382
 
383
+ // OpenAlex lookups by ID, DOI, author, venue, or citation have no Semantic
384
+ // Scholar equivalent; only plain topic searches fall back.
385
+ const OPENALEX_LOOKUP_QUERY =
386
+ /^(?:openalex_\w+|rate-limit|authors?|author-search|sources?|venues?|citations?|references?|work|detail)(?::|\s|$)|^(?:W\d+|doi:\s*10\.|10\.\S+\/)/i;
387
+
388
+ function isIndexUnavailable(error: unknown): boolean {
389
+ if (error instanceof ScienceDatabaseRequestError) return error.status === 429 || error.status >= 500;
390
+ return error instanceof Error && /^OpenAlex request failed: (?:429|5\d\d)\b/.test(error.message);
391
+ }
392
+
393
+ // A rate-limited or overloaded index should not end a research step: answer a
394
+ // topic search from the other general index, and say so first in the result.
395
+ async function withIndexFallback(
396
+ run: () => Promise<Record<string, unknown>>,
397
+ fallback: (() => Promise<Record<string, unknown>>) | undefined,
398
+ describe: (reason: string) => string,
399
+ ): Promise<Record<string, unknown>> {
400
+ try {
401
+ return await run();
402
+ } catch (error) {
403
+ if (!fallback || !isIndexUnavailable(error)) throw error;
404
+ let result: Record<string, unknown>;
405
+ try {
406
+ result = await fallback();
407
+ } catch {
408
+ throw error;
409
+ }
410
+ return { fallbackNote: describe(error instanceof Error ? error.message : String(error)), ...result };
411
+ }
412
+ }
413
+
380
414
  async function scienceDatabaseSearch(params: ScienceDatabaseSearchParams): Promise<Record<string, unknown>> {
381
415
  if (params.source === "arxiv") return searchArxiv(params);
382
416
  if (params.source === "biorxiv") return searchPreprints(params, "biorxiv");
383
417
  if (params.source === "medrxiv") return searchPreprints(params, "medrxiv");
384
418
  if (params.source === "pubmed") return searchPubMed(params);
385
419
  if (params.source === "crossref") return searchCrossref(params);
386
- if (params.source === "semanticscholar") return searchSemanticScholar(params);
420
+ const openAlex = () => searchOpenAlex({ limit: params.limit, query: params.query, source: "openalex" });
421
+ const semanticScholar = () => searchSemanticScholar({ ...params, source: "semanticscholar" });
422
+ if (params.source === "semanticscholar") {
423
+ return withIndexFallback(semanticScholar, openAlex, (reason) => `Semantic Scholar was unavailable (${reason}); these results are from OpenAlex.`);
424
+ }
387
425
  if (params.source === "openalex") {
388
- return searchOpenAlex({
389
- limit: params.limit,
390
- query: params.query,
391
- source: params.source,
392
- });
426
+ return withIndexFallback(
427
+ openAlex,
428
+ OPENALEX_LOOKUP_QUERY.test(params.query.trim()) ? undefined : semanticScholar,
429
+ (reason) => `OpenAlex was unavailable (${reason}); these results are from Semantic Scholar.`,
430
+ );
393
431
  }
394
432
  return searchEuropePmc(params);
395
433
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@companion-ai/feynman",
3
- "version": "0.5.16",
3
+ "version": "0.5.18",
4
4
  "description": "Research-first CLI agent built on Pi and alphaXiv",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -83,7 +83,7 @@
83
83
  "pi-btw": "0.6.1",
84
84
  "pi-docparser": "4.0.0",
85
85
  "pi-subagents": "0.73.1",
86
- "pi-web-access": "0.34.0",
86
+ "pi-web-access": "0.35.0",
87
87
  "posthog-node": "^5.53.0",
88
88
  "undici": "^8.11.2"
89
89
  },