@inflexa-ai/harness 0.15.1 → 0.17.0
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/dist/agents/conversation-agent.d.ts +3 -0
- package/dist/agents/conversation-agent.js +5 -2
- package/dist/agents/sandbox/shared.d.ts +3 -0
- package/dist/agents/sandbox/shared.js +5 -0
- package/dist/agents/sandbox/types.d.ts +1 -1
- package/dist/app/chat-turn.d.ts +2 -0
- package/dist/app/chat-turn.js +11 -2
- package/dist/app/synthesize-run.d.ts +3 -0
- package/dist/app/synthesize-run.js +2 -1
- package/dist/citations/__fixtures__/resolver.d.ts +3 -0
- package/dist/citations/__fixtures__/resolver.js +9 -0
- package/dist/citations/aggregate.d.ts +3 -0
- package/dist/citations/aggregate.js +44 -0
- package/dist/citations/clients/arxiv.d.ts +6 -0
- package/dist/citations/clients/arxiv.js +34 -0
- package/dist/citations/clients/common.d.ts +11 -0
- package/dist/citations/clients/common.js +43 -0
- package/dist/citations/clients/crossref.d.ts +8 -0
- package/dist/citations/clients/crossref.js +98 -0
- package/dist/citations/clients/doi-registry.d.ts +3 -0
- package/dist/citations/clients/doi-registry.js +95 -0
- package/dist/citations/clients/pubmed.d.ts +6 -0
- package/dist/citations/clients/pubmed.js +83 -0
- package/dist/citations/clients/semantic-scholar.d.ts +6 -0
- package/dist/citations/clients/semantic-scholar.js +60 -0
- package/dist/citations/compare.d.ts +9 -0
- package/dist/citations/compare.js +72 -0
- package/dist/citations/match.d.ts +11 -0
- package/dist/citations/match.js +173 -0
- package/dist/citations/normalize.d.ts +31 -0
- package/dist/citations/normalize.js +187 -0
- package/dist/citations/plan.d.ts +2 -0
- package/dist/citations/plan.js +59 -0
- package/dist/citations/resolve.d.ts +28 -0
- package/dist/citations/resolve.js +238 -0
- package/dist/citations/types.d.ts +583 -0
- package/dist/citations/types.js +129 -0
- package/dist/contracts/chat-events.d.ts +16 -0
- package/dist/contracts/message.d.ts +13 -8
- package/dist/contracts/schemas/chat-events.d.ts +7 -0
- package/dist/contracts/schemas/chat-events.js +2 -0
- package/dist/contracts/schemas/chat-parts.d.ts +8 -8
- package/dist/contracts/target-dossier.d.ts +20 -20
- package/dist/execution/run-synthesis.d.ts +3 -0
- package/dist/execution/run-synthesis.js +1 -0
- package/dist/index.d.ts +14 -10
- package/dist/index.js +20 -16
- package/dist/lib/cache.d.ts +8 -0
- package/dist/lib/cache.js +35 -0
- package/dist/lib/rate-limit.d.ts +35 -0
- package/dist/lib/rate-limit.js +121 -0
- package/dist/literature/sources/arxiv.d.ts +22 -0
- package/dist/literature/sources/arxiv.js +103 -0
- package/dist/literature/sources/http.d.ts +28 -0
- package/dist/literature/sources/http.js +97 -0
- package/dist/literature/sources/pubmed.d.ts +68 -0
- package/dist/literature/sources/pubmed.js +266 -0
- package/dist/literature/sources/semantic-scholar.d.ts +24 -0
- package/dist/literature/sources/semantic-scholar.js +71 -0
- package/dist/memory/content-to-cortex.d.ts +12 -11
- package/dist/memory/content-to-cortex.js +17 -14
- package/dist/memory/conversation-display-backfill.d.ts +25 -0
- package/dist/memory/conversation-display-backfill.js +97 -0
- package/dist/memory/conversation-display-recorder.d.ts +17 -0
- package/dist/memory/conversation-display-recorder.js +156 -0
- package/dist/memory/conversation-display-replay.d.ts +19 -0
- package/dist/memory/conversation-display-replay.js +39 -0
- package/dist/memory/conversation-display-storage.d.ts +86 -0
- package/dist/memory/conversation-display-storage.js +168 -0
- package/dist/memory/reconstruct-cards.d.ts +7 -1
- package/dist/memory/reconstruct-cards.js +5 -1
- package/dist/memory/thread-history.d.ts +46 -11
- package/dist/memory/thread-history.js +55 -13
- package/dist/memory/thread-store.d.ts +142 -25
- package/dist/memory/thread-store.js +228 -40
- package/dist/prompts/literature-reviewer.d.ts +1 -1
- package/dist/prompts/literature-reviewer.js +2 -0
- package/dist/runtime/assemble.d.ts +50 -4
- package/dist/runtime/assemble.js +47 -3
- package/dist/runtime/boot.d.ts +8 -2
- package/dist/runtime/boot.js +20 -2
- package/dist/state/init.js +50 -1
- package/dist/state/schema.d.ts +2 -2
- package/dist/tools/bio/gene-preclinical-profile.d.ts +1 -1
- package/dist/tools/bio/keys.d.ts +1 -0
- package/dist/tools/bio/pubmed.js +4 -4
- package/dist/tools/lib/ncbi-utils.d.ts +3 -58
- package/dist/tools/lib/ncbi-utils.js +2 -286
- package/dist/tools/lib/pubmed-client.d.ts +5 -12
- package/dist/tools/lib/pubmed-client.js +20 -79
- package/dist/tools/research/generate-analogy-report.js +2 -2
- package/dist/tools/research/index.d.ts +1 -0
- package/dist/tools/research/index.js +1 -0
- package/dist/tools/research/inspect-run.d.ts +1 -1
- package/dist/tools/research/literature-reviewer.d.ts +3 -0
- package/dist/tools/research/literature-reviewer.js +2 -0
- package/dist/tools/research/resolve-citation.d.ts +3 -0
- package/dist/tools/research/resolve-citation.js +15 -0
- package/dist/tools/research/search-arxiv.d.ts +3 -19
- package/dist/tools/research/search-arxiv.js +9 -105
- package/dist/tools/research/search-semantic-scholar.d.ts +8 -33
- package/dist/tools/research/search-semantic-scholar.js +29 -88
- package/dist/tools/sandbox/list-available-refs.js +1 -2
- package/dist/workflows/execute-analysis.d.ts +3 -0
- package/dist/workflows/execute-analysis.js +1 -0
- package/dist/workflows/sandbox-step.d.ts +5 -0
- package/dist/workflows/sandbox-step.js +1 -0
- package/dist/workflows/target-assessment/phase4-assemble.d.ts +2 -2
- package/dist/workflows/target-assessment/schemas.d.ts +2 -2
- package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +1 -1
- package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface RateLimitConfig {
|
|
2
|
+
readonly maxConcurrency: number;
|
|
3
|
+
readonly requestsPerSecond: number;
|
|
4
|
+
/**
|
|
5
|
+
* Retry policy for the source's HTTP layer. Retries re-enter the limiter as
|
|
6
|
+
* fresh attempts, so they are paced like any other request; the limiter
|
|
7
|
+
* itself does not read these two fields.
|
|
8
|
+
*/
|
|
9
|
+
readonly maxRetries: number;
|
|
10
|
+
readonly maxRetryDelayMs: number;
|
|
11
|
+
}
|
|
12
|
+
export interface RateLimitRuntime {
|
|
13
|
+
readonly now?: () => number;
|
|
14
|
+
readonly sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare function sleepWithSignal(milliseconds: number, signal?: AbortSignal): Promise<void>;
|
|
17
|
+
export declare class BoundedRateLimiter {
|
|
18
|
+
private readonly config;
|
|
19
|
+
private readonly semaphore;
|
|
20
|
+
private readonly now;
|
|
21
|
+
private readonly sleep;
|
|
22
|
+
private reservationTail;
|
|
23
|
+
private nextStartAt;
|
|
24
|
+
constructor(config: RateLimitConfig, runtime?: RateLimitRuntime);
|
|
25
|
+
private reserveStart;
|
|
26
|
+
run<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Admission gate for a source's HTTP layer (`SourceHttpOptions.schedule`).
|
|
30
|
+
*
|
|
31
|
+
* It gates the network call alone: the caller arms its request timeout inside
|
|
32
|
+
* the admitted operation, so a request held behind pacing or a concurrency
|
|
33
|
+
* ceiling cannot be reported as a timeout before it ever reaches the network.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createRateLimitSchedule(config: RateLimitConfig, runtime?: RateLimitRuntime): <T>(operation: () => Promise<T>, signal?: AbortSignal) => Promise<T>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
function abortReason(signal) {
|
|
2
|
+
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
3
|
+
}
|
|
4
|
+
export function sleepWithSignal(milliseconds, signal) {
|
|
5
|
+
if (milliseconds <= 0)
|
|
6
|
+
return Promise.resolve();
|
|
7
|
+
if (signal?.aborted)
|
|
8
|
+
return Promise.reject(abortReason(signal));
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const timeout = setTimeout(() => {
|
|
11
|
+
signal?.removeEventListener("abort", onAbort);
|
|
12
|
+
resolve();
|
|
13
|
+
}, milliseconds);
|
|
14
|
+
const onAbort = () => {
|
|
15
|
+
clearTimeout(timeout);
|
|
16
|
+
reject(abortReason(signal));
|
|
17
|
+
};
|
|
18
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
class Semaphore {
|
|
22
|
+
maximum;
|
|
23
|
+
active = 0;
|
|
24
|
+
waiters = [];
|
|
25
|
+
constructor(maximum) {
|
|
26
|
+
this.maximum = maximum;
|
|
27
|
+
}
|
|
28
|
+
async acquire(signal) {
|
|
29
|
+
if (signal?.aborted)
|
|
30
|
+
throw abortReason(signal);
|
|
31
|
+
if (this.active < this.maximum) {
|
|
32
|
+
this.active += 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
await new Promise((resolve, reject) => {
|
|
36
|
+
const waiter = { resolve, reject, ...(signal === undefined ? {} : { signal }) };
|
|
37
|
+
if (signal !== undefined) {
|
|
38
|
+
waiter.onAbort = () => {
|
|
39
|
+
const index = this.waiters.indexOf(waiter);
|
|
40
|
+
if (index >= 0)
|
|
41
|
+
this.waiters.splice(index, 1);
|
|
42
|
+
reject(abortReason(signal));
|
|
43
|
+
};
|
|
44
|
+
signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
45
|
+
}
|
|
46
|
+
this.waiters.push(waiter);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
release() {
|
|
50
|
+
const waiter = this.waiters.shift();
|
|
51
|
+
if (waiter === undefined) {
|
|
52
|
+
this.active -= 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (waiter.signal !== undefined && waiter.onAbort !== undefined)
|
|
56
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
57
|
+
waiter.resolve();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export class BoundedRateLimiter {
|
|
61
|
+
config;
|
|
62
|
+
semaphore;
|
|
63
|
+
now;
|
|
64
|
+
sleep;
|
|
65
|
+
reservationTail = Promise.resolve();
|
|
66
|
+
nextStartAt = 0;
|
|
67
|
+
constructor(config, runtime = {}) {
|
|
68
|
+
this.config = config;
|
|
69
|
+
if (!Number.isInteger(config.maxConcurrency) || config.maxConcurrency < 1)
|
|
70
|
+
throw new Error("maxConcurrency must be a positive integer");
|
|
71
|
+
if (!(config.requestsPerSecond > 0))
|
|
72
|
+
throw new Error("requestsPerSecond must be positive");
|
|
73
|
+
this.semaphore = new Semaphore(config.maxConcurrency);
|
|
74
|
+
this.now = runtime.now ?? Date.now;
|
|
75
|
+
this.sleep = runtime.sleep ?? sleepWithSignal;
|
|
76
|
+
}
|
|
77
|
+
async reserveStart(signal) {
|
|
78
|
+
let release;
|
|
79
|
+
const predecessor = this.reservationTail;
|
|
80
|
+
this.reservationTail = new Promise((resolve) => {
|
|
81
|
+
release = resolve;
|
|
82
|
+
});
|
|
83
|
+
if (signal?.aborted) {
|
|
84
|
+
release();
|
|
85
|
+
throw abortReason(signal);
|
|
86
|
+
}
|
|
87
|
+
await predecessor;
|
|
88
|
+
try {
|
|
89
|
+
if (signal?.aborted)
|
|
90
|
+
throw abortReason(signal);
|
|
91
|
+
const startAt = Math.max(this.now(), this.nextStartAt);
|
|
92
|
+
this.nextStartAt = startAt + 1000 / this.config.requestsPerSecond;
|
|
93
|
+
return startAt;
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
release();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async run(operation, signal) {
|
|
100
|
+
const startAt = await this.reserveStart(signal);
|
|
101
|
+
await this.sleep(Math.max(0, startAt - this.now()), signal);
|
|
102
|
+
await this.semaphore.acquire(signal);
|
|
103
|
+
try {
|
|
104
|
+
return await operation();
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
this.semaphore.release();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Admission gate for a source's HTTP layer (`SourceHttpOptions.schedule`).
|
|
113
|
+
*
|
|
114
|
+
* It gates the network call alone: the caller arms its request timeout inside
|
|
115
|
+
* the admitted operation, so a request held behind pacing or a concurrency
|
|
116
|
+
* ceiling cannot be reported as a timeout before it ever reaches the network.
|
|
117
|
+
*/
|
|
118
|
+
export function createRateLimitSchedule(config, runtime = {}) {
|
|
119
|
+
const limiter = new BoundedRateLimiter(config, runtime);
|
|
120
|
+
return (operation, signal) => limiter.run(operation, signal);
|
|
121
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type SourceHttpOptions, type SourceHttpResult } from "./http.js";
|
|
2
|
+
export interface ArxivPaper {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
abstract: string;
|
|
6
|
+
authors: string[];
|
|
7
|
+
published: string;
|
|
8
|
+
categories: string[];
|
|
9
|
+
url: string;
|
|
10
|
+
pdfUrl?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ArxivSearchInput {
|
|
13
|
+
readonly query: string;
|
|
14
|
+
readonly categories?: readonly string[];
|
|
15
|
+
readonly limit: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ArxivSource {
|
|
18
|
+
search(input: ArxivSearchInput, signal?: AbortSignal): Promise<SourceHttpResult<ArxivPaper[]>>;
|
|
19
|
+
lookupExact(id: string, limit: number, signal?: AbortSignal): Promise<SourceHttpResult<ArxivPaper[]>>;
|
|
20
|
+
}
|
|
21
|
+
export declare function parseArxivAtom(xml: string): ArxivPaper[];
|
|
22
|
+
export declare function createArxivSource(options?: SourceHttpOptions): ArxivSource;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { requestText } from "./http.js";
|
|
2
|
+
const BASE_URL = "https://export.arxiv.org/api/query";
|
|
3
|
+
const stripCdata = (value) => value.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1");
|
|
4
|
+
const decodeXmlEntities = (value) => value
|
|
5
|
+
.replace(/</g, "<")
|
|
6
|
+
.replace(/>/g, ">")
|
|
7
|
+
.replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, "'")
|
|
9
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hexadecimal) => String.fromCodePoint(parseInt(hexadecimal, 16)))
|
|
10
|
+
.replace(/&#(\d+);/g, (_, decimal) => String.fromCodePoint(parseInt(decimal, 10)))
|
|
11
|
+
.replace(/&/g, "&");
|
|
12
|
+
const collapseWhitespace = (value) => decodeXmlEntities(stripCdata(value)).replace(/\s+/g, " ").trim();
|
|
13
|
+
function extractEntries(xml) {
|
|
14
|
+
const expression = /<entry\b[^>]*>([\s\S]*?)<\/entry>/g;
|
|
15
|
+
const entries = [];
|
|
16
|
+
let match;
|
|
17
|
+
while ((match = expression.exec(xml)) !== null)
|
|
18
|
+
entries.push(match[1]);
|
|
19
|
+
return entries;
|
|
20
|
+
}
|
|
21
|
+
function firstMatch(entry, tag) {
|
|
22
|
+
return entry.match(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`))?.[1];
|
|
23
|
+
}
|
|
24
|
+
function allMatches(entry, tag) {
|
|
25
|
+
const expression = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`, "g");
|
|
26
|
+
const values = [];
|
|
27
|
+
let match;
|
|
28
|
+
while ((match = expression.exec(entry)) !== null)
|
|
29
|
+
values.push(match[1]);
|
|
30
|
+
return values;
|
|
31
|
+
}
|
|
32
|
+
function linkAttribute(entry, predicate) {
|
|
33
|
+
const expression = /<link\b([^>]*)\/>/g;
|
|
34
|
+
let match;
|
|
35
|
+
while ((match = expression.exec(entry)) !== null) {
|
|
36
|
+
const attributes = match[1];
|
|
37
|
+
if (predicate(attributes))
|
|
38
|
+
return attributes.match(/href="([^"]+)"/)?.[1];
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
function categoryTerms(entry) {
|
|
43
|
+
const expression = /<category\b([^>]*)\/>/g;
|
|
44
|
+
const terms = [];
|
|
45
|
+
let match;
|
|
46
|
+
while ((match = expression.exec(entry)) !== null) {
|
|
47
|
+
const term = match[1].match(/term="([^"]+)"/)?.[1];
|
|
48
|
+
if (term !== undefined)
|
|
49
|
+
terms.push(term);
|
|
50
|
+
}
|
|
51
|
+
return terms;
|
|
52
|
+
}
|
|
53
|
+
export function parseArxivAtom(xml) {
|
|
54
|
+
return extractEntries(xml).flatMap((entry) => {
|
|
55
|
+
const rawId = firstMatch(entry, "id");
|
|
56
|
+
const title = firstMatch(entry, "title");
|
|
57
|
+
if (rawId === undefined || title === undefined)
|
|
58
|
+
return [];
|
|
59
|
+
const idMatch = rawId.match(/abs\/(.+)$/);
|
|
60
|
+
const authors = allMatches(entry, "author")
|
|
61
|
+
.map((author) => firstMatch(author, "name"))
|
|
62
|
+
.filter((name) => name !== undefined)
|
|
63
|
+
.map(collapseWhitespace);
|
|
64
|
+
const htmlUrl = linkAttribute(entry, (attributes) => /rel="alternate"/.test(attributes) && /type="text\/html"/.test(attributes));
|
|
65
|
+
const pdfUrl = linkAttribute(entry, (attributes) => /title="pdf"/.test(attributes));
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
id: idMatch?.[1] ?? collapseWhitespace(rawId),
|
|
69
|
+
title: collapseWhitespace(title),
|
|
70
|
+
abstract: collapseWhitespace(firstMatch(entry, "summary") ?? ""),
|
|
71
|
+
authors,
|
|
72
|
+
published: collapseWhitespace(firstMatch(entry, "published") ?? ""),
|
|
73
|
+
categories: categoryTerms(entry),
|
|
74
|
+
url: htmlUrl ?? collapseWhitespace(rawId),
|
|
75
|
+
...(pdfUrl === undefined ? {} : { pdfUrl }),
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export function createArxivSource(options = {}) {
|
|
81
|
+
async function fetchPapers(params, signal) {
|
|
82
|
+
const result = await requestText(`${BASE_URL}?${params}`, options, signal);
|
|
83
|
+
if (result.status !== "ok")
|
|
84
|
+
return result;
|
|
85
|
+
if (!/<feed\b/.test(result.value))
|
|
86
|
+
return { status: "unavailable", detail: "response schema mismatch: missing Atom feed" };
|
|
87
|
+
return { status: "ok", value: parseArxivAtom(result.value) };
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
search: (input, signal) => {
|
|
91
|
+
const categoryQuery = input.categories !== undefined && input.categories.length > 0
|
|
92
|
+
? ` AND (${input.categories.map((category) => `cat:${category}`).join(" OR ")})`
|
|
93
|
+
: "";
|
|
94
|
+
return fetchPapers(new URLSearchParams({
|
|
95
|
+
search_query: `all:${input.query}${categoryQuery}`,
|
|
96
|
+
max_results: String(input.limit),
|
|
97
|
+
sortBy: "relevance",
|
|
98
|
+
sortOrder: "descending",
|
|
99
|
+
}), signal);
|
|
100
|
+
},
|
|
101
|
+
lookupExact: (id, limit, signal) => fetchPapers(new URLSearchParams({ id_list: id, max_results: String(limit) }), signal),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export interface SourceHttpOptions {
|
|
3
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
4
|
+
/**
|
|
5
|
+
* Admission gate wrapped around each network attempt — a rate limiter, a
|
|
6
|
+
* concurrency semaphore, or both. The request timeout is armed *inside* it,
|
|
7
|
+
* so time an attempt spends queueing is never charged against the timeout.
|
|
8
|
+
*/
|
|
9
|
+
readonly schedule?: <T>(operation: () => Promise<T>, signal?: AbortSignal) => Promise<T>;
|
|
10
|
+
readonly timeoutMs?: number;
|
|
11
|
+
readonly maxRetries?: number;
|
|
12
|
+
readonly retryDelayMs?: number;
|
|
13
|
+
/** Ceiling on any single retry wait, including one a server asks for via `Retry-After`. */
|
|
14
|
+
readonly maxRetryDelayMs?: number;
|
|
15
|
+
readonly sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export type SourceHttpResult<T> = {
|
|
18
|
+
readonly status: "ok";
|
|
19
|
+
readonly value: T;
|
|
20
|
+
} | {
|
|
21
|
+
readonly status: "no_data";
|
|
22
|
+
readonly detail: string;
|
|
23
|
+
} | {
|
|
24
|
+
readonly status: "unavailable";
|
|
25
|
+
readonly detail: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function requestJson<S extends z.ZodType>(url: string, schema: S, options: SourceHttpOptions, signal?: AbortSignal, init?: RequestInit): Promise<SourceHttpResult<z.infer<S>>>;
|
|
28
|
+
export declare function requestText(url: string, options: SourceHttpOptions, signal?: AbortSignal, init?: RequestInit): Promise<SourceHttpResult<string>>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const RETRYABLE_STATUSES = new Set([429, 503]);
|
|
2
|
+
function cancellationReason(signal) {
|
|
3
|
+
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
4
|
+
}
|
|
5
|
+
async function delay(ms, signal) {
|
|
6
|
+
if (signal?.aborted)
|
|
7
|
+
throw cancellationReason(signal);
|
|
8
|
+
await new Promise((resolve, reject) => {
|
|
9
|
+
const onAbort = () => {
|
|
10
|
+
clearTimeout(timeout);
|
|
11
|
+
reject(cancellationReason(signal));
|
|
12
|
+
};
|
|
13
|
+
const timeout = setTimeout(() => {
|
|
14
|
+
signal?.removeEventListener("abort", onAbort);
|
|
15
|
+
resolve();
|
|
16
|
+
}, ms);
|
|
17
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function retryAfterMilliseconds(response) {
|
|
21
|
+
const value = response.headers.get("retry-after")?.trim();
|
|
22
|
+
if (!value)
|
|
23
|
+
return undefined;
|
|
24
|
+
const seconds = Number(value);
|
|
25
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
26
|
+
return seconds * 1000;
|
|
27
|
+
const date = Date.parse(value);
|
|
28
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
29
|
+
}
|
|
30
|
+
async function request(url, options, signal, init) {
|
|
31
|
+
if (signal?.aborted)
|
|
32
|
+
throw cancellationReason(signal);
|
|
33
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
34
|
+
const maxRetries = options.maxRetries ?? 0;
|
|
35
|
+
const retryDelayMs = options.retryDelayMs ?? 1_000;
|
|
36
|
+
const maxRetryDelayMs = options.maxRetryDelayMs ?? Number.POSITIVE_INFINITY;
|
|
37
|
+
const schedule = options.schedule ?? ((operation) => operation());
|
|
38
|
+
const sleep = options.sleep ?? delay;
|
|
39
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
40
|
+
const backoff = (attempt) => Math.min(maxRetryDelayMs, retryDelayMs * 2 ** attempt);
|
|
41
|
+
for (let attempt = 0;; attempt += 1) {
|
|
42
|
+
let timeout;
|
|
43
|
+
try {
|
|
44
|
+
const response = await schedule(async () => {
|
|
45
|
+
timeout = AbortSignal.timeout(timeoutMs);
|
|
46
|
+
const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
47
|
+
return await fetcher(url, { ...init, signal: combined });
|
|
48
|
+
}, signal);
|
|
49
|
+
if (RETRYABLE_STATUSES.has(response.status) && attempt < maxRetries) {
|
|
50
|
+
await sleep(Math.min(maxRetryDelayMs, retryAfterMilliseconds(response) ?? backoff(attempt)), signal);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (response.status === 404 || response.status === 400 || response.status === 406) {
|
|
54
|
+
return { status: "no_data", detail: `HTTP ${response.status}` };
|
|
55
|
+
}
|
|
56
|
+
if (!response.ok)
|
|
57
|
+
return { status: "unavailable", detail: `HTTP ${response.status}` };
|
|
58
|
+
return { status: "ok", value: response };
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (signal?.aborted)
|
|
62
|
+
throw cancellationReason(signal);
|
|
63
|
+
if (timeout?.aborted)
|
|
64
|
+
return { status: "unavailable", detail: `request timed out after ${timeoutMs}ms` };
|
|
65
|
+
if (attempt < maxRetries) {
|
|
66
|
+
await sleep(backoff(attempt), signal);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
return { status: "unavailable", detail: error instanceof Error ? error.message : String(error) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export async function requestJson(url, schema, options, signal, init = {}) {
|
|
74
|
+
const response = await request(url, options, signal, init);
|
|
75
|
+
if (response.status !== "ok")
|
|
76
|
+
return response;
|
|
77
|
+
try {
|
|
78
|
+
const parsed = schema.safeParse(await response.value.json());
|
|
79
|
+
if (!parsed.success)
|
|
80
|
+
return { status: "unavailable", detail: `response schema mismatch: ${parsed.error.issues[0]?.message ?? "unknown"}` };
|
|
81
|
+
return { status: "ok", value: parsed.data };
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return { status: "unavailable", detail: `invalid JSON response: ${error instanceof Error ? error.message : String(error)}` };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export async function requestText(url, options, signal, init = {}) {
|
|
88
|
+
const response = await request(url, options, signal, init);
|
|
89
|
+
if (response.status !== "ok")
|
|
90
|
+
return response;
|
|
91
|
+
try {
|
|
92
|
+
return { status: "ok", value: await response.value.text() };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return { status: "unavailable", detail: `invalid text response: ${error instanceof Error ? error.message : String(error)}` };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type SourceHttpOptions, type SourceHttpResult } from "./http.js";
|
|
2
|
+
export declare const NCBI_BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils";
|
|
3
|
+
export declare const NCBI_IDCONV = "https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0";
|
|
4
|
+
export interface PubmedSourceOptions extends SourceHttpOptions {
|
|
5
|
+
readonly apiKey?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PubmedSearchOptions {
|
|
8
|
+
readonly maxResults?: number;
|
|
9
|
+
readonly sort?: "relevance" | "date";
|
|
10
|
+
readonly dateRange?: {
|
|
11
|
+
readonly from: string;
|
|
12
|
+
readonly to: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface PubmedCitationFields {
|
|
16
|
+
readonly venue?: string;
|
|
17
|
+
readonly year?: string | number;
|
|
18
|
+
readonly volume?: string;
|
|
19
|
+
readonly firstPage?: string;
|
|
20
|
+
readonly firstAuthor?: string;
|
|
21
|
+
readonly key: string;
|
|
22
|
+
}
|
|
23
|
+
export interface PubMedSummary {
|
|
24
|
+
pmid: string;
|
|
25
|
+
title: string;
|
|
26
|
+
journal: string;
|
|
27
|
+
year: string;
|
|
28
|
+
authors: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ArticleDetail {
|
|
31
|
+
pmid: string;
|
|
32
|
+
title: string;
|
|
33
|
+
abstract: string;
|
|
34
|
+
authors: string[];
|
|
35
|
+
journal: string;
|
|
36
|
+
year: string;
|
|
37
|
+
doi: string;
|
|
38
|
+
meshTerms: string[];
|
|
39
|
+
pmcId: string | null;
|
|
40
|
+
}
|
|
41
|
+
export interface ArticleSection {
|
|
42
|
+
heading: string;
|
|
43
|
+
text: string;
|
|
44
|
+
}
|
|
45
|
+
export interface FullTextResult {
|
|
46
|
+
fullText: string;
|
|
47
|
+
sections: ArticleSection[];
|
|
48
|
+
}
|
|
49
|
+
export interface PubmedSource {
|
|
50
|
+
search(query: string, options?: PubmedSearchOptions, signal?: AbortSignal): Promise<SourceHttpResult<{
|
|
51
|
+
totalFound: number;
|
|
52
|
+
results: PubMedSummary[];
|
|
53
|
+
}>>;
|
|
54
|
+
searchIds(query: string, maxResults: number, signal?: AbortSignal): Promise<SourceHttpResult<string[]>>;
|
|
55
|
+
matchCitation(fields: PubmedCitationFields, signal?: AbortSignal): Promise<SourceHttpResult<string[]>>;
|
|
56
|
+
fetchArticles(pmids: readonly string[], signal?: AbortSignal): Promise<SourceHttpResult<ArticleDetail[]>>;
|
|
57
|
+
articleDetails(pmids: readonly string[], signal?: AbortSignal): Promise<SourceHttpResult<{
|
|
58
|
+
articles: ArticleDetail[];
|
|
59
|
+
notFound: string[];
|
|
60
|
+
}>>;
|
|
61
|
+
fetchFullText(pmcId: string, signal?: AbortSignal): Promise<SourceHttpResult<FullTextResult | null>>;
|
|
62
|
+
}
|
|
63
|
+
export declare function ncbiUrl(apiKey: string | undefined, base: string, params: Record<string, string | number | undefined>): string;
|
|
64
|
+
export declare function parseEsummary(xml: string): PubMedSummary[];
|
|
65
|
+
export declare function parseEfetch(xml: string): ArticleDetail[];
|
|
66
|
+
export declare function parseIdConvResponse(data: unknown): Map<string, string>;
|
|
67
|
+
export declare function parsePmcFullText(xml: string): FullTextResult | null;
|
|
68
|
+
export declare function createPubmedSource(options?: PubmedSourceOptions): PubmedSource;
|