scholar-mcp 1.0.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.
@@ -0,0 +1,407 @@
1
+ import { Cite } from '@citation-js/core';
2
+ import '@citation-js/plugin-csl';
3
+ import '@citation-js/plugin-bibtex';
4
+ import '@citation-js/plugin-doi';
5
+ import { clamp, normalizeDoi, normalizeWhitespace, overlapScore, tokenizeForRanking } from './utils.js';
6
+ const styleTemplateMap = {
7
+ apa: 'apa',
8
+ ieee: 'ieee',
9
+ chicago: 'chicago-author-date',
10
+ vancouver: 'vancouver'
11
+ };
12
+ const extractQuery = (manuscriptText, cursorContext) => {
13
+ const context = (cursorContext && cursorContext.trim().length > 0 ? cursorContext : manuscriptText).slice(-2500);
14
+ const tokens = tokenizeForRanking(context).filter((token) => token.length > 3);
15
+ const counts = new Map();
16
+ for (const token of tokens) {
17
+ counts.set(token, (counts.get(token) ?? 0) + 1);
18
+ }
19
+ return [...counts.entries()]
20
+ .sort((a, b) => b[1] - a[1])
21
+ .slice(0, 12)
22
+ .map(([token]) => token)
23
+ .join(' ')
24
+ .trim();
25
+ };
26
+ const normalizeStyle = (style) => styleTemplateMap[style] ?? 'apa';
27
+ const toCsl = (work) => {
28
+ const authorList = work.authors.map((author) => {
29
+ const parts = author.name.trim().split(/\s+/);
30
+ const family = parts.length > 1 ? parts[parts.length - 1] : parts[0] ?? author.name;
31
+ const given = parts.length > 1 ? parts.slice(0, -1).join(' ') : '';
32
+ return {
33
+ family,
34
+ given
35
+ };
36
+ });
37
+ const id = work.doi ? `doi:${work.doi}` : work.paperId;
38
+ return {
39
+ id,
40
+ type: 'article-journal',
41
+ DOI: work.doi ?? undefined,
42
+ title: work.title,
43
+ author: authorList,
44
+ issued: work.year
45
+ ? {
46
+ 'date-parts': [[work.year]]
47
+ }
48
+ : undefined,
49
+ 'container-title': work.venue ?? undefined,
50
+ URL: work.url ?? undefined
51
+ };
52
+ };
53
+ const formatSingleReference = (work, style, locale) => {
54
+ const csl = toCsl(work);
55
+ const cite = new Cite([csl]);
56
+ let formatted = '';
57
+ try {
58
+ formatted = cite
59
+ .format('bibliography', {
60
+ format: 'text',
61
+ template: normalizeStyle(style),
62
+ lang: locale
63
+ })
64
+ .trim();
65
+ }
66
+ catch {
67
+ const authorLabel = work.authors[0]?.name ?? 'Unknown';
68
+ const yearLabel = work.year ?? 'n.d.';
69
+ formatted = `${authorLabel} (${yearLabel}). ${work.title}.`;
70
+ }
71
+ let bibtex = '';
72
+ try {
73
+ bibtex = cite.format('bibtex').trim();
74
+ }
75
+ catch {
76
+ const key = (work.authors[0]?.name ?? 'work').replace(/[^a-zA-Z0-9]/g, '') + (work.year ?? 'nd');
77
+ bibtex = `@article{${key},\n title={${work.title}},\n year={${work.year ?? ''}}\n}`;
78
+ }
79
+ return {
80
+ id: String(csl.id ?? work.paperId),
81
+ csl,
82
+ formatted,
83
+ bibtex,
84
+ sourceWork: work
85
+ };
86
+ };
87
+ const buildInlineSuggestion = (style, works) => {
88
+ const first = works.slice(0, 3);
89
+ if (first.length === 0) {
90
+ return '';
91
+ }
92
+ if (style === 'ieee' || style === 'vancouver') {
93
+ return first.map((_, index) => `[${index + 1}]`).join(', ');
94
+ }
95
+ return first
96
+ .map((work) => {
97
+ const family = work.authors[0]?.name.split(' ').at(-1) ?? 'Unknown';
98
+ const year = work.year ?? 'n.d.';
99
+ return `(${family}, ${year})`;
100
+ })
101
+ .join('; ');
102
+ };
103
+ const parseNumericInlineCitations = (manuscriptText) => {
104
+ const numbers = new Set();
105
+ const invalidChunks = [];
106
+ for (const match of manuscriptText.matchAll(/\[([^\]]+)\]/g)) {
107
+ const inner = match[1]?.trim() ?? '';
108
+ if (!inner) {
109
+ continue;
110
+ }
111
+ const chunks = inner.split(/[;,]/).map((chunk) => chunk.trim()).filter((chunk) => chunk.length > 0);
112
+ let parsedChunk = false;
113
+ for (const chunk of chunks) {
114
+ const single = chunk.match(/^(\d{1,4})$/);
115
+ if (single?.[1]) {
116
+ numbers.add(Number.parseInt(single[1], 10));
117
+ parsedChunk = true;
118
+ continue;
119
+ }
120
+ const range = chunk.match(/^(\d{1,4})\s*[-–]\s*(\d{1,4})$/);
121
+ if (range?.[1] && range[2]) {
122
+ const start = Number.parseInt(range[1], 10);
123
+ const end = Number.parseInt(range[2], 10);
124
+ if (start <= end && end - start <= 100) {
125
+ for (let value = start; value <= end; value += 1) {
126
+ numbers.add(value);
127
+ }
128
+ parsedChunk = true;
129
+ continue;
130
+ }
131
+ }
132
+ }
133
+ if (!parsedChunk) {
134
+ invalidChunks.push(`[${inner}]`);
135
+ }
136
+ }
137
+ return {
138
+ numbers: [...numbers].sort((a, b) => a - b),
139
+ invalidChunks
140
+ };
141
+ };
142
+ const parseAuthorYearCitations = (manuscriptText) => {
143
+ const citations = [];
144
+ for (const match of manuscriptText.matchAll(/\(([^()]*?(?:19|20)\d{2}[a-z]?[^()]*)\)/g)) {
145
+ const raw = match[0] ?? '';
146
+ const block = match[1] ?? '';
147
+ const parts = block.split(';').map((value) => normalizeWhitespace(value)).filter((value) => value.length > 0);
148
+ for (const part of parts) {
149
+ const authorMatch = part.match(/^([A-Z][A-Za-z'`\-]+)(?:\s+et al\.)?(?:\s*&\s+[A-Z][A-Za-z'`\-]+)?\s*,\s*(?:19|20)\d{2}[a-z]?/);
150
+ if (!authorMatch?.[1]) {
151
+ continue;
152
+ }
153
+ citations.push({
154
+ author: authorMatch[1],
155
+ raw
156
+ });
157
+ }
158
+ }
159
+ return citations;
160
+ };
161
+ const findReferenceYear = (reference) => {
162
+ if (reference.sourceWork.year) {
163
+ return reference.sourceWork.year;
164
+ }
165
+ const match = reference.formatted.match(/(?:19|20)\d{2}/);
166
+ return match?.[0] ? Number.parseInt(match[0], 10) : null;
167
+ };
168
+ const findReferenceTitle = (reference) => {
169
+ const title = normalizeWhitespace(reference.sourceWork.title ?? '');
170
+ if (title.length > 0) {
171
+ return title;
172
+ }
173
+ const parts = reference.formatted.split('.').map((part) => normalizeWhitespace(part));
174
+ const candidate = parts.find((part) => part.length > 10 && !/(?:19|20)\d{2}/.test(part));
175
+ return candidate ?? null;
176
+ };
177
+ const findReferenceAuthors = (reference) => {
178
+ if (reference.sourceWork.authors.length > 0) {
179
+ return reference.sourceWork.authors.map((author) => author.name).filter((name) => name.length > 0);
180
+ }
181
+ const authorPrefix = normalizeWhitespace(reference.formatted.split('(')[0] ?? '');
182
+ if (authorPrefix.length === 0) {
183
+ return [];
184
+ }
185
+ return authorPrefix
186
+ .split(/,|&| and /i)
187
+ .map((part) => normalizeWhitespace(part))
188
+ .filter((part) => part.length > 0);
189
+ };
190
+ const findReferenceSource = (reference) => {
191
+ const source = normalizeWhitespace(reference.sourceWork.venue ?? '');
192
+ if (source.length > 0) {
193
+ return source;
194
+ }
195
+ if (reference.sourceWork.url || reference.sourceWork.doi) {
196
+ return reference.sourceWork.url ?? reference.sourceWork.doi ?? null;
197
+ }
198
+ return null;
199
+ };
200
+ const buildCompletenessDiagnostic = (reference, index) => {
201
+ const missingElements = [];
202
+ const suggestions = [];
203
+ const authors = findReferenceAuthors(reference);
204
+ if (authors.length === 0) {
205
+ missingElements.push('author');
206
+ }
207
+ const year = findReferenceYear(reference);
208
+ if (!year) {
209
+ missingElements.push('year');
210
+ }
211
+ const title = findReferenceTitle(reference);
212
+ if (!title) {
213
+ missingElements.push('title');
214
+ }
215
+ const source = findReferenceSource(reference);
216
+ if (!source) {
217
+ missingElements.push('source');
218
+ }
219
+ const doi = normalizeDoi(reference.sourceWork.doi);
220
+ const hasUrl = Boolean(reference.sourceWork.url);
221
+ const hasPersistentIdentifier = Boolean(doi || hasUrl);
222
+ if (doi && !reference.formatted.toLowerCase().includes('doi.org/')) {
223
+ suggestions.push(`Reference ${index + 1}: include DOI as canonical URL (https://doi.org/${doi}).`);
224
+ }
225
+ if (!hasPersistentIdentifier) {
226
+ suggestions.push(`Reference ${index + 1}: add DOI or stable URL for traceability.`);
227
+ }
228
+ return {
229
+ referenceId: reference.id,
230
+ label: `[${index + 1}] ${reference.formatted}`,
231
+ missingElements,
232
+ missingPersistentIdentifier: !hasPersistentIdentifier,
233
+ suggestions
234
+ };
235
+ };
236
+ const findDuplicateReferences = (references) => {
237
+ const seen = new Map();
238
+ const duplicates = [];
239
+ references.forEach((reference, index) => {
240
+ const doi = normalizeDoi(reference.sourceWork.doi);
241
+ const title = normalizeWhitespace(reference.sourceWork.title ?? reference.formatted)
242
+ .toLowerCase()
243
+ .replace(/[^a-z0-9\s]/g, ' ')
244
+ .replace(/\s+/g, ' ')
245
+ .trim();
246
+ const year = reference.sourceWork.year ?? findReferenceYear(reference) ?? 'na';
247
+ const key = doi ? `doi:${doi}` : `title:${title}:year:${year}`;
248
+ const previous = seen.get(key);
249
+ if (previous !== undefined) {
250
+ duplicates.push(`Reference ${previous + 1} duplicates reference ${index + 1}.`);
251
+ return;
252
+ }
253
+ seen.set(key, index);
254
+ });
255
+ return duplicates;
256
+ };
257
+ export class CitationService {
258
+ literatureService;
259
+ constructor(literatureService) {
260
+ this.literatureService = literatureService;
261
+ }
262
+ async suggestContextualCitations(input) {
263
+ const query = extractQuery(input.manuscriptText, input.cursorContext);
264
+ const fallbackQuery = query.length > 0 ? query : input.manuscriptText.slice(0, 200);
265
+ const graphResult = await this.literatureService.searchGraph({
266
+ query: fallbackQuery,
267
+ limit: clamp(input.k * 3, input.k, 30),
268
+ sources: ['semantic_scholar', 'openalex', 'crossref']
269
+ });
270
+ const contextTokens = tokenizeForRanking(input.cursorContext ?? input.manuscriptText);
271
+ const currentYear = new Date().getFullYear();
272
+ const scored = graphResult.results
273
+ .map((work) => {
274
+ const text = `${work.title} ${work.abstract ?? ''} ${work.fieldsOfStudy.join(' ')}`;
275
+ const overlap = overlapScore(contextTokens, tokenizeForRanking(text));
276
+ const citationScore = Math.log10(work.citationCount + 1) / 4;
277
+ const recencyScore = work.year ? 1 / Math.max(1, currentYear - work.year + 1) : 0.2;
278
+ const finalScore = 0.55 * overlap +
279
+ 0.3 * Math.min(1, citationScore) +
280
+ 0.15 * clamp(recencyScore * Math.max(0, input.recencyBias), 0, 1);
281
+ return {
282
+ work,
283
+ relevanceScore: finalScore,
284
+ rationale: `overlap=${overlap.toFixed(2)}, citations=${work.citationCount}, year=${work.year ?? 'n/a'}`,
285
+ matchedContext: (work.abstract ?? work.title).slice(0, 280)
286
+ };
287
+ })
288
+ .sort((a, b) => b.relevanceScore - a.relevanceScore)
289
+ .slice(0, input.k);
290
+ return {
291
+ queryUsed: fallbackQuery,
292
+ suggestions: scored,
293
+ inlineSuggestion: buildInlineSuggestion(input.style, scored.map((candidate) => candidate.work))
294
+ };
295
+ }
296
+ async buildReferenceList(input) {
297
+ const locale = input.locale ?? 'en-US';
298
+ let works = input.works ?? [];
299
+ if (works.length === 0 && input.manuscriptText) {
300
+ const suggestions = await this.suggestContextualCitations({
301
+ manuscriptText: input.manuscriptText,
302
+ style: input.style,
303
+ k: 15,
304
+ recencyBias: 0.6
305
+ });
306
+ works = suggestions.suggestions.map((candidate) => candidate.work);
307
+ }
308
+ const deduped = new Map();
309
+ for (const work of works) {
310
+ const key = work.doi ?? work.paperId;
311
+ if (!deduped.has(key)) {
312
+ deduped.set(key, work);
313
+ }
314
+ }
315
+ const entries = [...deduped.values()].map((work) => formatSingleReference(work, input.style, locale));
316
+ const cite = new Cite(entries.map((entry) => entry.csl));
317
+ let bibliographyText = '';
318
+ try {
319
+ bibliographyText = cite
320
+ .format('bibliography', {
321
+ format: 'text',
322
+ template: normalizeStyle(input.style),
323
+ lang: locale
324
+ })
325
+ .trim();
326
+ }
327
+ catch {
328
+ bibliographyText = entries.map((entry) => entry.formatted).join('\n');
329
+ }
330
+ let bibtex = '';
331
+ try {
332
+ bibtex = cite.format('bibtex').trim();
333
+ }
334
+ catch {
335
+ bibtex = entries.map((entry) => entry.bibtex).join('\n\n');
336
+ }
337
+ return {
338
+ style: input.style,
339
+ locale,
340
+ references: entries,
341
+ bibliographyText,
342
+ bibtex
343
+ };
344
+ }
345
+ validateManuscriptCitations(manuscriptText, references, options) {
346
+ const numericCitationParse = parseNumericInlineCitations(manuscriptText);
347
+ const numericCitations = numericCitationParse.numbers;
348
+ const authorYearCitations = parseAuthorYearCitations(manuscriptText);
349
+ const placeholders = [...manuscriptText.matchAll(/\[(?:\?|TODO|CITATION)\]/gi)].map((match) => match[0] ?? '');
350
+ const missingReferences = new Set();
351
+ for (const value of numericCitations) {
352
+ if (value < 1 || value > references.length) {
353
+ missingReferences.add(`[${value}]`);
354
+ }
355
+ }
356
+ for (const citation of authorYearCitations) {
357
+ const matched = references.some((entry) => entry.formatted.toLowerCase().includes(citation.author.toLowerCase()));
358
+ if (!matched) {
359
+ missingReferences.add(citation.raw);
360
+ }
361
+ }
362
+ const uncitedReferences = references
363
+ .map((entry, index) => ({
364
+ label: `[${index + 1}] ${entry.formatted}`,
365
+ referenced: numericCitations.includes(index + 1) ||
366
+ authorYearCitations.some((citation) => entry.formatted.toLowerCase().includes(citation.author.toLowerCase()))
367
+ }))
368
+ .filter((entry) => !entry.referenced)
369
+ .map((entry) => entry.label);
370
+ const completenessDiagnostics = references.map((reference, index) => buildCompletenessDiagnostic(reference, index));
371
+ const normalizationSuggestions = [...new Set(completenessDiagnostics.flatMap((item) => item.suggestions))];
372
+ const styleWarnings = [...placeholders, ...numericCitationParse.invalidChunks];
373
+ if (references.length === 0) {
374
+ styleWarnings.push('Reference list is empty.');
375
+ }
376
+ if (numericCitations.length > 0 && authorYearCitations.length > 0) {
377
+ styleWarnings.push('Mixed numeric and author-year inline citation patterns detected.');
378
+ }
379
+ const expectedStyle = options?.style;
380
+ if (expectedStyle === 'ieee' || expectedStyle === 'vancouver') {
381
+ if (authorYearCitations.length > 0) {
382
+ styleWarnings.push(`Expected numeric citations for ${expectedStyle.toUpperCase()} style.`);
383
+ }
384
+ }
385
+ if (expectedStyle === 'apa' || expectedStyle === 'chicago') {
386
+ if (numericCitations.length > 0) {
387
+ styleWarnings.push(`Expected author-year citations for ${expectedStyle.toUpperCase()} style.`);
388
+ }
389
+ }
390
+ if (expectedStyle === 'apa') {
391
+ const referencesMissingDoiUrl = completenessDiagnostics.filter((item) => item.missingPersistentIdentifier).length;
392
+ if (referencesMissingDoiUrl > 0) {
393
+ styleWarnings.push(`${referencesMissingDoiUrl} reference(s) are missing a DOI/URL, which weakens APA traceability requirements.`);
394
+ }
395
+ }
396
+ return {
397
+ inlineCitationCount: numericCitations.length + authorYearCitations.length,
398
+ referenceCount: references.length,
399
+ missingReferences: [...missingReferences],
400
+ uncitedReferences,
401
+ styleWarnings,
402
+ duplicateReferences: findDuplicateReferences(references),
403
+ completenessDiagnostics,
404
+ normalizationSuggestions
405
+ };
406
+ }
407
+ }
@@ -0,0 +1,36 @@
1
+ export class ResearchError extends Error {
2
+ details;
3
+ constructor(message, details) {
4
+ super(message);
5
+ this.details = details;
6
+ this.name = 'ResearchError';
7
+ }
8
+ }
9
+ export class ResearchProviderError extends ResearchError {
10
+ provider;
11
+ status;
12
+ constructor(message, provider, status, details) {
13
+ super(message, details);
14
+ this.provider = provider;
15
+ this.status = status;
16
+ this.name = 'ResearchProviderError';
17
+ }
18
+ }
19
+ export class IngestionError extends ResearchError {
20
+ constructor(message, details) {
21
+ super(message, details);
22
+ this.name = 'IngestionError';
23
+ }
24
+ }
25
+ export class DocumentNotFoundError extends ResearchError {
26
+ constructor(documentId) {
27
+ super(`Document not found: ${documentId}`, { documentId });
28
+ this.name = 'DocumentNotFoundError';
29
+ }
30
+ }
31
+ export class JobNotFoundError extends ResearchError {
32
+ constructor(jobId) {
33
+ super(`Ingestion job not found: ${jobId}`, { jobId });
34
+ this.name = 'JobNotFoundError';
35
+ }
36
+ }
@@ -0,0 +1,109 @@
1
+ import { normalizeWhitespace } from './utils.js';
2
+ const splitSentences = (text) => text
3
+ .split(/(?<=[.!?])\s+/)
4
+ .map((sentence) => normalizeWhitespace(sentence))
5
+ .filter((sentence) => sentence.length > 20);
6
+ const collectByPatterns = (sections, patterns, confidence) => {
7
+ const output = [];
8
+ for (const section of sections) {
9
+ const sentences = splitSentences(section.text);
10
+ for (const sentence of sentences) {
11
+ if (patterns.some((pattern) => pattern.test(sentence))) {
12
+ output.push({
13
+ text: sentence,
14
+ confidence,
15
+ sectionId: section.id
16
+ });
17
+ }
18
+ }
19
+ }
20
+ return output.slice(0, 25);
21
+ };
22
+ const uniqueList = (items) => {
23
+ const set = new Set();
24
+ for (const item of items) {
25
+ const normalized = normalizeWhitespace(item);
26
+ if (normalized.length > 0) {
27
+ set.add(normalized);
28
+ }
29
+ }
30
+ return [...set];
31
+ };
32
+ const extractDatasets = (sections) => {
33
+ const matches = [];
34
+ const datasetPattern = /([A-Z][A-Za-z0-9\-]+\s+(?:dataset|corpus|benchmark))/g;
35
+ for (const section of sections) {
36
+ for (const match of section.text.matchAll(datasetPattern)) {
37
+ if (match[1]) {
38
+ matches.push(match[1]);
39
+ }
40
+ }
41
+ }
42
+ return uniqueList(matches).slice(0, 30);
43
+ };
44
+ const extractMetrics = (sections) => {
45
+ const metricPatterns = [
46
+ /\bF1(?:-score)?\b/gi,
47
+ /\baccuracy\b/gi,
48
+ /\bprecision\b/gi,
49
+ /\brecall\b/gi,
50
+ /\bAUC\b/gi,
51
+ /\bRMSE\b/gi,
52
+ /\bMAE\b/gi,
53
+ /\bBLEU\b/gi,
54
+ /\bROUGE\b/gi,
55
+ /\bmAP\b/gi
56
+ ];
57
+ const found = [];
58
+ for (const section of sections) {
59
+ for (const pattern of metricPatterns) {
60
+ for (const match of section.text.matchAll(pattern)) {
61
+ if (match[0]) {
62
+ found.push(match[0].toUpperCase());
63
+ }
64
+ }
65
+ }
66
+ }
67
+ return uniqueList(found);
68
+ };
69
+ export class ExtractionService {
70
+ extract(document, input) {
71
+ const selectedSections = this.selectSections(document.sections, input.sections);
72
+ const claims = collectByPatterns(selectedSections, [
73
+ /\bwe (?:propose|present|show|demonstrate)\b/i,
74
+ /\bthis paper\b/i,
75
+ /\bour (?:results|findings)\b/i,
76
+ /\bwe find that\b/i
77
+ ], Math.max(0.45, document.parser.confidence - 0.2));
78
+ const methods = collectByPatterns(selectedSections, [
79
+ /\bmethod(?:ology)?\b/i,
80
+ /\bapproach\b/i,
81
+ /\bmodel\b/i,
82
+ /\balgorithm\b/i,
83
+ /\bexperimental setup\b/i
84
+ ], Math.max(0.5, document.parser.confidence - 0.15));
85
+ const limitations = collectByPatterns(selectedSections, [/\blimitation\b/i, /\bhowever\b/i, /\bfuture work\b/i, /\bchallenge\b/i, /\bconstraint\b/i], Math.max(0.4, document.parser.confidence - 0.25));
86
+ return {
87
+ documentId: document.documentId,
88
+ title: document.title,
89
+ abstract: document.abstract,
90
+ requestedSections: selectedSections,
91
+ claims,
92
+ methods,
93
+ limitations,
94
+ datasets: extractDatasets(selectedSections),
95
+ metrics: extractMetrics(selectedSections),
96
+ references: input.includeReferences === false ? [] : document.references,
97
+ parserConfidence: document.parser.confidence,
98
+ provenance: document.provenance
99
+ };
100
+ }
101
+ selectSections(sections, requested) {
102
+ if (!requested || requested.length === 0) {
103
+ return sections;
104
+ }
105
+ const normalizedRequested = requested.map((name) => name.trim().toLowerCase());
106
+ const selected = sections.filter((section) => normalizedRequested.some((target) => section.heading.toLowerCase().includes(target)));
107
+ return selected.length > 0 ? selected : sections;
108
+ }
109
+ }
@@ -0,0 +1,62 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { ResearchProviderError } from './errors.js';
3
+ export class ResearchHttpClient {
4
+ config;
5
+ lastRequestAt = 0;
6
+ constructor(config) {
7
+ this.config = config;
8
+ }
9
+ async fetchJson({ provider, url, headers }) {
10
+ let attempt = 0;
11
+ let lastError;
12
+ while (attempt <= this.config.researchRetryAttempts) {
13
+ await this.waitBeforeRequest();
14
+ const controller = new AbortController();
15
+ const timeoutId = setTimeout(() => controller.abort(), this.config.researchTimeoutMs);
16
+ try {
17
+ const response = await fetch(url, {
18
+ method: 'GET',
19
+ headers,
20
+ signal: controller.signal
21
+ });
22
+ if (!response.ok) {
23
+ const body = await response.text();
24
+ throw new ResearchProviderError(`Provider ${provider} returned HTTP ${response.status}`, provider, response.status, { url: url.toString(), body: body.slice(0, 1000) });
25
+ }
26
+ const json = (await response.json());
27
+ return json;
28
+ }
29
+ catch (error) {
30
+ lastError = error;
31
+ const isLastAttempt = attempt >= this.config.researchRetryAttempts;
32
+ if (isLastAttempt) {
33
+ break;
34
+ }
35
+ await sleep(this.config.researchRetryDelayMs);
36
+ }
37
+ finally {
38
+ clearTimeout(timeoutId);
39
+ }
40
+ attempt += 1;
41
+ }
42
+ if (lastError instanceof Error) {
43
+ throw lastError;
44
+ }
45
+ throw new ResearchProviderError(`Unknown provider error for ${provider}`, provider, undefined, {
46
+ url: url.toString()
47
+ });
48
+ }
49
+ async waitBeforeRequest() {
50
+ if (this.config.researchRequestDelayMs <= 0) {
51
+ this.lastRequestAt = Date.now();
52
+ return;
53
+ }
54
+ const now = Date.now();
55
+ const elapsed = now - this.lastRequestAt;
56
+ const waitMs = this.config.researchRequestDelayMs - elapsed;
57
+ if (waitMs > 0) {
58
+ await sleep(waitMs);
59
+ }
60
+ this.lastRequestAt = Date.now();
61
+ }
62
+ }
@@ -0,0 +1,7 @@
1
+ export * from './types.js';
2
+ export * from './errors.js';
3
+ export * from './research-service.js';
4
+ export * from './literature-service.js';
5
+ export * from './ingestion-service.js';
6
+ export * from './extraction-service.js';
7
+ export * from './citation-service.js';