@oh-my-pi/pi-coding-agent 3.30.0 → 3.32.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 (158) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +367 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/sdk.ts +10 -2
  9. package/src/core/session-manager.ts +158 -246
  10. package/src/core/session-storage.ts +379 -0
  11. package/src/core/settings-manager.ts +155 -4
  12. package/src/core/slash-commands.ts +39 -13
  13. package/src/core/system-prompt.ts +62 -64
  14. package/src/core/tools/ask.ts +5 -4
  15. package/src/core/tools/bash-interceptor.ts +26 -61
  16. package/src/core/tools/bash.ts +13 -8
  17. package/src/core/tools/edit-diff.ts +11 -4
  18. package/src/core/tools/edit.ts +7 -13
  19. package/src/core/tools/find.ts +111 -50
  20. package/src/core/tools/gemini-image.ts +128 -147
  21. package/src/core/tools/grep.ts +397 -415
  22. package/src/core/tools/index.test.ts +5 -1
  23. package/src/core/tools/index.ts +8 -4
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +84 -19
  26. package/src/core/tools/lsp/config.ts +205 -656
  27. package/src/core/tools/lsp/defaults.json +465 -0
  28. package/src/core/tools/lsp/index.ts +72 -35
  29. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  30. package/src/core/tools/lsp/types.ts +1 -0
  31. package/src/core/tools/lsp/utils.ts +1 -1
  32. package/src/core/tools/read.ts +150 -74
  33. package/src/core/tools/render-utils.ts +70 -10
  34. package/src/core/tools/review.ts +38 -126
  35. package/src/core/tools/task/artifacts.ts +5 -4
  36. package/src/core/tools/task/commands.ts +4 -0
  37. package/src/core/tools/task/executor.ts +94 -83
  38. package/src/core/tools/task/index.ts +130 -92
  39. package/src/core/tools/task/parallel.ts +30 -3
  40. package/src/core/tools/task/render.ts +85 -39
  41. package/src/core/tools/task/types.ts +15 -6
  42. package/src/core/tools/task/worker.ts +124 -89
  43. package/src/core/tools/web-fetch.ts +112 -377
  44. package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
  45. package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
  46. package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
  47. package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
  48. package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
  49. package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
  50. package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
  51. package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
  52. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  53. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  54. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  55. package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
  56. package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
  57. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  58. package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
  59. package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
  60. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  61. package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
  62. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  63. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  64. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  65. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
  66. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
  67. package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
  68. package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
  69. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
  70. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
  71. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
  72. package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
  73. package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
  74. package/src/core/tools/web-scrapers/index.ts +250 -0
  75. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  76. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  77. package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
  78. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
  79. package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
  80. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
  81. package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
  82. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  83. package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
  84. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
  85. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
  86. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  87. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  88. package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
  89. package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
  90. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  91. package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
  92. package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
  93. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
  94. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
  95. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
  96. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  97. package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
  98. package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
  99. package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
  100. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
  101. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
  102. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  103. package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
  104. package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
  105. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  106. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  107. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  108. package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
  109. package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
  110. package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
  111. package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
  112. package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
  113. package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
  114. package/src/core/tools/web-scrapers/utils.ts +162 -0
  115. package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
  116. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  117. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  118. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
  119. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
  120. package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
  121. package/src/core/tools/write.ts +21 -18
  122. package/src/core/voice.ts +3 -2
  123. package/src/lib/worktree/collapse.ts +2 -1
  124. package/src/lib/worktree/git.ts +2 -18
  125. package/src/main.ts +59 -3
  126. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  127. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  128. package/src/modes/interactive/components/hook-editor.ts +2 -1
  129. package/src/modes/interactive/components/model-selector.ts +19 -4
  130. package/src/modes/interactive/interactive-mode.ts +41 -63
  131. package/src/modes/interactive/theme/theme.ts +58 -58
  132. package/src/modes/rpc/rpc-mode.ts +10 -9
  133. package/src/prompts/review-request.md +27 -0
  134. package/src/prompts/reviewer.md +64 -68
  135. package/src/prompts/tools/output.md +22 -3
  136. package/src/prompts/tools/task.md +32 -33
  137. package/src/utils/clipboard.ts +2 -1
  138. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  139. package/src/core/tools/web-fetch-handlers/index.ts +0 -69
  140. package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
  141. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
  142. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
  143. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
  144. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
  145. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
  146. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
  147. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
  148. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
  149. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
  150. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
  151. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
  152. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
  153. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
  154. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
  155. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
  156. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
  157. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
  158. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * ORCID handler for web-fetch
