@guanyilun/pi-ads 0.1.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.
Files changed (3) hide show
  1. package/README.md +123 -0
  2. package/extensions/ads.ts +504 -0
  3. package/package.json +18 -0
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # pi-ads
2
+
3
+ A [pi](https://github.com/MarioZechner/pi-coding-agent) extension for querying [NASA's Astrophysics Data System (ADS)](https://ui.adsabs.harvard.edu).
4
+
5
+ Provides three tools for searching papers, fetching paper details, and exploring citation graphs — all directly from your pi coding agent.
6
+
7
+ ## Setup
8
+
9
+ ### 1. Get an ADS API token
10
+
11
+ 1. Create a free account at [ui.adsabs.harvard.edu](https://ui.adsabs.harvard.edu) and log in.
12
+ 2. Go to **Settings → Token** and click **"Generate a new key"**.
13
+
14
+ ### 2. Set the environment variable
15
+
16
+ ```bash
17
+ export ADS_API_TOKEN="your-token-here"
18
+ ```
19
+
20
+ Add this to your `~/.bashrc`, `~/.zshrc`, or equivalent so it persists across sessions.
21
+
22
+ ### 3. Install the package
23
+
24
+ ```bash
25
+ pi install /path/to/pi-ads
26
+ ```
27
+
28
+ Or install from a git repository:
29
+
30
+ ```bash
31
+ pi install git:github.com/your-username/pi-ads
32
+ ```
33
+
34
+ Or install from npm:
35
+
36
+ ```bash
37
+ pi install npm:pi-ads
38
+ ```
39
+
40
+ For a quick one-shot test without installing:
41
+
42
+ ```bash
43
+ pi -e /path/to/pi-ads
44
+ ```
45
+
46
+ ## Tools
47
+
48
+ ### `ads_search` — Search ADS papers
49
+
50
+ Search the full ADS database with fielded queries, filters, and sorting.
51
+
52
+ **Parameters:**
53
+
54
+ | Parameter | Type | Description |
55
+ |---|---|---|
56
+ | `query` | string | **Required.** Search query. Supports fielded syntax: `title:exoplanets`, `author:"Spergel, D"`, `keyword:"dark matter"`, `year:2023`, `bibcode:2023ApJ...950L..12A`, `doi:10.3847/...`. Unfielded terms search all metadata. Use quotes for phrases, `+`/`-` for inclusion/exclusion. |
57
+ | `database` | string | Filter by database: `astronomy`, `physics`, or `general`. |
58
+ | `doctype` | string | Filter by document type: `article`, `proceedings`, `thesis`, `book`, `catalog`, `software`, `proposal`. |
59
+ | `refereed` | boolean | Filter to only peer-reviewed papers. |
60
+ | `year_from` | string | Start year for date range, e.g. `"2020"`. |
61
+ | `year_to` | string | End year for date range, e.g. `"2024"`. |
62
+ | `max_results` | number | Max papers to return (default 10, max 50). |
63
+ | `sort_by` | string | Sort order: `relevance` (default), `date`, `citation_count`, `read_count`. |
64
+ | `start` | number | Pagination offset (default 0). |
65
+
66
+ **Example prompts:**
67
+ - *"Search ADS for recent papers about fast radio bursts"*
68
+ - *"Find highly-cited papers about dark energy by Spergel, sorted by citation count"*
69
+ - *"Search for refereed articles about exoplanets in the astronomy database from 2023"*
70
+ - *"Find papers with 'transiting exoplanets' in the title, excluding Kepler"*
71
+
72
+ ### `ads_paper` — Fetch a specific paper
73
+
74
+ Look up a paper by its ADS bibcode, DOI, or arXiv ID.
75
+
76
+ **Parameters:**
77
+
78
+ | Parameter | Type | Description |
79
+ |---|---|---|
80
+ | `id` | string | **Required.** ADS bibcode (`2023ApJ...950L..12A`), DOI (`10.3847/2041-8213/acb7e0`), or arXiv ID (`arXiv:2301.01234`). |
81
+ | `bibtex` | boolean | Include BibTeX citation in the result (default false). |
82
+
83
+ **Example prompts:**
84
+ - *"Look up bibcode 2023ApJ...950L..12A on ADS and give me the BibTeX"*
85
+ - *"Find the ADS entry for DOI 10.3847/2041-8213/acb7e0"*
86
+ - *"Fetch arxiv paper 2301.01234 from ADS"*
87
+
88
+ ### `ads_citations` — Explore citation graphs
89
+
90
+ Find papers that cite a given paper (forward citations) or that a given paper cites (backward references).
91
+
92
+ **Parameters:**
93
+
94
+ | Parameter | Type | Description |
95
+ |---|---|---|
96
+ | `bibcode` | string | **Required.** ADS bibcode of the paper. |
97
+ | `direction` | string | `citations` (papers citing this one — default) or `references` (papers this one cites). |
98
+ | `max_results` | number | Max papers to return (default 20, max 50). |
99
+ | `sort_by` | string | Sort order: `date` (default), `citation_count`, or `read_count`. |
100
+ | `start` | number | Pagination offset (default 0). |
101
+
102
+ **Example prompts:**
103
+ - *"Find papers citing 2023ApJ...950L..12A"*
104
+ - *"Show me the references of 2019ARA&A..57..417P sorted by citation count"*
105
+ - *"What are the most-cited papers that cite bibcode 2020MNRAS.498.1424W?"*
106
+
107
+ ## Advanced Query Syntax
108
+
109
+ The `query` parameter in `ads_search` supports the full [ADS/Solr search syntax](https://ui.adsabs.harvard.edu/help/search/):
110
+
111
+ - **Fielded search:** `title:exoplanets`, `author:"Hubble, E"`, `abstract:"dark energy"`, `keyword:"gravitational waves"`
112
+ - **Phrase search:** `"black holes"` (use quotes for exact phrases)
113
+ - **Boolean operators:** `"transiting exoplanets" +JWST -Kepler`
114
+ - **Date ranges:** `year:2023`, `year:[2020 TO 2024]`, `pubdate:[2023-01-00 TO *]`
115
+ - **Wildcard:** `title:exoplanet*`
116
+
117
+ ## Rate Limits
118
+
119
+ The ADS API allows **5,000 queries per day** per token. Rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`) are included in API responses.
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,504 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "@mariozechner/pi-coding-agent";
3
+ import { Text } from "@mariozechner/pi-tui";
4
+ import { Type } from "@sinclair/typebox";
5
+ import { StringEnum } from "@mariozechner/pi-ai";
6
+
7
+ const ADS_SEARCH_API = "https://api.adsabs.harvard.edu/v1/search/query";
8
+ const ADS_EXPORT_API = "https://api.adsabs.harvard.edu/v1/export";
9
+ const ADS_LINK_GATEWAY = "https://ui.adsabs.harvard.edu/link_gateway";
10
+
11
+ const SEARCH_FIELDS = [
12
+ "bibcode", "title", "author", "abstract", "pubdate", "year",
13
+ "citation_count", "read_count", "doi", "identifier", "pub",
14
+ "arxiv_class", "keyword", "database", "doctype", "aff",
15
+ "volume", "issue", "page", "bibstem",
16
+ ].join(",");
17
+
18
+ interface ADSPaper {
19
+ bibcode: string;
20
+ title: string;
21
+ authors: string[];
22
+ affiliations: string[];
23
+ abstract: string;
24
+ pubdate: string;
25
+ year: string;
26
+ citationCount: number;
27
+ readCount: number;
28
+ doi: string[];
29
+ identifier: string[];
30
+ pub: string;
31
+ bibstem: string;
32
+ volume: string;
33
+ issue: string;
34
+ page: string;
35
+ arxivClass: string[];
36
+ keywords: string[];
37
+ database: string[];
38
+ doctype: string;
39
+ }
40
+
41
+ interface SearchDetails {
42
+ query: string;
43
+ totalResults: number;
44
+ returned: number;
45
+ papers: ADSPaper[];
46
+ }
47
+
48
+ interface PaperDetails {
49
+ paper: ADSPaper | null;
50
+ bibtex?: string;
51
+ }
52
+
53
+ interface CitationsDetails {
54
+ bibcode: string;
55
+ direction: "citations" | "references";
56
+ totalResults: number;
57
+ returned: number;
58
+ papers: ADSPaper[];
59
+ }
60
+
61
+ function parseDoc(doc: any): ADSPaper {
62
+ return {
63
+ bibcode: doc.bibcode ?? "",
64
+ title: Array.isArray(doc.title) ? doc.title.join(" ") : (doc.title ?? ""),
65
+ authors: Array.isArray(doc.author) ? doc.author : doc.author ? [doc.author] : [],
66
+ affiliations: Array.isArray(doc.aff) ? doc.aff : doc.aff ? [doc.aff] : [],
67
+ abstract: doc.abstract ?? "",
68
+ pubdate: doc.pubdate ?? "",
69
+ year: doc.year ?? "",
70
+ citationCount: doc.citation_count ?? 0,
71
+ readCount: doc.read_count ?? 0,
72
+ doi: Array.isArray(doc.doi) ? doc.doi : doc.doi ? [doc.doi] : [],
73
+ identifier: Array.isArray(doc.identifier) ? doc.identifier : doc.identifier ? [doc.identifier] : [],
74
+ pub: doc.pub ?? "",
75
+ bibstem: Array.isArray(doc.bibstem) ? doc.bibstem[0] ?? "" : (doc.bibstem ?? ""),
76
+ volume: doc.volume ?? "",
77
+ issue: doc.issue ?? "",
78
+ page: doc.page ?? "",
79
+ arxivClass: Array.isArray(doc.arxiv_class) ? doc.arxiv_class : doc.arxiv_class ? [doc.arxiv_class] : [],
80
+ keywords: Array.isArray(doc.keyword) ? doc.keyword : doc.keyword ? [doc.keyword] : [],
81
+ database: Array.isArray(doc.database) ? doc.database : doc.database ? [doc.database] : [],
82
+ doctype: doc.doctype ?? "",
83
+ };
84
+ }
85
+
86
+ function formatPaper(p: ADSPaper, index?: number): string {
87
+ const prefix = index !== undefined ? `[${index + 1}] ` : "";
88
+ const lines: string[] = [
89
+ `${prefix}${p.title}`,
90
+ ` Bibcode: ${p.bibcode}`,
91
+ ` Authors: ${p.authors.join("; ")}`,
92
+ ` Published: ${p.pubdate} Year: ${p.year}`,
93
+ ` Journal: ${p.pub}`,
94
+ ];
95
+ if (p.doi.length > 0) lines.push(` DOI: ${p.doi.join(", ")}`);
96
+ if (p.arxivClass.length > 0) lines.push(` arXiv: ${p.arxivClass.join(", ")}`);
97
+ if (p.keywords.length > 0) lines.push(` Keywords: ${p.keywords.slice(0, 10).join(", ")}`);
98
+ lines.push(` Citations: ${p.citationCount} Reads: ${p.readCount}`);
99
+ lines.push(` ADS URL: ${ADS_LINK_GATEWAY}/${p.bibcode}`);
100
+ if (p.abstract) lines.push(` Abstract: ${p.abstract}`);
101
+ return lines.join("\n");
102
+ }
103
+
104
+ function getToken(): string {
105
+ const token = process.env.ADS_API_TOKEN ?? "";
106
+ if (!token) {
107
+ throw new Error(
108
+ "ADS_API_TOKEN environment variable not set. " +
109
+ "Get a token from https://ui.adsabs.harvard.edu/#user/settings/token and set it in your environment."
110
+ );
111
+ }
112
+ return token;
113
+ }
114
+
115
+ async function adsFetch(url: string, signal?: AbortSignal, options?: RequestInit): Promise<any> {
116
+ const token = getToken();
117
+ const resp = await fetch(url, {
118
+ ...options,
119
+ signal,
120
+ headers: {
121
+ "Authorization": `Bearer ${token}`,
122
+ "Content-Type": "application/json",
123
+ ...(options?.headers ?? {}),
124
+ },
125
+ });
126
+ if (!resp.ok) {
127
+ const body = await resp.text().catch(() => "");
128
+ throw new Error(`ADS API error: ${resp.status} ${resp.statusText}${body ? ` - ${body}` : ""}`);
129
+ }
130
+ return resp.json();
131
+ }
132
+
133
+ function buildSortParam(sortBy: string): string {
134
+ const map: Record<string, string> = {
135
+ relevance: "score desc",
136
+ date: "date desc",
137
+ citation_count: "citation_count desc",
138
+ read_count: "read_count desc",
139
+ };
140
+ return map[sortBy] ?? "score desc";
141
+ }
142
+
143
+ export default function (pi: ExtensionAPI) {
144
+ pi.registerTool({
145
+ name: "ads_search",
146
+ label: "ADS Search",
147
+ description:
148
+ "Search NASA's Astrophysics Data System (ADS) for astronomy and physics papers. " +
149
+ "Supports fielded queries (title:, author:, abstract:, keyword:, year:, bibcode:, doi:, etc.), " +
150
+ "database filters (astronomy, physics, general), and sorting by relevance, date, or citation count. " +
151
+ "Returns titles, authors, abstracts, citation counts, and ADS links. " +
152
+ "Requires ADS_API_TOKEN environment variable.",
153
+ promptSnippet:
154
+ "Search NASA ADS for astronomy/physics papers. Supports fielded queries, database filters, and sorting. " +
155
+ "Returns titles, authors, abstracts, citations, and links.",
156
+ parameters: Type.Object({
157
+ query: Type.String({
158
+ description:
159
+ 'Search query. Supports fielded searches like "title:exoplanets", ' +
160
+ '"author:\\"Hubble, E\\"", "keyword:dark matter", "year:2023", ' +
161
+ '"bibcode:2023ApJ...950L..12A", "doi:10.3847/2041-8213/acbe no". ' +
162
+ 'Unfielded terms search all metadata, e.g. "black holes". ' +
163
+ 'Use quotes for phrases, +/- for inclusion/exclusion.',
164
+ }),
165
+ database: Type.Optional(
166
+ StringEnum(["astronomy", "physics", "general"] as const, {
167
+ description: "Filter by ADS database (astronomy, physics, or general). Default: searches all databases.",
168
+ })
169
+ ),
170
+ doctype: Type.Optional(
171
+ StringEnum(["article", "proceedings", "thesis", "book", "catalog", "software", "proposal"] as const, {
172
+ description: "Filter by document type (e.g. article, proceedings, thesis).",
173
+ })
174
+ ),
175
+ refereed: Type.Optional(
176
+ Type.Boolean({ description: "Filter to only refereed (peer-reviewed) papers." })
177
+ ),
178
+ year_from: Type.Optional(
179
+ Type.String({ description: "Start year for date range filter, e.g. '2020'." })
180
+ ),
181
+ year_to: Type.Optional(
182
+ Type.String({ description: "End year for date range filter, e.g. '2024'." })
183
+ ),
184
+ max_results: Type.Optional(
185
+ Type.Number({ description: "Max papers to return (default 10, max 50)", default: 10 })
186
+ ),
187
+ sort_by: Type.Optional(
188
+ StringEnum(["relevance", "date", "citation_count", "read_count"] as const, {
189
+ description: "Sort order (default: relevance).",
190
+ })
191
+ ),
192
+ start: Type.Optional(
193
+ Type.Number({ description: "Start index for pagination (default 0)", default: 0 })
194
+ ),
195
+ }),
196
+
197
+ async execute(_toolCallId, params, signal) {
198
+ const maxResults = Math.min(params.max_results ?? 10, 50);
199
+ const start = params.start ?? 0;
200
+ const sort = buildSortParam(params.sort_by ?? "relevance");
201
+
202
+ // Build filter queries
203
+ const fq: string[] = [];
204
+ if (params.database) fq.push(`database:${params.database}`);
205
+ if (params.doctype) fq.push(`doctype:${params.doctype}`);
206
+ if (params.refereed) fq.push("property:refereed");
207
+ if (params.year_from || params.year_to) {
208
+ const from = params.year_from ?? "*";
209
+ const to = params.year_to ?? "*";
210
+ fq.push(`year:[${from} TO ${to}]`);
211
+ }
212
+
213
+ const fqParam = fq.map(f => `&fq=${encodeURIComponent(f)}`).join("");
214
+ const url =
215
+ `${ADS_SEARCH_API}?q=${encodeURIComponent(params.query)}` +
216
+ `&fl=${SEARCH_FIELDS}&rows=${maxResults}&start=${start}` +
217
+ `&sort=${encodeURIComponent(sort)}${fqParam}`;
218
+
219
+ const data = await adsFetch(url, signal);
220
+ const response = data.response ?? data;
221
+ const docs: any[] = response.docs ?? [];
222
+ const totalResults = response.numFound ?? 0;
223
+
224
+ if (docs.length === 0) {
225
+ return {
226
+ content: [{ type: "text", text: `No papers found for query: ${params.query}` }],
227
+ details: { query: params.query, totalResults: 0, returned: 0, papers: [] } as SearchDetails,
228
+ };
229
+ }
230
+
231
+ const papers = docs.map(parseDoc);
232
+ const header = `Found ${totalResults} papers (showing ${start + 1}-${start + papers.length}):\n`;
233
+ const body = papers.map((p, i) => formatPaper(p, i)).join("\n\n");
234
+ let text = header + body;
235
+
236
+ const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
237
+ text = truncation.content;
238
+ if (truncation.truncated) {
239
+ text += `\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}]`;
240
+ }
241
+
242
+ return {
243
+ content: [{ type: "text", text }],
244
+ details: { query: params.query, totalResults, returned: papers.length, papers } as SearchDetails,
245
+ };
246
+ },
247
+
248
+ renderCall(args, theme) {
249
+ let text = theme.fg("toolTitle", theme.bold("ads "));
250
+ text += theme.fg("accent", `"${args.query}"`);
251
+ if (args.database) text += theme.fg("muted", ` db:${args.database}`);
252
+ if (args.refereed) text += theme.fg("muted", " refereed");
253
+ if (args.max_results) text += theme.fg("dim", ` max:${args.max_results}`);
254
+ return new Text(text, 0, 0);
255
+ },
256
+
257
+ renderResult(result, { expanded }, theme) {
258
+ const details = result.details as SearchDetails | undefined;
259
+ if (!details || details.returned === 0) {
260
+ return new Text(theme.fg("dim", "No papers found"), 0, 0);
261
+ }
262
+
263
+ let text = theme.fg("success", `${details.totalResults} results`);
264
+ text += theme.fg("dim", ` (showing ${details.returned})`);
265
+
266
+ if (expanded) {
267
+ for (const p of details.papers) {
268
+ text += "\n\n" + theme.fg("accent", theme.bold(p.title));
269
+ text += "\n" + theme.fg("dim", `${p.bibcode} · ${p.pubdate} · ${p.authors.slice(0, 3).join(", ")}${p.authors.length > 3 ? " et al." : ""}`);
270
+ text += " " + theme.fg("muted", `(${p.citationCount} cit)`);
271
+ }
272
+ }
273
+
274
+ return new Text(text, 0, 0);
275
+ },
276
+ });
277
+
278
+ pi.registerTool({
279
+ name: "ads_paper",
280
+ label: "ADS Paper",
281
+ description:
282
+ "Fetch details of a specific paper from NASA's ADS by bibcode, DOI, or arXiv ID. " +
283
+ "Accepts ADS bibcodes like '2023ApJ...950L..12A', DOIs like '10.3847/2041-8213/acb7e0', " +
284
+ "or arXiv IDs like 'arXiv:2301.01234'. Optionally returns BibTeX for citation. " +
285
+ "Requires ADS_API_TOKEN environment variable.",
286
+ promptSnippet:
287
+ "Fetch a specific ADS paper by bibcode, DOI, or arXiv ID. Optionally returns BibTeX.",
288
+ parameters: Type.Object({
289
+ id: Type.String({
290
+ description:
291
+ 'Paper identifier: ADS bibcode (e.g. "2023ApJ...950L..12A"), ' +
292
+ 'DOI (e.g. "10.3847/2041-8213/acb7e0"), or arXiv ID (e.g. "arXiv:2301.01234").',
293
+ }),
294
+ bibtex: Type.Optional(
295
+ Type.Boolean({ description: "Include BibTeX citation (default: false)." })
296
+ ),
297
+ }),
298
+
299
+ async execute(_toolCallId, params, signal) {
300
+ let query: string;
301
+ const id = params.id.trim();
302
+
303
+ // Detect identifier type and build appropriate query
304
+ if (id.startsWith("10.") || id.startsWith("doi:")) {
305
+ const doi = id.replace(/^doi:/, "");
306
+ query = `doi:"${doi}"`;
307
+ } else if (id.toLowerCase().startsWith("arxiv:") || /^\d{4}\.\d{4,5}/.test(id)) {
308
+ const arxivId = id.replace(/^arxiv:/i, "");
309
+ query = `arxiv:"${arxivId}"`;
310
+ } else {
311
+ // Assume bibcode
312
+ query = `bibcode:${id}`;
313
+ }
314
+
315
+ const url =
316
+ `${ADS_SEARCH_API}?q=${encodeURIComponent(query)}` +
317
+ `&fl=${SEARCH_FIELDS}&rows=1`;
318
+
319
+ const data = await adsFetch(url, signal);
320
+ const response = data.response ?? data;
321
+ const docs: any[] = response.docs ?? [];
322
+
323
+ if (docs.length === 0) {
324
+ return {
325
+ content: [{ type: "text", text: `Paper not found: ${id}` }],
326
+ details: { paper: null } as PaperDetails,
327
+ isError: true,
328
+ };
329
+ }
330
+
331
+ const paper = parseDoc(docs[0]);
332
+ let text = formatPaper(paper);
333
+
334
+ // Optionally fetch BibTeX
335
+ let bibtex: string | undefined;
336
+ if (params.bibtex && paper.bibcode) {
337
+ try {
338
+ const exportUrl = `${ADS_EXPORT_API}/bibtex`;
339
+ const exportData = await adsFetch(exportUrl, signal, {
340
+ method: "POST",
341
+ body: JSON.stringify({ bibcode: [paper.bibcode] }),
342
+ });
343
+ bibtex = exportData.export ?? "";
344
+ if (bibtex) {
345
+ text += `\n\nBibTeX:\n${bibtex}`;
346
+ }
347
+ } catch {
348
+ text += "\n\n[Failed to fetch BibTeX]";
349
+ }
350
+ }
351
+
352
+ return {
353
+ content: [{ type: "text", text }],
354
+ details: { paper, bibtex } as PaperDetails,
355
+ };
356
+ },
357
+
358
+ renderCall(args, theme) {
359
+ let text = theme.fg("toolTitle", theme.bold("ads "));
360
+ text += theme.fg("accent", args.id);
361
+ if (args.bibtex) text += theme.fg("dim", " +bibtex");
362
+ return new Text(text, 0, 0);
363
+ },
364
+
365
+ renderResult(result, { expanded }, theme) {
366
+ const details = result.details as PaperDetails | undefined;
367
+ if (!details?.paper) {
368
+ return new Text(theme.fg("error", "Paper not found"), 0, 0);
369
+ }
370
+
371
+ const p = details.paper;
372
+ let text = theme.fg("accent", theme.bold(p.title));
373
+ text += "\n" + theme.fg("dim", `${p.bibcode} · ${p.pubdate}`);
374
+ text += "\n" + theme.fg("muted", p.authors.join("; "));
375
+ text += "\n" + theme.fg("muted", `${p.pub}`);
376
+ text += " " + theme.fg("success", `${p.citationCount} cit`);
377
+
378
+ if (expanded) {
379
+ if (p.keywords.length > 0) {
380
+ text += "\n" + theme.fg("dim", `Keywords: ${p.keywords.join(", ")}`);
381
+ }
382
+ if (p.doi.length > 0) {
383
+ text += "\n" + theme.fg("dim", `DOI: ${p.doi.join(", ")}`);
384
+ }
385
+ if (p.abstract) {
386
+ text += "\n\n" + p.abstract;
387
+ }
388
+ if (details.bibtex) {
389
+ text += "\n\n" + theme.fg("dim", "BibTeX available");
390
+ }
391
+ }
392
+
393
+ return new Text(text, 0, 0);
394
+ },
395
+ });
396
+
397
+ pi.registerTool({
398
+ name: "ads_citations",
399
+ label: "ADS Citations",
400
+ description:
401
+ "Find papers that cite a given paper (citations) or that a given paper cites (references). " +
402
+ "Uses ADS bibcode identifiers. Requires ADS_API_TOKEN environment variable.",
403
+ promptSnippet:
404
+ "Find citing or referenced papers for an ADS bibcode. Returns titles, authors, abstracts, and citation counts.",
405
+ parameters: Type.Object({
406
+ bibcode: Type.String({
407
+ description: 'ADS bibcode of the paper, e.g. "2023ApJ...950L..12A".',
408
+ }),
409
+ direction: Type.Optional(
410
+ StringEnum(["citations", "references"] as const, {
411
+ description: '"citations" = papers that cite this paper (default). "references" = papers this paper cites.',
412
+ })
413
+ ),
414
+ max_results: Type.Optional(
415
+ Type.Number({ description: "Max papers to return (default 20, max 50)", default: 20 })
416
+ ),
417
+ sort_by: Type.Optional(
418
+ StringEnum(["date", "citation_count", "read_count"] as const, {
419
+ description: "Sort order (default: date).",
420
+ })
421
+ ),
422
+ start: Type.Optional(
423
+ Type.Number({ description: "Start index for pagination (default 0)", default: 0 })
424
+ ),
425
+ }),
426
+
427
+ async execute(_toolCallId, params, signal) {
428
+ const direction = params.direction ?? "citations";
429
+ const maxResults = Math.min(params.max_results ?? 20, 50);
430
+ const start = params.start ?? 0;
431
+ const sortField = params.sort_by ?? "date";
432
+ const sort = buildSortParam(sortField);
433
+
434
+ // ADS uses citations(bibcode) and references(bibcode) query syntax
435
+ const queryFunc = direction === "citations" ? "citations" : "references";
436
+ const query = `${queryFunc}(${params.bibcode})`;
437
+
438
+ const url =
439
+ `${ADS_SEARCH_API}?q=${encodeURIComponent(query)}` +
440
+ `&fl=${SEARCH_FIELDS}&rows=${maxResults}&start=${start}` +
441
+ `&sort=${encodeURIComponent(sort)}`;
442
+
443
+ const data = await adsFetch(url, signal);
444
+ const response = data.response ?? data;
445
+ const docs: any[] = response.docs ?? [];
446
+ const totalResults = response.numFound ?? 0;
447
+
448
+ if (docs.length === 0) {
449
+ const label = direction === "citations" ? "citing" : "referenced by";
450
+ return {
451
+ content: [{ type: "text", text: `No papers ${label} ${params.bibcode}` }],
452
+ details: { bibcode: params.bibcode, direction, totalResults: 0, returned: 0, papers: [] } as CitationsDetails,
453
+ };
454
+ }
455
+
456
+ const papers = docs.map(parseDoc);
457
+ const dirLabel = direction === "citations" ? "citing papers" : "referenced papers";
458
+ const header = `Found ${totalResults} ${dirLabel} (showing ${start + 1}-${start + papers.length}):
459
+ `;
460
+ const body = papers.map((p, i) => formatPaper(p, i)).join("\n\n");
461
+ let text = header + body;
462
+
463
+ const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
464
+ text = truncation.content;
465
+ if (truncation.truncated) {
466
+ text += `\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}]`;
467
+ }
468
+
469
+ return {
470
+ content: [{ type: "text", text }],
471
+ details: { bibcode: params.bibcode, direction, totalResults, returned: papers.length, papers } as CitationsDetails,
472
+ };
473
+ },
474
+
475
+ renderCall(args, theme) {
476
+ const dir = args.direction ?? "citations";
477
+ let text = theme.fg("toolTitle", theme.bold("ads "));
478
+ text += theme.fg("accent", args.bibcode);
479
+ text += theme.fg("muted", ` ${dir}`);
480
+ return new Text(text, 0, 0);
481
+ },
482
+
483
+ renderResult(result, { expanded }, theme) {
484
+ const details = result.details as CitationsDetails | undefined;
485
+ if (!details || details.returned === 0) {
486
+ return new Text(theme.fg("dim", "No citations found"), 0, 0);
487
+ }
488
+
489
+ const dirLabel = details.direction === "citations" ? "citing" : "referenced";
490
+ let text = theme.fg("success", `${details.totalResults} ${dirLabel}`);
491
+ text += theme.fg("dim", ` (showing ${details.returned})`);
492
+
493
+ if (expanded) {
494
+ for (const p of details.papers) {
495
+ text += "\n\n" + theme.fg("accent", theme.bold(p.title));
496
+ text += "\n" + theme.fg("dim", `${p.bibcode} · ${p.pubdate} · ${p.authors.slice(0, 3).join(", ")}${p.authors.length > 3 ? " et al." : ""}`);
497
+ text += " " + theme.fg("muted", `(${p.citationCount} cit)`);
498
+ }
499
+ }
500
+
501
+ return new Text(text, 0, 0);
502
+ },
503
+ });
504
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@guanyilun/pi-ads",
3
+ "version": "0.1.0",
4
+ "description": "pi extension for querying NASA's Astrophysics Data System (ADS)",
5
+ "keywords": ["pi-package"],
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/guanyilun/pi-ads.git"
10
+ },
11
+ "homepage": "https://github.com/guanyilun/pi-ads",
12
+ "peerDependencies": {
13
+ "@mariozechner/pi-ai": "*",
14
+ "@mariozechner/pi-coding-agent": "*",
15
+ "@mariozechner/pi-tui": "*",
16
+ "@sinclair/typebox": "*"
17
+ }
18
+ }