@companion-ai/feynman 0.5.44 → 0.5.45

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/RELEASES.md CHANGED
@@ -6,6 +6,12 @@ GitHub release notes are generated from the matching `## vX.Y.Z` section in this
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## v0.5.45 - 2026-10-13
10
+
11
+ - **Paper lookups recover instead of failing.**
12
+ - A topic sent to the arXiv source, which only looks papers up by ID, now gets OpenAlex semantic search results with a note saying so, instead of an error the model had to recover from.
13
+ - When arXiv times out or is overloaded, arXiv ID lookups come from Semantic Scholar, with a note saying so.
14
+
9
15
  ## v0.5.44 - 2026-10-13
10
16
 
11
17
  - **`feynman search set` works with every search provider pi-web-access supports.** It accepted only `auto`, `exa`, `perplexity` and `gemini`; it now takes all 39, including Brave, Tavily, Kagi, Serper, You.com, SearXNG and DuckDuckGo, and saves each key where pi-web-access reads it. `feynman search status` and `feynman doctor` name the provider you chose and list every saved key, and no longer describe an outdated automatic search order.
@@ -35,6 +35,20 @@ function cleanQuery(query: string): string {
35
35
  const arxivPacer = createRequestPacer();
36
36
  export const ARXIV_MIN_GAP_MS = 3000;
37
37
 
38
+ // The query held no arXiv ID; the caller searches another index for `topic`.
39
+ export class ArxivTopicQueryError extends Error {
40
+ constructor(message: string, readonly topic: string) {
41
+ super(message);
42
+ }
43
+ }
44
+
45
+ // arXiv timed out or answered 429/5xx; the caller can look `ids` up elsewhere.
46
+ export class ArxivUnavailableError extends Error {
47
+ constructor(message: string, readonly ids: string[]) {
48
+ super(message);
49
+ }
50
+ }
51
+
38
52
  async function fetchText(url: URL, accept: string): Promise<string> {
39
53
  for (let attempt = 0; ; attempt += 1) {
40
54
  const result = await arxivPacer(ARXIV_MIN_GAP_MS, async () => {
@@ -157,13 +171,25 @@ async function getArxivPapers(query: string): Promise<Record<string, unknown>> {
157
171
  else notFound.push(raw);
158
172
  }
159
173
  if (!ids.length) {
160
- throw new Error(`No arXiv IDs found in "${query}". The arxiv source only looks up papers by ID; search topics with source semanticscholar or openalex.`);
174
+ throw new ArxivTopicQueryError(
175
+ `No arXiv IDs found in "${query}". The arxiv source only looks up papers by ID; search topics with source semanticscholar or openalex.`,
176
+ parsed.text || stripGetPapersCommand(query),
177
+ );
161
178
  }
162
179
  const rows: Record<string, unknown>[] = [];
163
180
  const duplicates: Array<Record<string, unknown>> = [];
164
181
  const url = new URL(ARXIV_QUERY_URL);
165
182
  url.search = new URLSearchParams({ id_list: ids.join(","), max_results: String(ids.length) }).toString();
166
- const xml = await fetchText(url, "application/atom+xml, application/xml;q=0.9, text/xml;q=0.8");
183
+ let xml: string;
184
+ try {
185
+ xml = await fetchText(url, "application/atom+xml, application/xml;q=0.9, text/xml;q=0.8");
186
+ } catch (error) {
187
+ const reason = error instanceof Error && error.name === "AbortError" ? `no answer within ${REQUEST_TIMEOUT_MS / 1000} s` : error instanceof Error ? error.message : String(error);
188
+ if (error instanceof Error && (error.name === "AbortError" || /request failed: (?:429|5\d\d)\b/.test(error.message))) {
189
+ throw new ArxivUnavailableError(reason, ids);
190
+ }
191
+ throw error;
192
+ }
167
193
  const feed = recordValue(xmlParser.parse(xml).feed);
168
194
  const byId = new Map<string, Record<string, unknown>>();
169
195
  for (const row of arxivFeedEntries(feed).map(parseArxivEntry).filter((item): item is Record<string, unknown> => Boolean(item))) {
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { searchArxiv } from "./science-database-arxiv.js";
3
+ import { ArxivTopicQueryError, ArxivUnavailableError, searchArxiv } from "./science-database-arxiv.js";
4
4
  import { searchOpenAlex } from "./science-database-openalex.js";
5
5
  import { searchPreprints } from "./science-database-preprints.js";
6
6
  import { searchPubMed } from "./science-database-pubmed.js";
@@ -322,6 +322,29 @@ function semanticScholarSearchUrl(query: string, limit: number, sort: string, ye
322
322
 
323
323
  // Bulk search sorted by citation count surfaces seminal papers that relevance
324
324
  // ranking misses; it returns up to 1,000 rows, so only the top `limit` are kept.
325
+ function semanticScholarRecord(item: unknown): Record<string, unknown>[] {
326
+ const record = recordValue(item);
327
+ const paperId = stringValue(record.paperId);
328
+ if (!paperId) return [];
329
+ const externalIds = recordValue(record.externalIds);
330
+ const doi = stringValue(externalIds.DOI);
331
+ return [{
332
+ paperId,
333
+ title: stringValue(record.title),
334
+ year: numberValue(record.year),
335
+ authors: arrayValue(record.authors).map((author) => stringValue(recordValue(author).name)).filter(Boolean).slice(0, 8),
336
+ venue: stringValue(record.venue),
337
+ citationCount: numberValue(record.citationCount),
338
+ doi,
339
+ arxivId: stringValue(externalIds.ArXiv),
340
+ pmid: stringValue(externalIds.PubMed),
341
+ openAccessPdf: stringValue(recordValue(record.openAccessPdf).url),
342
+ abstract: truncateText(stringValue(record.abstract), SEMANTIC_SCHOLAR_ABSTRACT_CHARS),
343
+ url: `https://www.semanticscholar.org/paper/${paperId}`,
344
+ ...(doiUrl(doi) ? { doiUrl: doiUrl(doi) } : {}),
345
+ }];
346
+ }
347
+
325
348
  async function searchSemanticScholar(params: ScienceDatabaseSearchParams): Promise<Record<string, unknown>> {
326
349
  const { query, year } = parseSemanticScholarQuery(cleanQuery(params.query));
327
350
  const limit = safeLimit(params.limit);
@@ -340,28 +363,7 @@ async function searchSemanticScholar(params: ScienceDatabaseSearchParams): Promi
340
363
  note = `Relevance search was rate-limited (HTTP 429) on Semantic Scholar's shared anonymous pool, so these are citation-sorted bulk results. A free SEMANTIC_SCHOLAR_API_KEY avoids this: ${SEMANTIC_SCHOLAR_KEY_URL}`;
341
364
  payload = recordValue(await fetchSemanticScholar(endpoints[1]!));
342
365
  }
343
- const results = arrayValue(payload.data).slice(0, limit).flatMap((item) => {
344
- const record = recordValue(item);
345
- const paperId = stringValue(record.paperId);
346
- if (!paperId) return [];
347
- const externalIds = recordValue(record.externalIds);
348
- const doi = stringValue(externalIds.DOI);
349
- return [{
350
- paperId,
351
- title: stringValue(record.title),
352
- year: numberValue(record.year),
353
- authors: arrayValue(record.authors).map((author) => stringValue(recordValue(author).name)).filter(Boolean).slice(0, 8),
354
- venue: stringValue(record.venue),
355
- citationCount: numberValue(record.citationCount),
356
- doi,
357
- arxivId: stringValue(externalIds.ArXiv),
358
- pmid: stringValue(externalIds.PubMed),
359
- openAccessPdf: stringValue(recordValue(record.openAccessPdf).url),
360
- abstract: truncateText(stringValue(record.abstract), SEMANTIC_SCHOLAR_ABSTRACT_CHARS),
361
- url: `https://www.semanticscholar.org/paper/${paperId}`,
362
- ...(doiUrl(doi) ? { doiUrl: doiUrl(doi) } : {}),
363
- }];
364
- });
366
+ const results = arrayValue(payload.data).slice(0, limit).flatMap(semanticScholarRecord);
365
367
  return {
366
368
  schema: "feynman.scienceDatabaseSearch.v1",
367
369
  source: "semanticscholar",
@@ -418,8 +420,49 @@ async function withIndexFallback(
418
420
  }
419
421
  }
420
422
 
423
+ // arXiv IDs looked up on Semantic Scholar, one request per ID (at most 10).
424
+ async function semanticScholarByArxivIds(ids: string[]): Promise<Record<string, unknown>> {
425
+ const endpoints: string[] = [];
426
+ const results: Record<string, unknown>[] = [];
427
+ const notFound: string[] = [];
428
+ for (const id of ids.slice(0, 10)) {
429
+ const bare = id.replace(/v\d+$/, "");
430
+ const url = new URL(`${SEMANTIC_SCHOLAR_BASE}/paper/ARXIV:${bare}`);
431
+ url.searchParams.set("fields", SEMANTIC_SCHOLAR_FIELDS);
432
+ endpoints.push(url.toString());
433
+ try {
434
+ results.push(...semanticScholarRecord(await fetchSemanticScholar(url)));
435
+ } catch (error) {
436
+ if (!(error instanceof ScienceDatabaseRequestError) || error.status !== 404) throw error;
437
+ notFound.push(id);
438
+ }
439
+ }
440
+ return { schema: "feynman.scienceDatabaseSearch.v1", source: "semanticscholar", query: ids.join(","), not_found: notFound, results, provenance: { endpoints } };
441
+ }
442
+
443
+ async function searchArxivWithFallback(params: ScienceDatabaseSearchParams): Promise<Record<string, unknown>> {
444
+ try {
445
+ return await searchArxiv(params);
446
+ } catch (error) {
447
+ if (error instanceof ArxivTopicQueryError) {
448
+ const result = await searchOpenAlex({ limit: params.limit, query: `semantic: ${error.topic}`, source: "openalex" });
449
+ return { fallbackNote: "The arxiv source only looks up papers by ID, so this topic was searched with OpenAlex semantic search instead.", ...result };
450
+ }
451
+ if (error instanceof ArxivUnavailableError) {
452
+ let result: Record<string, unknown>;
453
+ try {
454
+ result = await semanticScholarByArxivIds(error.ids);
455
+ } catch {
456
+ throw error;
457
+ }
458
+ return { fallbackNote: `arXiv was unavailable (${error.message}); these records are from Semantic Scholar.`, ...result };
459
+ }
460
+ throw error;
461
+ }
462
+ }
463
+
421
464
  async function scienceDatabaseSearch(params: ScienceDatabaseSearchParams): Promise<Record<string, unknown>> {
422
- if (params.source === "arxiv") return searchArxiv(params);
465
+ if (params.source === "arxiv") return searchArxivWithFallback(params);
423
466
  if (params.source === "biorxiv") return searchPreprints(params, "biorxiv");
424
467
  if (params.source === "medrxiv") return searchPreprints(params, "medrxiv");
425
468
  if (params.source === "pubmed") return searchPubMed(params);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@companion-ai/feynman",
3
- "version": "0.5.44",
3
+ "version": "0.5.45",
4
4
  "description": "The open source AI research agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -82,7 +82,7 @@
82
82
  "@earendil-works/pi-codemode": "1.2.0",
83
83
  "@earendil-works/pi-mcp": "1.2.0",
84
84
  "@earendil-works/pi-telemetry": "1.2.0",
85
- "fast-xml-parser": "5.11.1",
85
+ "fast-xml-parser": "5.11.2",
86
86
  "pi-btw": "0.8.0",
87
87
  "pi-docparser": "4.0.0",
88
88
  "pi-subagents": "0.78.0",