@companion-ai/feynman 0.5.44 → 0.5.46
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,17 @@ GitHub release notes are generated from the matching `## vX.Y.Z` section in this
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## v0.5.46 - 2026-10-13
|
|
10
|
+
|
|
11
|
+
- **Recent arXiv papers resolve by DOI.** OpenAlex adds new arXiv preprints days late, so looking up an arXiv DOI (`10.48550/arXiv.<id>`) it doesn't have yet failed. It is now looked up on arXiv itself, with a note.
|
|
12
|
+
- When a topic sent to the arXiv source is written in arXiv's own query syntax (`search_query=all:...`, `submittedDate:[...]`), only the search words are passed on to OpenAlex.
|
|
13
|
+
|
|
14
|
+
## v0.5.45 - 2026-10-13
|
|
15
|
+
|
|
16
|
+
- **Paper lookups recover instead of failing.**
|
|
17
|
+
- 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.
|
|
18
|
+
- When arXiv times out or is overloaded, arXiv ID lookups come from Semantic Scholar, with a note saying so.
|
|
19
|
+
|
|
9
20
|
## v0.5.44 - 2026-10-13
|
|
10
21
|
|
|
11
22
|
- **`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 () => {
|
|
@@ -145,6 +159,19 @@ function arxivFeedEntries(feed: Record<string, unknown>): Record<string, unknown
|
|
|
145
159
|
return entries;
|
|
146
160
|
}
|
|
147
161
|
|
|
162
|
+
// Plain search words from a query written in arXiv's API syntax
|
|
163
|
+
// (search_query=all:..., AND submittedDate:[...], &max_results=...).
|
|
164
|
+
export function arxivTopicText(query: string): string {
|
|
165
|
+
return query
|
|
166
|
+
.replace(/&?\b(?:max_results|start|sortBy|sortOrder)=[^&\s]*/gi, " ")
|
|
167
|
+
.replace(/\b(?:AND|OR|ANDNOT)\s+submittedDate:\[[^\]]*\]/gi, " ")
|
|
168
|
+
.replace(/\bsearch_query[=_:]+/gi, " ")
|
|
169
|
+
.replace(/\b(?:all|ti|abs|au|cat|co|jr):/gi, " ")
|
|
170
|
+
.replace(/[&"()]/g, " ")
|
|
171
|
+
.replace(/\s+/g, " ")
|
|
172
|
+
.trim();
|
|
173
|
+
}
|
|
174
|
+
|
|
148
175
|
async function getArxivPapers(query: string): Promise<Record<string, unknown>> {
|
|
149
176
|
const parsed = parseToolKeyValueQuery(stripGetPapersCommand(query));
|
|
150
177
|
const requested = (parsed.flags.ids ?? parsed.text).split(/[,\s]+/).map((id) => id.trim()).filter(Boolean).slice(0, MAX_ARXIV_EXACT_RESULTS);
|
|
@@ -157,13 +184,25 @@ async function getArxivPapers(query: string): Promise<Record<string, unknown>> {
|
|
|
157
184
|
else notFound.push(raw);
|
|
158
185
|
}
|
|
159
186
|
if (!ids.length) {
|
|
160
|
-
throw new
|
|
187
|
+
throw new ArxivTopicQueryError(
|
|
188
|
+
`No arXiv IDs found in "${query}". The arxiv source only looks up papers by ID; search topics with source semanticscholar or openalex.`,
|
|
189
|
+
arxivTopicText(parsed.text || stripGetPapersCommand(query)),
|
|
190
|
+
);
|
|
161
191
|
}
|
|
162
192
|
const rows: Record<string, unknown>[] = [];
|
|
163
193
|
const duplicates: Array<Record<string, unknown>> = [];
|
|
164
194
|
const url = new URL(ARXIV_QUERY_URL);
|
|
165
195
|
url.search = new URLSearchParams({ id_list: ids.join(","), max_results: String(ids.length) }).toString();
|
|
166
|
-
|
|
196
|
+
let xml: string;
|
|
197
|
+
try {
|
|
198
|
+
xml = await fetchText(url, "application/atom+xml, application/xml;q=0.9, text/xml;q=0.8");
|
|
199
|
+
} catch (error) {
|
|
200
|
+
const reason = error instanceof Error && error.name === "AbortError" ? `no answer within ${REQUEST_TIMEOUT_MS / 1000} s` : error instanceof Error ? error.message : String(error);
|
|
201
|
+
if (error instanceof Error && (error.name === "AbortError" || /request failed: (?:429|5\d\d)\b/.test(error.message))) {
|
|
202
|
+
throw new ArxivUnavailableError(reason, ids);
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
167
206
|
const feed = recordValue(xmlParser.parse(xml).feed);
|
|
168
207
|
const byId = new Map<string, Record<string, unknown>>();
|
|
169
208
|
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(
|
|
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,65 @@ 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
|
+
|
|
464
|
+
// OpenAlex indexes new arXiv preprints days late; arXiv itself answers for its
|
|
465
|
+
// own DOIs (10.48550/arXiv.<id>).
|
|
466
|
+
async function withArxivDoiFallback(
|
|
467
|
+
params: ScienceDatabaseSearchParams,
|
|
468
|
+
run: () => Promise<Record<string, unknown>>,
|
|
469
|
+
): Promise<Record<string, unknown>> {
|
|
470
|
+
try {
|
|
471
|
+
return await run();
|
|
472
|
+
} catch (error) {
|
|
473
|
+
const arxivId = error instanceof Error ? /^OpenAlex has no work for doi:10\.48550\/arxiv\.(.+)$/i.exec(error.message)?.[1] : undefined;
|
|
474
|
+
if (!arxivId) throw error;
|
|
475
|
+
const result = await searchArxivWithFallback({ ...params, source: "arxiv", query: arxivId });
|
|
476
|
+
return { fallbackNote: `OpenAlex has no record of this arXiv DOI yet, so it was looked up on arXiv (${arxivId}).`, ...result };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
421
480
|
async function scienceDatabaseSearch(params: ScienceDatabaseSearchParams): Promise<Record<string, unknown>> {
|
|
422
|
-
if (params.source === "arxiv") return
|
|
481
|
+
if (params.source === "arxiv") return searchArxivWithFallback(params);
|
|
423
482
|
if (params.source === "biorxiv") return searchPreprints(params, "biorxiv");
|
|
424
483
|
if (params.source === "medrxiv") return searchPreprints(params, "medrxiv");
|
|
425
484
|
if (params.source === "pubmed") return searchPubMed(params);
|
|
@@ -430,11 +489,11 @@ async function scienceDatabaseSearch(params: ScienceDatabaseSearchParams): Promi
|
|
|
430
489
|
return withIndexFallback(semanticScholar, openAlex, (reason) => `Semantic Scholar was unavailable (${reason}); these results are from OpenAlex.`);
|
|
431
490
|
}
|
|
432
491
|
if (params.source === "openalex") {
|
|
433
|
-
return withIndexFallback(
|
|
492
|
+
return withArxivDoiFallback(params, () => withIndexFallback(
|
|
434
493
|
openAlex,
|
|
435
494
|
OPENALEX_LOOKUP_QUERY.test(params.query.trim()) ? undefined : semanticScholar,
|
|
436
495
|
(reason) => `OpenAlex was unavailable (${reason}); these results are from Semantic Scholar.`,
|
|
437
|
-
);
|
|
496
|
+
));
|
|
438
497
|
}
|
|
439
498
|
return searchEuropePmc(params);
|
|
440
499
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@companion-ai/feynman",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.46",
|
|
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.
|
|
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",
|