@juliangruber/harness 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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/agent.js +73 -0
- package/dist/bashreview.js +58 -0
- package/dist/cli.js +143 -0
- package/dist/debug.js +83 -0
- package/dist/docs.js +24 -0
- package/dist/html.js +12 -0
- package/dist/http.js +44 -0
- package/dist/instructions.js +31 -0
- package/dist/llm.js +52 -0
- package/dist/markdown.js +67 -0
- package/dist/mcp-servers.js +34 -0
- package/dist/mcp.js +197 -0
- package/dist/ratelimit.js +98 -0
- package/dist/research.js +33 -0
- package/dist/sources.js +359 -0
- package/dist/subagent.js +66 -0
- package/dist/tools.js +251 -0
- package/package.json +36 -0
package/dist/sources.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// Research sources whose income doesn't depend on human visitors. Each uses
|
|
2
|
+
// the source's official keyless API.
|
|
3
|
+
import { cleanText } from './html.js';
|
|
4
|
+
import { CONTACT, getJson, request } from './http.js';
|
|
5
|
+
const MAX_ARTICLE = 15_000;
|
|
6
|
+
const MAX_PROPERTIES = 40;
|
|
7
|
+
const MAX_VALUES = 5;
|
|
8
|
+
const shorten = (text, max) => text.length > max ? `${text.slice(0, max)}...` : text;
|
|
9
|
+
const clampLimit = (limit) => Math.min(Math.max(Math.round(limit) || 5, 1), 20);
|
|
10
|
+
// The language ends up in a hostname, so it must not be arbitrary
|
|
11
|
+
function checkLanguage(language) {
|
|
12
|
+
if (!/^[a-z]{2,3}(-[a-z]+)?$/.test(language))
|
|
13
|
+
throw new Error(`Invalid language code ${language}`);
|
|
14
|
+
}
|
|
15
|
+
const languageParam = {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'Language code, like "en" or "de". Defaults to "en"'
|
|
18
|
+
};
|
|
19
|
+
// Wikipedia
|
|
20
|
+
const wikipediaApi = (language, params) => getJson(`https://${language}.wikipedia.org/w/api.php?${new URLSearchParams({ format: 'json', formatversion: '2', ...params })}`);
|
|
21
|
+
const articleUrl = (language, title) => `https://${language}.wikipedia.org/wiki/${encodeURIComponent(title.replaceAll(' ', '_'))}`;
|
|
22
|
+
export const wikipediaSearch = {
|
|
23
|
+
name: 'wikipedia_search',
|
|
24
|
+
description: 'Search Wikipedia articles. Returns titles, URLs and snippets.',
|
|
25
|
+
parameters: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
query: { type: 'string' },
|
|
29
|
+
language: languageParam,
|
|
30
|
+
limit: { type: 'number', description: 'Maximum results, defaults to 5' }
|
|
31
|
+
},
|
|
32
|
+
required: ['query']
|
|
33
|
+
},
|
|
34
|
+
async run({ query, language = 'en', limit = 5 }) {
|
|
35
|
+
checkLanguage(language);
|
|
36
|
+
const body = await wikipediaApi(language, { action: 'query', list: 'search', srsearch: query, srlimit: String(clampLimit(limit)) });
|
|
37
|
+
const results = body.query?.search ?? [];
|
|
38
|
+
if (!results.length)
|
|
39
|
+
return 'No articles found';
|
|
40
|
+
return results
|
|
41
|
+
.map((result, i) => `${i + 1}. ${result.title}\n ${articleUrl(language, result.title)}\n ${cleanText(result.snippet)}`)
|
|
42
|
+
.join('\n');
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
export const wikipediaArticle = {
|
|
46
|
+
name: 'wikipedia_article',
|
|
47
|
+
description: 'Read the plain text of a Wikipedia article by its exact title.',
|
|
48
|
+
parameters: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: { title: { type: 'string' }, language: languageParam },
|
|
51
|
+
required: ['title']
|
|
52
|
+
},
|
|
53
|
+
async run({ title, language = 'en' }) {
|
|
54
|
+
checkLanguage(language);
|
|
55
|
+
const body = await wikipediaApi(language, { action: 'query', prop: 'extracts', explaintext: '1', redirects: '1', titles: title });
|
|
56
|
+
const page = body.query?.pages?.[0];
|
|
57
|
+
if (!page || page.missing)
|
|
58
|
+
throw new Error(`No Wikipedia article titled ${title}`);
|
|
59
|
+
const text = page.extract.length > MAX_ARTICLE
|
|
60
|
+
? `${page.extract.slice(0, MAX_ARTICLE)}\n[truncated ${page.extract.length - MAX_ARTICLE} chars]`
|
|
61
|
+
: page.extract;
|
|
62
|
+
return `${page.title}\n${articleUrl(language, page.title)}\n\n${text}`;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
// Wikidata
|
|
66
|
+
const wikidataApi = (params) => getJson(`https://www.wikidata.org/w/api.php?${new URLSearchParams({ format: 'json', formatversion: '2', ...params })}`);
|
|
67
|
+
const wikidataUrl = (id) => `https://www.wikidata.org/wiki/${id}`;
|
|
68
|
+
export const wikidataSearch = {
|
|
69
|
+
name: 'wikidata_search',
|
|
70
|
+
description: 'Search Wikidata entities (people, places, organizations, concepts). Returns ids like Q42 for wikidata_entity.',
|
|
71
|
+
parameters: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
query: { type: 'string' },
|
|
75
|
+
language: languageParam,
|
|
76
|
+
limit: { type: 'number', description: 'Maximum results, defaults to 5' }
|
|
77
|
+
},
|
|
78
|
+
required: ['query']
|
|
79
|
+
},
|
|
80
|
+
async run({ query, language = 'en', limit = 5 }) {
|
|
81
|
+
checkLanguage(language);
|
|
82
|
+
const body = await wikidataApi({ action: 'wbsearchentities', search: query, language, uselang: language, type: 'item', limit: String(clampLimit(limit)) });
|
|
83
|
+
const results = body.search ?? [];
|
|
84
|
+
if (!results.length)
|
|
85
|
+
return 'No entities found';
|
|
86
|
+
return results
|
|
87
|
+
.map((result, i) => `${i + 1}. ${result.id} ${result.label ?? ''}${result.description ? `: ${result.description}` : ''}\n ${wikidataUrl(result.id)}`)
|
|
88
|
+
.join('\n');
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
async function wikidataLabels(ids, language) {
|
|
92
|
+
const labels = new Map();
|
|
93
|
+
// The API accepts at most 50 ids per request
|
|
94
|
+
for (let i = 0; i < ids.length; i += 50) {
|
|
95
|
+
const body = await wikidataApi({ action: 'wbgetentities', ids: ids.slice(i, i + 50).join('|'), props: 'labels', languages: language, languagefallback: '1' });
|
|
96
|
+
for (const [id, entity] of Object.entries(body.entities ?? {})) {
|
|
97
|
+
const label = entity.labels?.[language]?.value;
|
|
98
|
+
if (label)
|
|
99
|
+
labels.set(id, label);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return labels;
|
|
103
|
+
}
|
|
104
|
+
function formatSnak(snak, labels) {
|
|
105
|
+
if (snak.snaktype === 'somevalue')
|
|
106
|
+
return 'unknown value';
|
|
107
|
+
if (snak.snaktype === 'novalue')
|
|
108
|
+
return 'no value';
|
|
109
|
+
const { type, value } = snak.datavalue ?? {};
|
|
110
|
+
switch (type) {
|
|
111
|
+
case 'wikibase-entityid': return labels.get(value.id) ?? value.id;
|
|
112
|
+
case 'string': return value;
|
|
113
|
+
case 'monolingualtext': return value.text;
|
|
114
|
+
case 'time': return value.time.replace(/^\+/, '').replace(/T.*$/, '').replace(/-00/g, '');
|
|
115
|
+
case 'quantity': return value.amount.replace(/^\+/, '');
|
|
116
|
+
case 'globecoordinate': return `${value.latitude}, ${value.longitude}`;
|
|
117
|
+
default: return JSON.stringify(value);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export const wikidataEntity = {
|
|
121
|
+
name: 'wikidata_entity',
|
|
122
|
+
description: 'Get the facts Wikidata has about an entity, by id like Q42.',
|
|
123
|
+
parameters: {
|
|
124
|
+
type: 'object',
|
|
125
|
+
properties: { id: { type: 'string' }, language: languageParam },
|
|
126
|
+
required: ['id']
|
|
127
|
+
},
|
|
128
|
+
async run({ id, language = 'en' }) {
|
|
129
|
+
checkLanguage(language);
|
|
130
|
+
if (!/^[QPL]\d+$/.test(id))
|
|
131
|
+
throw new Error(`Invalid Wikidata id ${id}, expected something like Q42`);
|
|
132
|
+
const body = await wikidataApi({ action: 'wbgetentities', ids: id, props: 'labels|descriptions|aliases|claims', languages: language, languagefallback: '1' });
|
|
133
|
+
const entity = body.entities?.[id];
|
|
134
|
+
if (!entity || 'missing' in entity)
|
|
135
|
+
throw new Error(`No Wikidata entity ${id}`);
|
|
136
|
+
const properties = Object.entries(entity.claims ?? {});
|
|
137
|
+
const shown = properties.slice(0, MAX_PROPERTIES).map(([property, claims]) => [property, claims.filter(claim => claim.rank !== 'deprecated').slice(0, MAX_VALUES).map(claim => claim.mainsnak)]);
|
|
138
|
+
const ids = new Set();
|
|
139
|
+
for (const [property, snaks] of shown) {
|
|
140
|
+
ids.add(property);
|
|
141
|
+
for (const snak of snaks) {
|
|
142
|
+
if (snak.datavalue?.type === 'wikibase-entityid')
|
|
143
|
+
ids.add(snak.datavalue.value.id);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const labels = await wikidataLabels([...ids], language);
|
|
147
|
+
const lines = [`${entity.labels?.[language]?.value ?? id} (${id})`];
|
|
148
|
+
const description = entity.descriptions?.[language]?.value;
|
|
149
|
+
if (description)
|
|
150
|
+
lines.push(description);
|
|
151
|
+
const aliases = entity.aliases?.[language] ?? [];
|
|
152
|
+
if (aliases.length)
|
|
153
|
+
lines.push(`Also known as: ${aliases.map(alias => alias.value).join(', ')}`);
|
|
154
|
+
lines.push(wikidataUrl(id), '');
|
|
155
|
+
for (const [property, snaks] of shown) {
|
|
156
|
+
if (snaks.length)
|
|
157
|
+
lines.push(`${labels.get(property) ?? property}: ${snaks.map(snak => formatSnak(snak, labels)).join(', ')}`);
|
|
158
|
+
}
|
|
159
|
+
if (properties.length > MAX_PROPERTIES)
|
|
160
|
+
lines.push(`[${properties.length - MAX_PROPERTIES} more properties]`);
|
|
161
|
+
return lines.join('\n');
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const withMailto = (params) => {
|
|
165
|
+
if (CONTACT)
|
|
166
|
+
params.set('mailto', CONTACT);
|
|
167
|
+
return params;
|
|
168
|
+
};
|
|
169
|
+
const openAlex = async (query, limit) => {
|
|
170
|
+
const params = withMailto(new URLSearchParams({
|
|
171
|
+
search: query,
|
|
172
|
+
per_page: String(limit),
|
|
173
|
+
select: 'id,doi,title,publication_year,authorships,primary_location,abstract_inverted_index'
|
|
174
|
+
}));
|
|
175
|
+
const body = await getJson(`https://api.openalex.org/works?${params}`);
|
|
176
|
+
return (body.results ?? []).map((work) => ({
|
|
177
|
+
title: work.title ?? 'Untitled',
|
|
178
|
+
authors: (work.authorships ?? []).map((a) => a.author?.display_name).filter(Boolean),
|
|
179
|
+
year: work.publication_year ?? undefined,
|
|
180
|
+
venue: work.primary_location?.source?.display_name ?? undefined,
|
|
181
|
+
doi: work.doi?.replace(/^https?:\/\/(dx\.)?doi\.org\//i, '') ?? undefined,
|
|
182
|
+
url: work.primary_location?.landing_page_url ?? work.id,
|
|
183
|
+
abstract: work.abstract_inverted_index ? uninvert(work.abstract_inverted_index) : undefined,
|
|
184
|
+
sources: ['openalex']
|
|
185
|
+
}));
|
|
186
|
+
};
|
|
187
|
+
// OpenAlex stores abstracts as { word: [positions] }
|
|
188
|
+
function uninvert(index) {
|
|
189
|
+
const words = [];
|
|
190
|
+
for (const [word, positions] of Object.entries(index)) {
|
|
191
|
+
for (const position of positions)
|
|
192
|
+
words[position] = word;
|
|
193
|
+
}
|
|
194
|
+
return words.filter(Boolean).join(' ');
|
|
195
|
+
}
|
|
196
|
+
const semanticScholar = async (query, limit) => {
|
|
197
|
+
const params = new URLSearchParams({ query, limit: String(limit), fields: 'title,year,authors,venue,abstract,externalIds,url' });
|
|
198
|
+
const body = await getJson(`https://api.semanticscholar.org/graph/v1/paper/search?${params}`);
|
|
199
|
+
return (body.data ?? []).map((paper) => ({
|
|
200
|
+
title: paper.title ?? 'Untitled',
|
|
201
|
+
authors: (paper.authors ?? []).map((a) => a.name),
|
|
202
|
+
year: paper.year ?? undefined,
|
|
203
|
+
venue: paper.venue || undefined,
|
|
204
|
+
doi: paper.externalIds?.DOI,
|
|
205
|
+
url: paper.url,
|
|
206
|
+
abstract: paper.abstract ?? undefined,
|
|
207
|
+
sources: ['semantic_scholar']
|
|
208
|
+
}));
|
|
209
|
+
};
|
|
210
|
+
const crossref = async (query, limit) => {
|
|
211
|
+
const params = withMailto(new URLSearchParams({ query, rows: String(limit), select: 'DOI,title,author,issued,container-title,abstract,URL' }));
|
|
212
|
+
const body = await getJson(`https://api.crossref.org/works?${params}`);
|
|
213
|
+
return (body.message?.items ?? []).map((item) => ({
|
|
214
|
+
title: cleanText(item.title?.[0] ?? 'Untitled'),
|
|
215
|
+
authors: (item.author ?? []).map((a) => [a.given, a.family].filter(Boolean).join(' ') || a.name).filter(Boolean),
|
|
216
|
+
year: item.issued?.['date-parts']?.[0]?.[0] ?? undefined,
|
|
217
|
+
venue: item['container-title']?.[0],
|
|
218
|
+
doi: item.DOI,
|
|
219
|
+
url: item.URL,
|
|
220
|
+
// Abstracts are JATS XML, usually starting with an "Abstract" heading
|
|
221
|
+
abstract: item.abstract ? cleanText(item.abstract).replace(/^abstract\s+/i, '') : undefined,
|
|
222
|
+
sources: ['crossref']
|
|
223
|
+
}));
|
|
224
|
+
};
|
|
225
|
+
const europePmc = async (query, limit) => {
|
|
226
|
+
const params = new URLSearchParams({ query, format: 'json', pageSize: String(limit), resultType: 'core' });
|
|
227
|
+
const body = await getJson(`https://www.ebi.ac.uk/europepmc/webservices/rest/search?${params}`);
|
|
228
|
+
return (body.resultList?.result ?? []).map((result) => ({
|
|
229
|
+
title: cleanText(result.title ?? 'Untitled'),
|
|
230
|
+
authors: result.authorString ? result.authorString.replace(/\.$/, '').split(', ') : [],
|
|
231
|
+
year: Number(result.pubYear) || undefined,
|
|
232
|
+
venue: result.journalInfo?.journal?.title,
|
|
233
|
+
doi: result.doi,
|
|
234
|
+
url: `https://europepmc.org/article/${result.source}/${result.id}`,
|
|
235
|
+
abstract: result.abstractText ? cleanText(result.abstractText) : undefined,
|
|
236
|
+
sources: ['europe_pmc']
|
|
237
|
+
}));
|
|
238
|
+
};
|
|
239
|
+
// arXiv often rate limits or times out, so failure is expected: it should be
|
|
240
|
+
// fast, and point the model to sources that also index arXiv
|
|
241
|
+
const ARXIV_HINT = 'Use search_openalex or search_semantic_scholar instead, they also index arXiv papers.';
|
|
242
|
+
const arxiv = async (query, limit) => {
|
|
243
|
+
const terms = query.split(/\s+/).map(term => term.replace(/[^\p{L}\p{N}-]/gu, '')).filter(Boolean);
|
|
244
|
+
const params = new URLSearchParams({ search_query: terms.map(term => `all:${term}`).join(' AND '), max_results: String(limit) });
|
|
245
|
+
const res = await request(`https://export.arxiv.org/api/query?${params}`, { timeout: 10_000, hint: ARXIV_HINT });
|
|
246
|
+
const xml = await res.text();
|
|
247
|
+
if (!xml.includes('<feed'))
|
|
248
|
+
throw new Error(`export.arxiv.org returned an unexpected response. ${ARXIV_HINT}`);
|
|
249
|
+
return [...xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map(([, entry]) => {
|
|
250
|
+
const tag = (name) => entry.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`))?.[1];
|
|
251
|
+
const summary = tag('summary');
|
|
252
|
+
return {
|
|
253
|
+
title: cleanText(tag('title') ?? 'Untitled'),
|
|
254
|
+
authors: [...entry.matchAll(/<name>([\s\S]*?)<\/name>/g)].map(match => cleanText(match[1])),
|
|
255
|
+
year: Number(tag('published')?.slice(0, 4)) || undefined,
|
|
256
|
+
venue: 'arXiv',
|
|
257
|
+
doi: tag('arxiv:doi'),
|
|
258
|
+
url: tag('id')?.trim(),
|
|
259
|
+
abstract: summary ? cleanText(summary) : undefined,
|
|
260
|
+
sources: ['arxiv']
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
function formatPapers(papers) {
|
|
265
|
+
return papers.map((paper, i) => {
|
|
266
|
+
const authors = paper.authors.length > 3 ? `${paper.authors.slice(0, 3).join(', ')} et al.` : paper.authors.join(', ');
|
|
267
|
+
const lines = [`${i + 1}. ${paper.title}${paper.year ? ` (${paper.year})` : ''}`];
|
|
268
|
+
if (authors)
|
|
269
|
+
lines.push(` Authors: ${authors}`);
|
|
270
|
+
if (paper.venue)
|
|
271
|
+
lines.push(` Venue: ${paper.venue}`);
|
|
272
|
+
if (paper.doi)
|
|
273
|
+
lines.push(` DOI: ${paper.doi}`);
|
|
274
|
+
if (paper.url)
|
|
275
|
+
lines.push(` URL: ${paper.url}`);
|
|
276
|
+
lines.push(` Found in: ${paper.sources.join(', ')}`);
|
|
277
|
+
if (paper.abstract)
|
|
278
|
+
lines.push(` Abstract: ${shorten(paper.abstract, 600)}`);
|
|
279
|
+
return lines.join('\n');
|
|
280
|
+
}).join('\n\n');
|
|
281
|
+
}
|
|
282
|
+
const paperParameters = {
|
|
283
|
+
type: 'object',
|
|
284
|
+
properties: {
|
|
285
|
+
query: { type: 'string' },
|
|
286
|
+
limit: { type: 'number', description: 'Maximum results, defaults to 5' }
|
|
287
|
+
},
|
|
288
|
+
required: ['query']
|
|
289
|
+
};
|
|
290
|
+
const paperTool = (name, description, search) => ({
|
|
291
|
+
name,
|
|
292
|
+
description,
|
|
293
|
+
parameters: paperParameters,
|
|
294
|
+
async run({ query, limit = 5 }) {
|
|
295
|
+
const papers = await search(query, clampLimit(limit));
|
|
296
|
+
return papers.length ? formatPapers(papers) : 'No papers found';
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
const PAPER_SOURCES = [
|
|
300
|
+
['openalex', openAlex],
|
|
301
|
+
['semantic_scholar', semanticScholar],
|
|
302
|
+
['crossref', crossref],
|
|
303
|
+
['europe_pmc', europePmc],
|
|
304
|
+
['arxiv', arxiv]
|
|
305
|
+
];
|
|
306
|
+
const normalizeTitle = (title) => title.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
|
|
307
|
+
export const papers = {
|
|
308
|
+
name: 'search_papers',
|
|
309
|
+
description: 'Search all paper sources at once (OpenAlex, Semantic Scholar, Crossref, Europe PMC, arXiv) and merge duplicates. Use a single source tool when you know the field.',
|
|
310
|
+
parameters: paperParameters,
|
|
311
|
+
async run({ query, limit = 5 }) {
|
|
312
|
+
const results = await Promise.allSettled(PAPER_SOURCES.map(([, search]) => search(query, clampLimit(limit))));
|
|
313
|
+
const errors = [];
|
|
314
|
+
const lists = [];
|
|
315
|
+
results.forEach((result, i) => {
|
|
316
|
+
if (result.status === 'fulfilled')
|
|
317
|
+
lists.push(result.value);
|
|
318
|
+
else
|
|
319
|
+
errors.push(`${PAPER_SOURCES[i][0]}: ${result.reason instanceof Error ? result.reason.message : result.reason}`);
|
|
320
|
+
});
|
|
321
|
+
// Interleave sources so each one's best results come first
|
|
322
|
+
const merged = new Map();
|
|
323
|
+
for (let rank = 0; rank < Math.max(0, ...lists.map(list => list.length)); rank++) {
|
|
324
|
+
for (const paper of lists.map(list => list[rank]).filter(Boolean)) {
|
|
325
|
+
const key = normalizeTitle(paper.title);
|
|
326
|
+
const existing = merged.get(key);
|
|
327
|
+
if (!existing) {
|
|
328
|
+
merged.set(key, { ...paper, sources: [...paper.sources] });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
// A source can return the same paper twice, like a preprint and its publication
|
|
332
|
+
existing.sources = [...new Set([...existing.sources, ...paper.sources])];
|
|
333
|
+
existing.year ??= paper.year;
|
|
334
|
+
existing.venue ??= paper.venue;
|
|
335
|
+
existing.doi ??= paper.doi;
|
|
336
|
+
existing.url ??= paper.url;
|
|
337
|
+
existing.abstract ??= paper.abstract;
|
|
338
|
+
if (!existing.authors.length)
|
|
339
|
+
existing.authors = paper.authors;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
let output = merged.size ? formatPapers([...merged.values()]) : 'No papers found';
|
|
343
|
+
if (errors.length)
|
|
344
|
+
output += `\n\nUnavailable sources:\n${errors.join('\n')}`;
|
|
345
|
+
return output;
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
export const sourceTools = [
|
|
349
|
+
wikipediaSearch,
|
|
350
|
+
wikipediaArticle,
|
|
351
|
+
wikidataSearch,
|
|
352
|
+
wikidataEntity,
|
|
353
|
+
papers,
|
|
354
|
+
paperTool('search_openalex', 'Search OpenAlex, a broad index of scholarly works across all fields.', openAlex),
|
|
355
|
+
paperTool('search_semantic_scholar', 'Search Semantic Scholar, strong in computer science and biomedicine. Shared rate limit, may fail.', semanticScholar),
|
|
356
|
+
paperTool('search_crossref', 'Search Crossref publication metadata (journals, books, DOIs). Abstracts are often missing.', crossref),
|
|
357
|
+
paperTool('search_europe_pmc', 'Search Europe PMC for biomedical and life science literature.', europePmc),
|
|
358
|
+
paperTool('search_arxiv', 'Search arXiv preprints in physics, mathematics, computer science and related fields. Often rate limited, if it fails use search_openalex or search_semantic_scholar.', arxiv)
|
|
359
|
+
];
|
package/dist/subagent.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { runAgent } from './agent.js';
|
|
2
|
+
// A tool that delegates to a separate agent, so search results and pages
|
|
3
|
+
// don't fill up the main conversation
|
|
4
|
+
export function createSubagentTool(definition, { maxTurns = definition.maxTurns, extraTools = [], ...options }) {
|
|
5
|
+
const { parameter } = definition;
|
|
6
|
+
return {
|
|
7
|
+
name: definition.name,
|
|
8
|
+
description: definition.description,
|
|
9
|
+
parameters: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: { [parameter.name]: { type: 'string', description: parameter.description } },
|
|
12
|
+
required: [parameter.name]
|
|
13
|
+
},
|
|
14
|
+
async run(args) {
|
|
15
|
+
const messages = [
|
|
16
|
+
{ role: 'system', content: definition.prompt() },
|
|
17
|
+
{ role: 'user', content: args[parameter.name] }
|
|
18
|
+
];
|
|
19
|
+
const tools = typeof definition.tools === 'function' ? await definition.tools() : definition.tools;
|
|
20
|
+
const agentOptions = { ...options, tools: [...tools, ...extraTools], maxTurns };
|
|
21
|
+
const answer = await runAgent(messages, agentOptions);
|
|
22
|
+
if (extractUrls(answer).length)
|
|
23
|
+
return answer;
|
|
24
|
+
messages.push({ role: 'user', content: 'List the URL of every source you used.' });
|
|
25
|
+
return `${answer}\n\n${await runAgent(messages, agentOptions)}`;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function extractUrls(text) {
|
|
30
|
+
const urls = (text.match(/https?:\/\/[^\s<>"'`\]]+/g) ?? [])
|
|
31
|
+
// Trailing punctuation and unbalanced closing parens belong to the sentence
|
|
32
|
+
.map(url => url.replace(/[.,;:!?]+$/, ''))
|
|
33
|
+
.map(url => url.endsWith(')') && !url.includes('(') ? url.replace(/\)+$/, '') : url);
|
|
34
|
+
return [...new Set(urls)];
|
|
35
|
+
}
|
|
36
|
+
const appendList = (answer, items, heading, moreHeading, isIncluded) => {
|
|
37
|
+
const missing = items.filter(item => !isIncluded(item));
|
|
38
|
+
if (!missing.length)
|
|
39
|
+
return answer;
|
|
40
|
+
return `${answer}\n\n${missing.length < items.length ? moreHeading : heading}\n${missing.map(item => `- ${item}`).join('\n')}`;
|
|
41
|
+
};
|
|
42
|
+
// Makes sure every source ends up in the answer
|
|
43
|
+
export const appendSources = (answer, urls) => appendList(answer, urls, 'Sources:', 'More sources:', url => answer.includes(url));
|
|
44
|
+
// Headings of the sources and suggested web searches sections, in plain text
|
|
45
|
+
export const SECTION_HEADING = /^(more )?(sources|suggested web searches)\b[^:]*:?$/i;
|
|
46
|
+
// Reads the queries listed under a "Suggested web searches:" heading. Models
|
|
47
|
+
// like to extend the heading, as in "Suggested web searches for current news:".
|
|
48
|
+
export function extractSearches(text) {
|
|
49
|
+
const lines = text.split('\n');
|
|
50
|
+
const start = lines.findIndex(line => /^[#*_\s]*suggested web searches\b[^:]*:?[*_\s]*$/i.test(line));
|
|
51
|
+
if (start === -1)
|
|
52
|
+
return [];
|
|
53
|
+
const searches = [];
|
|
54
|
+
for (const line of lines.slice(start + 1)) {
|
|
55
|
+
const item = line.match(/^\s*(?:[-*+]|\d+[.)])\s+(.+)$/);
|
|
56
|
+
if (item) {
|
|
57
|
+
searches.push(item[1].trim().replace(/^["'`“]+|["'`”]+$/g, ''));
|
|
58
|
+
}
|
|
59
|
+
else if (line.trim() || searches.length) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [...new Set(searches)];
|
|
64
|
+
}
|
|
65
|
+
// Makes sure every suggested web search ends up in the answer
|
|
66
|
+
export const appendSearches = (answer, searches) => appendList(answer, searches, 'Suggested web searches:', 'More suggested web searches:', search => answer.toLowerCase().includes(search.toLowerCase()));
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { glob as fsGlob, mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
const MAX_OUTPUT = 30_000;
|
|
5
|
+
const MAX_RESULTS = 200;
|
|
6
|
+
const truncate = (text) => text.length > MAX_OUTPUT
|
|
7
|
+
? `${text.slice(0, MAX_OUTPUT)}\n[truncated ${text.length - MAX_OUTPUT} chars]`
|
|
8
|
+
: text;
|
|
9
|
+
const limitResults = (results) => truncate(results.length > MAX_RESULTS
|
|
10
|
+
? [...results.slice(0, MAX_RESULTS), `[${results.length - MAX_RESULTS} more results]`].join('\n')
|
|
11
|
+
: results.join('\n'));
|
|
12
|
+
const IGNORED = /(^|\/)(node_modules|\.git)$/;
|
|
13
|
+
const walk = async (pattern, cwd) => (await Array.fromAsync(fsGlob(pattern, { cwd, exclude: path => IGNORED.test(path) }))).sort();
|
|
14
|
+
// File tools only access files inside this directory. Undefined lifts the limit.
|
|
15
|
+
let fileRoot;
|
|
16
|
+
export function restrictFileTools(directory) {
|
|
17
|
+
fileRoot = directory === undefined ? undefined : resolve(directory);
|
|
18
|
+
}
|
|
19
|
+
// Follows symlinks. A path that doesn't exist yet resolves through its nearest
|
|
20
|
+
// existing parent, so a new file can't be written through a link either.
|
|
21
|
+
async function realPathOf(path) {
|
|
22
|
+
try {
|
|
23
|
+
return await realpath(path);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
const code = err.code;
|
|
27
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
28
|
+
throw err;
|
|
29
|
+
const parent = dirname(path);
|
|
30
|
+
return parent === path ? path : join(await realPathOf(parent), basename(path));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function isAllowed(path) {
|
|
34
|
+
if (fileRoot === undefined)
|
|
35
|
+
return true;
|
|
36
|
+
const rel = relative(await realPathOf(fileRoot), await realPathOf(resolve(path)));
|
|
37
|
+
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
38
|
+
}
|
|
39
|
+
async function checkPath(path) {
|
|
40
|
+
if (!await isAllowed(path)) {
|
|
41
|
+
throw new Error(`${path} is outside the working directory ${fileRoot}, file tools can only access files inside it`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export const read = {
|
|
45
|
+
name: 'read',
|
|
46
|
+
description: 'Read a text file. For large files, use offset and limit to read a range of lines.',
|
|
47
|
+
parameters: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
path: { type: 'string' },
|
|
51
|
+
offset: { type: 'number', description: 'First line to read, starting at 1' },
|
|
52
|
+
limit: { type: 'number', description: 'Maximum number of lines to read' }
|
|
53
|
+
},
|
|
54
|
+
required: ['path']
|
|
55
|
+
},
|
|
56
|
+
async run({ path, offset = 1, limit }) {
|
|
57
|
+
await checkPath(path);
|
|
58
|
+
const lines = (await readFile(path, 'utf8')).split('\n');
|
|
59
|
+
const end = limit === undefined ? lines.length : offset - 1 + limit;
|
|
60
|
+
let output = lines.slice(offset - 1, end).join('\n');
|
|
61
|
+
if (end < lines.length)
|
|
62
|
+
output += `\n[${lines.length - end} more lines, continue with offset ${end + 1}]`;
|
|
63
|
+
return truncate(output);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
export const write = {
|
|
67
|
+
name: 'write',
|
|
68
|
+
description: 'Write content to a file, creating parent directories and overwriting existing content.',
|
|
69
|
+
parameters: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: { path: { type: 'string' }, content: { type: 'string' } },
|
|
72
|
+
required: ['path', 'content']
|
|
73
|
+
},
|
|
74
|
+
async run({ path, content }) {
|
|
75
|
+
await checkPath(path);
|
|
76
|
+
await mkdir(dirname(path), { recursive: true });
|
|
77
|
+
await writeFile(path, content);
|
|
78
|
+
return `Wrote ${content.length} chars to ${path}`;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
export const edit = {
|
|
82
|
+
name: 'edit',
|
|
83
|
+
description: 'Replace an exact string in a file. old_string must match exactly once, unless replace_all is set.',
|
|
84
|
+
parameters: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
path: { type: 'string' },
|
|
88
|
+
old_string: { type: 'string', description: 'Exact text to replace, including whitespace' },
|
|
89
|
+
new_string: { type: 'string' },
|
|
90
|
+
replace_all: { type: 'boolean', description: 'Replace every occurrence' }
|
|
91
|
+
},
|
|
92
|
+
required: ['path', 'old_string', 'new_string']
|
|
93
|
+
},
|
|
94
|
+
async run({ path, old_string: oldString, new_string: newString, replace_all: replaceAll = false }) {
|
|
95
|
+
if (!oldString)
|
|
96
|
+
throw new Error('old_string must not be empty');
|
|
97
|
+
await checkPath(path);
|
|
98
|
+
const parts = (await readFile(path, 'utf8')).split(oldString);
|
|
99
|
+
const count = parts.length - 1;
|
|
100
|
+
if (count === 0)
|
|
101
|
+
throw new Error(`old_string not found in ${path}`);
|
|
102
|
+
if (count > 1 && !replaceAll) {
|
|
103
|
+
throw new Error(`old_string found ${count} times in ${path}. Include more surrounding text to make it unique, or set replace_all`);
|
|
104
|
+
}
|
|
105
|
+
// split/join instead of String.replace, so `$&` and friends stay literal
|
|
106
|
+
await writeFile(path, parts.join(newString));
|
|
107
|
+
return `Replaced ${count} occurrence${count === 1 ? '' : 's'} in ${path}`;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const IGNORED_NAMES = new Set(['node_modules', '.git']);
|
|
111
|
+
const formatSize = (bytes) => bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
112
|
+
export const glob = {
|
|
113
|
+
name: 'glob',
|
|
114
|
+
description: 'List files and directories matching a glob pattern, like "*" for the directory, "src/**/*.ts" or "{*.md,*/*.md}". Hidden files are included. Directories end with "/", files show their size. Doesn\'t look inside node_modules and .git. Use it instead of find, and ls to list a single directory.',
|
|
115
|
+
parameters: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
pattern: { type: 'string', description: 'Glob pattern, defaults to "*": everything in the directory' },
|
|
119
|
+
path: { type: 'string', description: 'Directory to search in, defaults to the working directory' }
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
async run({ pattern = '*', path = '.' }) {
|
|
123
|
+
await checkPath(path);
|
|
124
|
+
// Globs skip hidden files, so also match the last part with a leading dot:
|
|
125
|
+
// "*" becomes ".*", "**/*.md" becomes "**/.*.md"
|
|
126
|
+
const segments = pattern.split('/');
|
|
127
|
+
const last = segments.pop();
|
|
128
|
+
const patterns = last.startsWith('.') ? [pattern] : [pattern, [...segments, `.${last}`].join('/')];
|
|
129
|
+
const entries = new Map();
|
|
130
|
+
for (const variant of patterns) {
|
|
131
|
+
// node_modules and .git are listed, but never searched inside
|
|
132
|
+
for await (const entry of fsGlob(variant, { cwd: path, withFileTypes: true, exclude: dirent => IGNORED_NAMES.has(dirent.name) })) {
|
|
133
|
+
const fullPath = join(entry.parentPath, entry.name);
|
|
134
|
+
const relativePath = relative(path, fullPath);
|
|
135
|
+
// Patterns like "../*" and symlinks can reach outside the working directory
|
|
136
|
+
if (relativePath && await isAllowed(fullPath))
|
|
137
|
+
entries.set(fullPath, { path: join(path, relativePath), fullPath, directory: entry.isDirectory() });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!entries.size)
|
|
141
|
+
return 'No files found';
|
|
142
|
+
const sorted = [...entries.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
143
|
+
const lines = await Promise.all(sorted.slice(0, MAX_RESULTS).map(async (entry) => {
|
|
144
|
+
if (entry.directory)
|
|
145
|
+
return `${entry.path}/`;
|
|
146
|
+
try {
|
|
147
|
+
return `${entry.path} (${formatSize((await stat(entry.fullPath)).size)})`;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return entry.path;
|
|
151
|
+
}
|
|
152
|
+
}));
|
|
153
|
+
if (sorted.length > MAX_RESULTS)
|
|
154
|
+
lines.push(`[${sorted.length - MAX_RESULTS} more results]`);
|
|
155
|
+
return truncate(lines.join('\n'));
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
// Models reach for ls out of habit, so listing a directory gets its own name
|
|
159
|
+
export const ls = {
|
|
160
|
+
name: 'ls',
|
|
161
|
+
description: 'List a directory, like ls -la: hidden files included, directories end with "/", files show their size. Use it instead of running ls in bash.',
|
|
162
|
+
parameters: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
properties: {
|
|
165
|
+
path: { type: 'string', description: 'Directory to list, defaults to the working directory' }
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
run: ({ path = '.' }) => glob.run({ pattern: '*', path })
|
|
169
|
+
};
|
|
170
|
+
export const grep = {
|
|
171
|
+
name: 'grep',
|
|
172
|
+
description: 'Search file contents with a JavaScript regular expression. Returns file:line: text. Skips node_modules, .git and binary files.',
|
|
173
|
+
parameters: {
|
|
174
|
+
type: 'object',
|
|
175
|
+
properties: {
|
|
176
|
+
pattern: { type: 'string', description: 'Regular expression' },
|
|
177
|
+
path: { type: 'string', description: 'File or directory to search in, defaults to the working directory' },
|
|
178
|
+
include: { type: 'string', description: 'Glob of files to search, like "**/*.ts"' }
|
|
179
|
+
},
|
|
180
|
+
required: ['pattern']
|
|
181
|
+
},
|
|
182
|
+
async run({ pattern, path = '.', include = '**/*' }) {
|
|
183
|
+
const regex = new RegExp(pattern);
|
|
184
|
+
await checkPath(path);
|
|
185
|
+
const files = (await stat(path)).isFile()
|
|
186
|
+
? [path]
|
|
187
|
+
: (await walk(include, path)).map(file => join(path, file));
|
|
188
|
+
const matches = [];
|
|
189
|
+
for (const file of files) {
|
|
190
|
+
if (!await isAllowed(file))
|
|
191
|
+
continue;
|
|
192
|
+
let content;
|
|
193
|
+
try {
|
|
194
|
+
content = await readFile(file, 'utf8');
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
continue; // directories, unreadable files
|
|
198
|
+
}
|
|
199
|
+
if (content.includes('\0'))
|
|
200
|
+
continue;
|
|
201
|
+
content.split('\n').forEach((line, i) => {
|
|
202
|
+
if (regex.test(line))
|
|
203
|
+
matches.push(`${file}:${i + 1}: ${line.slice(0, 500)}`);
|
|
204
|
+
});
|
|
205
|
+
if (matches.length > MAX_RESULTS)
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
return matches.length ? limitResults(matches) : 'No matches found';
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
export const bash = {
|
|
212
|
+
name: 'bash',
|
|
213
|
+
description: 'Run a bash command in the working directory. Returns stdout and stderr. Prefer the dedicated tools when one fits.',
|
|
214
|
+
parameters: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
properties: { command: { type: 'string', description: 'The command to run' } },
|
|
217
|
+
required: ['command']
|
|
218
|
+
},
|
|
219
|
+
run({ command }) {
|
|
220
|
+
return new Promise(resolve => {
|
|
221
|
+
execFile('bash', ['-c', command], { timeout: 120_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
222
|
+
let output = stdout + stderr;
|
|
223
|
+
if (err)
|
|
224
|
+
output += `\n[exit ${err.code ?? err.signal}]`;
|
|
225
|
+
resolve(truncate(output));
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
// Tools that are missing on purpose, so the model doesn't keep asking for them.
|
|
231
|
+
// Fetching arbitrary pages and web search would send agent traffic to ad funded sites.
|
|
232
|
+
export const UNAVAILABLE_TOOLS = ['fetch web page', 'search the web'];
|
|
233
|
+
// Finds nothing, on purpose: requests show which tools are worth adding
|
|
234
|
+
export function createToolSearch(onSearch) {
|
|
235
|
+
return {
|
|
236
|
+
name: 'tool_search',
|
|
237
|
+
description: `Search for more tools by describing what you need a tool to do. Use it when none of your tools fit. Known to be unavailable, don't search for: ${UNAVAILABLE_TOOLS.join(', ')}.`,
|
|
238
|
+
parameters: {
|
|
239
|
+
type: 'object',
|
|
240
|
+
properties: {
|
|
241
|
+
description: { type: 'string', description: 'What the tool should do, like "fetch a web page as text"' }
|
|
242
|
+
},
|
|
243
|
+
required: ['description']
|
|
244
|
+
},
|
|
245
|
+
async run({ description }) {
|
|
246
|
+
onSearch(description);
|
|
247
|
+
return 'No matching tools found. Continue with the tools you have.';
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
export const tools = [read, write, edit, ls, glob, grep, bash];
|