3
+ */
4
+
5
+ import type { RenderResult, SpecialHandler } from "./types";
6
+ import { finalizeOutput, loadPage } from "./types";
7
+
8
+ const MAX_WORKS = 50;
9
+ const ORCID_PATTERN = /\/(\d{4}-\d{4}-\d{4}-\d{3}[\dXx])(?:\/|$)/;
10
+
11
+ interface OrcidName {
12
+ "given-names"?: { value?: string };
13
+ "family-name"?: { value?: string };
14
+ "credit-name"?: { value?: string };
15
+ }
16
+
17
+ interface OrcidBiography {
18
+ content?: string;
19
+ }
20
+
21
+ interface OrcidPerson {
22
+ name?: OrcidName;
23
+ biography?: OrcidBiography;
24
+ }
25
+
26
+ interface OrcidSummaryDate {
27
+ year?: { value?: string };
28
+ month?: { value?: string };
29
+ day?: { value?: string };
30
+ }
31
+
32
+ interface OrcidOrganizationAddress {
33
+ city?: string;
34
+ region?: string;
35
+ country?: string;
36
+ }
37
+
38
+ interface OrcidOrganization {
39
+ name?: string;
40
+ address?: OrcidOrganizationAddress;
41
+ }
42
+
43
+ interface OrcidAffiliationSummary {
44
+ organization?: OrcidOrganization;
45
+ "role-title"?: string;
46
+ "department-name"?: string;
47
+ "start-date"?: OrcidSummaryDate;
48
+ "end-date"?: OrcidSummaryDate;
49
+ }
50
+
51
+ interface OrcidAffiliationGroupSummary {
52
+ "employment-summary"?: OrcidAffiliationSummary;
53
+ "education-summary"?: OrcidAffiliationSummary;
54
+ }
55
+
56
+ interface OrcidAffiliationGroup {
57
+ summaries?: OrcidAffiliationGroupSummary[];
58
+ }
59
+
60
+ interface OrcidAffiliationsContainer {
61
+ "affiliation-group"?: OrcidAffiliationGroup[];
62
+ "employment-summary"?: OrcidAffiliationSummary[];
63
+ "education-summary"?: OrcidAffiliationSummary[];
64
+ }
65
+
66
+ interface OrcidWorkTitle {
67
+ title?: { value?: string };
68
+ }
69
+
70
+ interface OrcidWorkSummary {
71
+ title?: OrcidWorkTitle;
72
+ }
73
+
74
+ interface OrcidWorkGroup {
75
+ "work-summary"?: OrcidWorkSummary[];
76
+ }
77
+
78
+ interface OrcidWorksContainer {
79
+ group?: OrcidWorkGroup[];
80
+ }
81
+
82
+ interface OrcidActivitiesSummary {
83
+ employments?: OrcidAffiliationsContainer;
84
+ educations?: OrcidAffiliationsContainer;
85
+ works?: OrcidWorksContainer;
86
+ }
87
+
88
+ interface OrcidRecord {
89
+ "orcid-identifier"?: { path?: string; uri?: string };
90
+ person?: OrcidPerson;
91
+ "activities-summary"?: OrcidActivitiesSummary;
92
+ }
93
+
94
+ function isOrcidHost(hostname: string): boolean {
95
+ return hostname === "orcid.org" || hostname === "www.orcid.org";
96
+ }
97
+
98
+ function extractOrcidId(pathname: string): string | null {
99
+ const match = pathname.match(ORCID_PATTERN);
100
+ return match?.[1] ?? null;
101
+ }
102
+
103
+ function formatName(name?: OrcidName): string | null {
104
+ const credit = name?.["credit-name"]?.value?.trim();
105
+ if (credit) return credit;
106
+
107
+ const given = name?.["given-names"]?.value?.trim();
108
+ const family = name?.["family-name"]?.value?.trim();
109
+ if (given && family) return `${given} ${family}`;
110
+ return given || family || null;
111
+ }
112
+
113
+ function formatDate(date?: OrcidSummaryDate): string | null {
114
+ const year = date?.year?.value;
115
+ if (!year) return null;
116
+
117
+ const month = date?.month?.value;
118
+ const day = date?.day?.value;
119
+ if (month && day) {
120
+ return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
121
+ }
122
+ if (month) return `${year}-${month.padStart(2, "0")}`;
123
+ return year;
124
+ }
125
+
126
+ function collectAffiliations(
127
+ container: OrcidAffiliationsContainer | undefined,
128
+ key: "employment-summary" | "education-summary",
129
+ ): OrcidAffiliationSummary[] {
130
+ const summaries: OrcidAffiliationSummary[] = [];
131
+
132
+ if (!container) return summaries;
133
+
134
+ const direct = container[key];
135
+ if (direct?.length) summaries.push(...direct);
136
+
137
+ const groups = container["affiliation-group"];
138
+ if (groups?.length) {
139
+ for (const group of groups) {
140
+ const groupSummaries = group.summaries || [];
141
+ for (const summary of groupSummaries) {
142
+ const entry = summary[key];
143
+ if (entry) summaries.push(entry);
144
+ }
145
+ }
146
+ }
147
+
148
+ return summaries;
149
+ }
150
+
151
+ function formatAffiliation(summary: OrcidAffiliationSummary): string | null {
152
+ const organization = summary.organization?.name?.trim();
153
+ const role = summary["role-title"]?.trim();
154
+ const department = summary["department-name"]?.trim();
155
+
156
+ const address = summary.organization?.address;
157
+ const locationParts = [address?.city, address?.region, address?.country].filter(Boolean) as string[];
158
+ const location = locationParts.length > 0 ? locationParts.join(", ") : null;
159
+
160
+ const start = formatDate(summary["start-date"]);
161
+ const end = formatDate(summary["end-date"]);
162
+ let dates: string | null = null;
163
+ if (start && end) {
164
+ dates = `${start} - ${end}`;
165
+ } else if (start) {
166
+ dates = `${start} - Present`;
167
+ } else if (end) {
168
+ dates = `Until ${end}`;
169
+ }
170
+
171
+ const label = organization || role || department;
172
+ if (!label) return null;
173
+
174
+ const details: string[] = [];
175
+ if (organization && role) details.push(role);
176
+ if (!organization && role && department) details.push(department);
177
+ if (organization && department) details.push(`Dept: ${department}`);
178
+ if (location) details.push(`Location: ${location}`);
179
+ if (dates) details.push(`Dates: ${dates}`);
180
+
181
+ if (details.length === 0) return label;
182
+ return `${label} (${details.join("; ")})`;
183
+ }
184
+
185
+ function collectWorkTitles(container: OrcidWorksContainer | undefined): string[] {
186
+ const titles: string[] = [];
187
+ const seen = new Set<string>();
188
+ const groups = container?.group || [];
189
+
190
+ for (const group of groups) {
191
+ const summaries = group["work-summary"] || [];
192
+ for (const summary of summaries) {
193
+ const title = summary.title?.title?.value?.trim();
194
+ if (!title || seen.has(title)) continue;
195
+ seen.add(title);
196
+ titles.push(title);
197
+ if (titles.length >= MAX_WORKS) return titles;
198
+ }
199
+ }
200
+
201
+ return titles;
202
+ }
203
+
204
+ export const handleOrcid: SpecialHandler = async (
205
+ url: string,
206
+ timeout: number,
207
+ signal?: AbortSignal,
208
+ ): Promise<RenderResult | null> => {
209
+ try {
210
+ const parsed = new URL(url);
211
+ if (!isOrcidHost(parsed.hostname)) return null;
212
+
213
+ const orcid = extractOrcidId(parsed.pathname);
214
+ if (!orcid) return null;
215
+
216
+ const fetchedAt = new Date().toISOString();
217
+ const apiUrl = `https://pub.orcid.org/v3.0/${orcid}/record`;
218
+
219
+ const result = await loadPage(apiUrl, {
220
+ timeout,
221
+ headers: { Accept: "application/json" },
222
+ signal,
223
+ });
224
+
225
+ if (!result.ok || !result.content) return null;
226
+
227
+ let record: OrcidRecord;
228
+ try {
229
+ record = JSON.parse(result.content) as OrcidRecord;
230
+ } catch {
231
+ return null;
232
+ }
233
+
234
+ const personName = formatName(record.person?.name);
235
+ const biography = record.person?.biography?.content?.trim();
236
+
237
+ const activities = record["activities-summary"];
238
+ const employments = collectAffiliations(activities?.employments, "employment-summary");
239
+ const educations = collectAffiliations(activities?.educations, "education-summary");
240
+ const works = collectWorkTitles(activities?.works);
241
+
242
+ let md = `# ${personName || "ORCID Profile"}\n\n`;
243
+ md += `**ORCID:** ${orcid}\n`;
244
+ md += `**ORCID Profile:** https://orcid.org/${orcid}\n\n`;
245
+
246
+ md += "## Biography\n\n";
247
+ md += biography ? `${biography}\n\n` : "No biography available.\n\n";
248
+
249
+ md += "## Affiliations\n\n";
250
+ let hasAffiliations = false;
251
+
252
+ if (employments.length > 0) {
253
+ hasAffiliations = true;
254
+ md += "### Employment\n\n";
255
+ for (const summary of employments) {
256
+ const line = formatAffiliation(summary);
257
+ if (line) md += `- ${line}\n`;
258
+ }
259
+ md += "\n";
260
+ }
261
+
262
+ if (educations.length > 0) {
263
+ hasAffiliations = true;
264
+ md += "### Education\n\n";
265
+ for (const summary of educations) {
266
+ const line = formatAffiliation(summary);
267
+ if (line) md += `- ${line}\n`;
268
+ }
269
+ md += "\n";
270
+ }
271
+
272
+ if (!hasAffiliations) {
273
+ md += "No affiliations available.\n\n";
274
+ }
275
+
276
+ md += "## Works\n\n";
277
+ if (works.length > 0) {
278
+ for (const title of works) {
279
+ md += `- ${title}\n`;
280
+ }
281
+ } else {
282
+ md += "No works available.\n";
283
+ }
284
+
285
+ const output = finalizeOutput(md);
286
+ return {
287
+ url,
288
+ finalUrl: url,
289
+ contentType: "text/markdown",
290
+ method: "orcid-api",
291
+ content: output.content,
292
+ fetchedAt,
293
+ truncated: output.truncated,
294
+ notes: ["Fetched via ORCID Public API"],
295
+ };
296
+ } catch {
297
+ return null;
298
+ }
299
+ };
@@ -47,7 +47,11 @@ interface OsvVulnerability {
47
47
  /**
48
48
  * Handle OSV (Open Source Vulnerabilities) URLs
49
49
  */
50
- export const handleOsv: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
50
+ export const handleOsv: SpecialHandler = async (
51
+ url: string,
52
+ timeout: number,
53
+ signal?: AbortSignal,
54
+ ): Promise<RenderResult | null> => {
51
55
  try {
52
56
  const parsed = new URL(url);
53
57
  if (parsed.hostname !== "osv.dev") return null;
@@ -64,6 +68,7 @@ export const handleOsv: SpecialHandler = async (url: string, timeout: number): P
64
68
  const result = await loadPage(apiUrl, {
65
69
  timeout,
66
70
  headers: { Accept: "application/json" },
71
+ signal,
67
72
  });
68
73
 
69
74
  if (!result.ok) return null;
@@ -4,7 +4,11 @@ import { finalizeOutput, formatCount, loadPage } from "./types";
4
4
  /**
5
5
  * Handle Packagist URLs via JSON API
6
6
  */
7
- export const handlePackagist: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
7
+ export const handlePackagist: SpecialHandler = async (
8
+ url: string,
9
+ timeout: number,
10
+ signal?: AbortSignal,
11
+ ): Promise<RenderResult | null> => {
8
12
  try {
9
13
  const parsed = new URL(url);
10
14
  if (parsed.hostname !== "packagist.org" && parsed.hostname !== "www.packagist.org") return null;
@@ -19,7 +23,7 @@ export const handlePackagist: SpecialHandler = async (url: string, timeout: numb
19
23
 
20
24
  // Fetch from Packagist JSON API
21
25
  const apiUrl = `https://packagist.org/packages/${vendor}/${packageName}.json`;
22
- const result = await loadPage(apiUrl, { timeout });
26
+ const result = await loadPage(apiUrl, { timeout, signal });
23
27
 
24
28
  if (!result.ok) return null;
25
29
 
@@ -3,7 +3,7 @@ import { finalizeOutput, formatCount, loadPage, type SpecialHandler } from "./ty
3
3
  /**
4
4
  * Handle pub.dev URLs via API
5
5
  */
6
- export const handlePubDev: SpecialHandler = async (url: string, timeout: number) => {
6
+ export const handlePubDev: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => {
7
7
  try {
8
8
  const parsed = new URL(url);
9
9
  if (parsed.hostname !== "pub.dev" && parsed.hostname !== "www.pub.dev") return null;
@@ -17,7 +17,7 @@ export const handlePubDev: SpecialHandler = async (url: string, timeout: number)
17
17
 
18
18
  // Fetch from pub.dev API
19
19
  const apiUrl = `https://pub.dev/api/packages/${encodeURIComponent(packageName)}`;
20
- const result = await loadPage(apiUrl, { timeout });
20
+ const result = await loadPage(apiUrl, { timeout, signal });
21
21
 
22
22
  if (!result.ok) return null;
23
23
 
@@ -122,7 +122,7 @@ export const handlePubDev: SpecialHandler = async (url: string, timeout: number)
122
122
  // Try to fetch README from pub.dev
123
123
  const readmeUrl = `https://pub.dev/packages/${encodeURIComponent(packageName)}/versions/${encodeURIComponent(latest.version)}/readme`;
124
124
  try {
125
- const readmeResult = await loadPage(readmeUrl, { timeout: Math.min(timeout, 10) });
125
+ const readmeResult = await loadPage(readmeUrl, { timeout: Math.min(timeout, 10), signal });
126
126
  if (readmeResult.ok) {
127
127
  // Extract README content from HTML
128
128
  const readmeMatch = readmeResult.content.match(
@@ -8,7 +8,11 @@ import { finalizeOutput, loadPage } from "./types";
8
8
  /**
9
9
  * Handle PubMed URLs - fetch article metadata, abstract, MeSH terms
10
10
  */
11
- export const handlePubMed: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
11
+ export const handlePubMed: SpecialHandler = async (
12
+ url: string,
13
+ timeout: number,
14
+ signal?: AbortSignal,
15
+ ): Promise<RenderResult | null> => {
12
16
  try {
13
17
  const parsed = new URL(url);
14
18
 
@@ -39,7 +43,7 @@ export const handlePubMed: SpecialHandler = async (url: string, timeout: number)
39
43
 
40
44
  // Fetch summary metadata
41
45
  const summaryUrl = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&id=${pmid}&retmode=json`;
42
- const summaryResult = await loadPage(summaryUrl, { timeout });
46
+ const summaryResult = await loadPage(summaryUrl, { timeout, signal });
43
47
 
44
48
  if (!summaryResult.ok) return null;
45
49
 
@@ -70,7 +74,7 @@ export const handlePubMed: SpecialHandler = async (url: string, timeout: number)
70
74
 
71
75
  // Fetch abstract
72
76
  const abstractUrl = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=pubmed&id=${pmid}&rettype=abstract&retmode=text`;
73
- const abstractResult = await loadPage(abstractUrl, { timeout });
77
+ const abstractResult = await loadPage(abstractUrl, { timeout, signal });
74
78
 
75
79
  let abstractText = "";
76
80
  if (abstractResult.ok) {
@@ -133,7 +137,7 @@ export const handlePubMed: SpecialHandler = async (url: string, timeout: number)
133
137
  // Try to fetch MeSH terms
134
138
  try {
135
139
  const meshUrl = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=pubmed&id=${pmid}&rettype=medline&retmode=text`;
136
- const meshResult = await loadPage(meshUrl, { timeout: Math.min(timeout, 5) });
140
+ const meshResult = await loadPage(meshUrl, { timeout: Math.min(timeout, 5), signal });
137
141
 
138
142
  if (meshResult.ok) {
139
143
  const meshTerms: string[] = [];
@@ -4,7 +4,11 @@ import { finalizeOutput, formatCount, loadPage } from "./types";
4
4
  /**
5
5
  * Handle PyPI URLs via JSON API
6
6
  */
7
- export const handlePyPI: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
7
+ export const handlePyPI: SpecialHandler = async (
8
+ url: string,
9
+ timeout: number,
10
+ signal?: AbortSignal,
11
+ ): Promise<RenderResult | null> => {
8
12
  try {
9
13
  const parsed = new URL(url);
10
14
  if (parsed.hostname !== "pypi.org" && parsed.hostname !== "www.pypi.org") return null;
@@ -22,8 +26,8 @@ export const handlePyPI: SpecialHandler = async (url: string, timeout: number):
22
26
 
23
27
  // Fetch package info and download stats in parallel
24
28
  const [result, downloadsResult] = await Promise.all([
25
- loadPage(apiUrl, { timeout }),
26
- loadPage(downloadsUrl, { timeout: Math.min(timeout, 5) }),
29
+ loadPage(apiUrl, { timeout, signal }),
30
+ loadPage(downloadsUrl, { timeout: Math.min(timeout, 5), signal }),
27
31
  ]);
28
32
 
29
33
  if (!result.ok) return null;
@@ -0,0 +1,124 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ interface RawgPlatformEntry {
5
+ platform?: {
6
+ name?: string;
7
+ };
8
+ }
9
+
10
+ interface RawgGenreEntry {
11
+ name?: string;
12
+ }
13
+
14
+ interface RawgGameResponse {
15
+ name?: string;
16
+ released?: string;
17
+ rating?: number;
18
+ platforms?: RawgPlatformEntry[];
19
+ genres?: RawgGenreEntry[];
20
+ description?: string;
21
+ description_raw?: string;
22
+ detail?: string;
23
+ error?: string;
24
+ }
25
+
26
+ export const handleRawg: SpecialHandler = async (
27
+ url: string,
28
+ timeout: number,
29
+ signal?: AbortSignal,
30
+ ): Promise<RenderResult | null> => {
31
+ try {
32
+ const parsed = new URL(url);
33
+ if (!isRawgHostname(parsed.hostname)) return null;
34
+
35
+ const slug = extractGameSlug(parsed.pathname);
36
+ if (!slug) return null;
37
+
38
+ const fetchedAt = new Date().toISOString();
39
+ const apiUrl = `https://api.rawg.io/api/games/${encodeURIComponent(slug)}`;
40
+ const result = await loadPage(apiUrl, { timeout, signal, headers: { Accept: "application/json" } });
41
+
42
+ if (!result.ok) return null;
43
+
44
+ let game: RawgGameResponse;
45
+ try {
46
+ game = JSON.parse(result.content);
47
+ } catch {
48
+ return null;
49
+ }
50
+
51
+ if (requiresApiKey(game)) return null;
52
+
53
+ const title = game.name?.trim() || slug;
54
+ let md = `# ${title}\n\n`;
55
+
56
+ if (game.released) md += `**Released:** ${game.released}\n`;
57
+ if (typeof game.rating === "number" && !Number.isNaN(game.rating)) {
58
+ md += `**Rating:** ${game.rating.toFixed(2)} / 5\n`;
59
+ }
60
+
61
+ const platforms = collectNames(game.platforms?.map((entry) => entry.platform?.name));
62
+ if (platforms.length) md += `**Platforms:** ${platforms.join(", ")}\n`;
63
+
64
+ const genres = collectNames(game.genres?.map((entry) => entry.name));
65
+ if (genres.length) md += `**Genres:** ${genres.join(", ")}\n`;
66
+
67
+ md += `**RAWG:** https://rawg.io/games/${encodeURIComponent(slug)}\n`;
68
+ md += "\n";
69
+
70
+ const description = extractDescription(game);
71
+ if (description) {
72
+ md += `## Description\n\n${description}\n`;
73
+ }
74
+
75
+ const output = finalizeOutput(md);
76
+ return {
77
+ url,
78
+ finalUrl: url,
79
+ contentType: "text/markdown",
80
+ method: "rawg",
81
+ content: output.content,
82
+ fetchedAt,
83
+ truncated: output.truncated,
84
+ notes: ["Fetched via RAWG API"],
85
+ };
86
+ } catch {}
87
+
88
+ return null;
89
+ };
90
+
91
+ function isRawgHostname(hostname: string): boolean {
92
+ return hostname === "rawg.io" || hostname === "www.rawg.io";
93
+ }
94
+
95
+ function extractGameSlug(pathname: string): string | null {
96
+ const match = pathname.match(/^\/games\/([^/?#]+)/);
97
+ if (!match) return null;
98
+
99
+ const slug = decodeURIComponent(match[1]);
100
+ return slug ? slug.trim() : null;
101
+ }
102
+
103
+ function requiresApiKey(game: RawgGameResponse): boolean {
104
+ const detail = `${game.detail ?? ""} ${game.error ?? ""}`.toLowerCase();
105
+ return detail.includes("api key") || detail.includes("key is required") || detail.includes("apikey");
106
+ }
107
+
108
+ function extractDescription(game: RawgGameResponse): string | null {
109
+ if (game.description_raw) return game.description_raw.trim();
110
+ if (!game.description) return null;
111
+
112
+ const markdown = htmlToBasicMarkdown(game.description).trim();
113
+ return markdown || null;
114
+ }
115
+
116
+ function collectNames(values?: Array<string | undefined>): string[] {
117
+ if (!values?.length) return [];
118
+ const names = new Set<string>();
119
+ for (const value of values) {
120
+ const trimmed = value?.trim();
121
+ if (trimmed) names.add(trimmed);
122
+ }
123
+ return Array.from(names);
124
+ }
@@ -6,7 +6,11 @@ import { parse as parseHtml } from "node-html-parser";
6
6
  import type { RenderResult, SpecialHandler } from "./types";
7
7
  import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
8
8
 
9
- export const handleReadTheDocs: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
9
+ export const handleReadTheDocs: SpecialHandler = async (
10
+ url: string,
11
+ timeout: number,
12
+ signal?: AbortSignal,
13
+ ): Promise<RenderResult | null> => {
10
14
  // Check if URL matches Read the Docs patterns
11
15
  const urlObj = new URL(url);
12
16
  const isReadTheDocs =
@@ -22,7 +26,7 @@ export const handleReadTheDocs: SpecialHandler = async (url: string, timeout: nu
22
26
  const fetchedAt = new Date().toISOString();
23
27
 
24
28
  // Fetch the page
25
- const result = await loadPage(url, { timeout });
29
+ const result = await loadPage(url, { timeout, signal });
26
30
  if (!result.ok) {
27
31
  return {
28
32
  url,
@@ -86,7 +90,7 @@ export const handleReadTheDocs: SpecialHandler = async (url: string, timeout: nu
86
90
  // Try to fetch raw source if available
87
91
  if (sourceUrl) {
88
92
  try {
89
- const sourceResult = await loadPage(sourceUrl, { timeout: Math.min(timeout, 10) });
93
+ const sourceResult = await loadPage(sourceUrl, { timeout: Math.min(timeout, 10), signal });
90
94
  if (sourceResult.ok && sourceResult.content.length > 0 && sourceResult.content.length < 1_000_000) {
91
95
  content = sourceResult.content;
92
96
  notes.push(`Fetched raw source from ${sourceUrl}`);
@@ -24,7 +24,11 @@ interface RedditComment {
24
24
  /**
25
25
  * Handle Reddit URLs via JSON API
26
26
  */
27
- export const handleReddit: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
27
+ export const handleReddit: SpecialHandler = async (
28
+ url: string,
29
+ timeout: number,
30
+ signal?: AbortSignal,
31
+ ): Promise<RenderResult | null> => {
28
32
  try {
29
33
  const parsed = new URL(url);
30
34
  if (!parsed.hostname.includes("reddit.com")) return null;
@@ -37,7 +41,7 @@ export const handleReddit: SpecialHandler = async (url: string, timeout: number)
37
41
  jsonUrl = `${url.replace(/\/$/, "").replace(parsed.search, "")}.json${parsed.search}`;
38
42
  }
39
43
 
40
- const result = await loadPage(jsonUrl, { timeout });
44
+ const result = await loadPage(jsonUrl, { timeout, signal });
41
45
  if (!result.ok) return null;
42
46
 
43
47
  const data = JSON.parse(result.content);
@@ -102,7 +102,11 @@ function prettifyRepo(repo: string): string {
102
102
  /**
103
103
  * Handle Repology URLs via API
104
104
  */
105
- export const handleRepology: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
105
+ export const handleRepology: SpecialHandler = async (
106
+ url: string,
107
+ timeout: number,
108
+ signal?: AbortSignal,
109
+ ): Promise<RenderResult | null> => {
106
110
  try {
107
111
  const parsed = new URL(url);
108
112
  if (parsed.hostname !== "repology.org" && parsed.hostname !== "www.repology.org") return null;
@@ -119,6 +123,7 @@ export const handleRepology: SpecialHandler = async (url: string, timeout: numbe
119
123
  const result = await loadPage(apiUrl, {
120
124
  timeout,
121
125
  headers: { Accept: "application/json" },
126
+ signal,
122
127
  });
123
128
 
124
129
  if (!result.ok) return null;
@@ -90,7 +90,11 @@ function cleanRfcText(text: string): string {
90
90
  /**
91
91
  * Handle RFC Editor URLs - fetches IETF RFCs
92
92
  */
93
- export const handleRfc: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
93
+ export const handleRfc: SpecialHandler = async (
94
+ url: string,
95
+ timeout: number,
96
+ signal?: AbortSignal,
97
+ ): Promise<RenderResult | null> => {
94
98
  try {
95
99
  const parsed = new URL(url);
96
100
  const rfcNumber = extractRfcNumber(parsed);
@@ -105,8 +109,8 @@ export const handleRfc: SpecialHandler = async (url: string, timeout: number): P
105
109
  const textUrl = `https://www.rfc-editor.org/rfc/rfc${rfcNumber}.txt`;
106
110
 
107
111
  const [metaResult, textResult] = await Promise.all([
108
- loadPage(metadataUrl, { timeout: Math.min(timeout, 10) }),
109
- loadPage(textUrl, { timeout }),
112
+ loadPage(metadataUrl, { timeout: Math.min(timeout, 10), signal }),
113
+ loadPage(textUrl, { timeout, signal }),
110
114
  ]);
111
115
 
112
116
  // We need at least the text content
@@ -30,7 +30,11 @@ interface RubyGemsResponse {
30
30
  /**
31
31
  * Handle RubyGems URLs via API
32
32
  */
33
- export const handleRubyGems: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
33
+ export const handleRubyGems: SpecialHandler = async (
34
+ url: string,
35
+ timeout: number,
36
+ signal?: AbortSignal,
37
+ ): Promise<RenderResult | null> => {
34
38
  try {
35
39
  const parsed = new URL(url);
36
40
  if (parsed.hostname !== "rubygems.org" && parsed.hostname !== "www.rubygems.org") return null;
@@ -46,6 +50,7 @@ export const handleRubyGems: SpecialHandler = async (url: string, timeout: numbe
46
50
  const apiUrl = `https://rubygems.org/api/v1/gems/${encodeURIComponent(gemName)}.json`;
47
51
  const result = await loadPage(apiUrl, {
48
52
  timeout,
53
+ signal,
49
54
  headers: { Accept: "application/json" },
50
55
  });
51
56