@oh-my-pi/pi-coding-agent 3.25.0 → 3.31.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 (157) hide show
  1. package/CHANGELOG.md +90 -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 +369 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/session-manager.ts +158 -246
  9. package/src/core/session-storage.ts +379 -0
  10. package/src/core/settings-manager.ts +155 -4
  11. package/src/core/system-prompt.ts +62 -64
  12. package/src/core/tools/ask.ts +5 -4
  13. package/src/core/tools/bash-interceptor.ts +26 -61
  14. package/src/core/tools/bash.ts +13 -8
  15. package/src/core/tools/complete.ts +2 -4
  16. package/src/core/tools/edit-diff.ts +11 -4
  17. package/src/core/tools/edit.ts +7 -13
  18. package/src/core/tools/find.ts +111 -50
  19. package/src/core/tools/gemini-image.ts +128 -147
  20. package/src/core/tools/grep.ts +397 -415
  21. package/src/core/tools/index.test.ts +5 -1
  22. package/src/core/tools/index.ts +6 -8
  23. package/src/core/tools/jtd-to-json-schema.ts +174 -196
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +58 -9
  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 +55 -32
  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 +152 -76
  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/executor.ts +204 -67
  37. package/src/core/tools/task/index.ts +129 -92
  38. package/src/core/tools/task/name-generator.ts +1544 -214
  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 +34 -11
  42. package/src/core/tools/task/worker.ts +152 -27
  43. package/src/core/tools/web-fetch.ts +220 -1657
  44. package/src/core/tools/web-scrapers/academic.test.ts +239 -0
  45. package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
  46. package/src/core/tools/web-scrapers/arxiv.ts +88 -0
  47. package/src/core/tools/web-scrapers/aur.ts +175 -0
  48. package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
  49. package/src/core/tools/web-scrapers/bluesky.ts +284 -0
  50. package/src/core/tools/web-scrapers/brew.ts +177 -0
  51. package/src/core/tools/web-scrapers/business.test.ts +82 -0
  52. package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
  53. package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
  54. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  55. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  56. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  57. package/src/core/tools/web-scrapers/coingecko.ts +184 -0
  58. package/src/core/tools/web-scrapers/crates-io.ts +128 -0
  59. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  60. package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
  61. package/src/core/tools/web-scrapers/devto.ts +177 -0
  62. package/src/core/tools/web-scrapers/discogs.ts +308 -0
  63. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  64. package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
  65. package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
  66. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  67. package/src/core/tools/web-scrapers/finance-media.test.ts +144 -0
  68. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  69. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  70. package/src/core/tools/web-scrapers/git-hosting.test.ts +272 -0
  71. package/src/core/tools/web-scrapers/github-gist.ts +68 -0
  72. package/src/core/tools/web-scrapers/github.ts +455 -0
  73. package/src/core/tools/web-scrapers/gitlab.ts +456 -0
  74. package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
  75. package/src/core/tools/web-scrapers/hackage.ts +94 -0
  76. package/src/core/tools/web-scrapers/hackernews.ts +208 -0
  77. package/src/core/tools/web-scrapers/hex.ts +121 -0
  78. package/src/core/tools/web-scrapers/huggingface.ts +385 -0
  79. package/src/core/tools/web-scrapers/iacr.ts +86 -0
  80. package/src/core/tools/web-scrapers/index.ts +250 -0
  81. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  82. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  83. package/src/core/tools/web-scrapers/lobsters.ts +186 -0
  84. package/src/core/tools/web-scrapers/mastodon.ts +310 -0
  85. package/src/core/tools/web-scrapers/maven.ts +152 -0
  86. package/src/core/tools/web-scrapers/mdn.ts +174 -0
  87. package/src/core/tools/web-scrapers/media.test.ts +138 -0
  88. package/src/core/tools/web-scrapers/metacpan.ts +253 -0
  89. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  90. package/src/core/tools/web-scrapers/npm.ts +114 -0
  91. package/src/core/tools/web-scrapers/nuget.ts +205 -0
  92. package/src/core/tools/web-scrapers/nvd.ts +243 -0
  93. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  94. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  95. package/src/core/tools/web-scrapers/opencorporates.ts +275 -0
  96. package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
  97. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  98. package/src/core/tools/web-scrapers/osv.ts +189 -0
  99. package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
  100. package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
  101. package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
  102. package/src/core/tools/web-scrapers/packagist.ts +174 -0
  103. package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
  104. package/src/core/tools/web-scrapers/pubmed.ts +178 -0
  105. package/src/core/tools/web-scrapers/pypi.ts +129 -0
  106. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  107. package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
  108. package/src/core/tools/web-scrapers/reddit.ts +104 -0
  109. package/src/core/tools/web-scrapers/repology.ts +262 -0
  110. package/src/core/tools/web-scrapers/research.test.ts +107 -0
  111. package/src/core/tools/web-scrapers/rfc.ts +209 -0
  112. package/src/core/tools/web-scrapers/rubygems.ts +117 -0
  113. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  114. package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
  115. package/src/core/tools/web-scrapers/security.test.ts +103 -0
  116. package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
  117. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  118. package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
  119. package/src/core/tools/web-scrapers/social.test.ts +259 -0
  120. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  121. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  122. package/src/core/tools/web-scrapers/spotify.ts +218 -0
  123. package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
  124. package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
  125. package/src/core/tools/web-scrapers/standards.test.ts +122 -0
  126. package/src/core/tools/web-scrapers/terraform.ts +304 -0
  127. package/src/core/tools/web-scrapers/tldr.ts +51 -0
  128. package/src/core/tools/web-scrapers/twitter.ts +96 -0
  129. package/src/core/tools/web-scrapers/types.ts +234 -0
  130. package/src/core/tools/web-scrapers/utils.ts +162 -0
  131. package/src/core/tools/web-scrapers/vimeo.ts +152 -0
  132. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  133. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  134. package/src/core/tools/web-scrapers/wikidata.ts +357 -0
  135. package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
  136. package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
  137. package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
  138. package/src/core/tools/web-scrapers/youtube.ts +371 -0
  139. package/src/core/tools/write.ts +21 -18
  140. package/src/core/voice.ts +3 -2
  141. package/src/lib/worktree/collapse.ts +2 -1
  142. package/src/lib/worktree/git.ts +2 -18
  143. package/src/main.ts +59 -3
  144. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  145. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  146. package/src/modes/interactive/components/hook-editor.ts +2 -1
  147. package/src/modes/interactive/components/model-selector.ts +19 -4
  148. package/src/modes/interactive/interactive-mode.ts +41 -38
  149. package/src/modes/interactive/theme/theme.ts +58 -58
  150. package/src/modes/rpc/rpc-mode.ts +10 -9
  151. package/src/prompts/review-request.md +27 -0
  152. package/src/prompts/reviewer.md +64 -68
  153. package/src/prompts/tools/output.md +22 -3
  154. package/src/prompts/tools/task.md +32 -33
  155. package/src/utils/clipboard.ts +2 -1
  156. package/src/utils/tools-manager.ts +110 -8
  157. package/examples/extensions/subagent/agents/reviewer.md +0 -35
@@ -0,0 +1,185 @@
1
+ import { finalizeOutput, formatCount, loadPage, type SpecialHandler } from "./types";
2
+
3
+ /**
4
+ * Handle pub.dev URLs via API
5
+ */
6
+ export const handlePubDev: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => {
7
+ try {
8
+ const parsed = new URL(url);
9
+ if (parsed.hostname !== "pub.dev" && parsed.hostname !== "www.pub.dev") return null;
10
+
11
+ // Extract package name from /packages/{package}
12
+ const match = parsed.pathname.match(/^\/packages\/([^/]+)/);
13
+ if (!match) return null;
14
+
15
+ const packageName = decodeURIComponent(match[1]);
16
+ const fetchedAt = new Date().toISOString();
17
+
18
+ // Fetch from pub.dev API
19
+ const apiUrl = `https://pub.dev/api/packages/${encodeURIComponent(packageName)}`;
20
+ const result = await loadPage(apiUrl, { timeout, signal });
21
+
22
+ if (!result.ok) return null;
23
+
24
+ let data: {
25
+ name: string;
26
+ latest: {
27
+ version: string;
28
+ pubspec: {
29
+ description?: string;
30
+ homepage?: string;
31
+ repository?: string;
32
+ documentation?: string;
33
+ environment?: Record<string, string>;
34
+ dependencies?: Record<string, unknown>;
35
+ dev_dependencies?: Record<string, unknown>;
36
+ };
37
+ };
38
+ publisherId?: string;
39
+ metrics?: {
40
+ score?: {
41
+ likeCount?: number;
42
+ grantedPoints?: number;
43
+ maxPoints?: number;
44
+ popularityScore?: number;
45
+ };
46
+ };
47
+ };
48
+
49
+ try {
50
+ data = JSON.parse(result.content);
51
+ } catch {
52
+ return null;
53
+ }
54
+
55
+ const { name, latest, publisherId, metrics } = data;
56
+ const pubspec = latest.pubspec;
57
+
58
+ let md = `# ${name}\n\n`;
59
+ if (pubspec.description) md += `${pubspec.description}\n\n`;
60
+
61
+ md += `**Latest:** ${latest.version}`;
62
+ if (publisherId) md += ` · **Publisher:** ${publisherId}`;
63
+ md += "\n";
64
+
65
+ // Add metrics if available
66
+ const score = metrics?.score;
67
+ if (score) {
68
+ const likes = score.likeCount;
69
+ const points = score.grantedPoints;
70
+ const maxPoints = score.maxPoints;
71
+ const popularity = score.popularityScore;
72
+
73
+ if (likes !== undefined) md += `**Likes:** ${formatCount(likes)}`;
74
+ if (points !== undefined && maxPoints !== undefined) {
75
+ md += ` · **Pub Points:** ${points}/${maxPoints}`;
76
+ }
77
+ if (popularity !== undefined) {
78
+ md += ` · **Popularity:** ${Math.round(popularity * 100)}%`;
79
+ }
80
+ md += "\n";
81
+ }
82
+
83
+ md += "\n";
84
+
85
+ if (pubspec.homepage) md += `**Homepage:** ${pubspec.homepage}\n`;
86
+ if (pubspec.repository) md += `**Repository:** ${pubspec.repository}\n`;
87
+ if (pubspec.documentation) md += `**Documentation:** ${pubspec.documentation}\n`;
88
+
89
+ // SDK constraints
90
+ if (pubspec.environment) {
91
+ const constraints: string[] = [];
92
+ for (const [key, value] of Object.entries(pubspec.environment)) {
93
+ constraints.push(`${key}: ${value}`);
94
+ }
95
+ if (constraints.length > 0) {
96
+ md += `**SDK:** ${constraints.join(", ")}\n`;
97
+ }
98
+ }
99
+
100
+ md += "\n";
101
+
102
+ // Dependencies
103
+ if (pubspec.dependencies) {
104
+ const deps = Object.keys(pubspec.dependencies);
105
+ if (deps.length > 0) {
106
+ md += `## Dependencies (${deps.length})\n\n`;
107
+ for (const dep of deps.slice(0, 20)) {
108
+ const constraint = pubspec.dependencies[dep];
109
+ const constraintStr =
110
+ typeof constraint === "string" ? constraint : typeof constraint === "object" ? "complex" : "";
111
+ md += `- ${dep}`;
112
+ if (constraintStr) md += `: ${constraintStr}`;
113
+ md += "\n";
114
+ }
115
+ if (deps.length > 20) {
116
+ md += `\n*...and ${deps.length - 20} more*\n`;
117
+ }
118
+ md += "\n";
119
+ }
120
+ }
121
+
122
+ // Try to fetch README from pub.dev
123
+ const readmeUrl = `https://pub.dev/packages/${encodeURIComponent(packageName)}/versions/${encodeURIComponent(latest.version)}/readme`;
124
+ try {
125
+ const readmeResult = await loadPage(readmeUrl, { timeout: Math.min(timeout, 10), signal });
126
+ if (readmeResult.ok) {
127
+ // Extract README content from HTML
128
+ const readmeMatch = readmeResult.content.match(
129
+ /<div[^>]*class="[^"]*markdown-body[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
130
+ );
131
+ if (readmeMatch) {
132
+ // Basic HTML to markdown conversion for README
133
+ const readme = readmeMatch[1]
134
+ .replace(/<h(\d)[^>]*>(.*?)<\/h\d>/gi, (_, level, text) => {
135
+ const stripped = text.replace(/<[^>]+>/g, "");
136
+ return `${"#".repeat(parseInt(level, 10))} ${stripped}\n\n`;
137
+ })
138
+ .replace(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => {
139
+ const decoded = code
140
+ .replace(/&lt;/g, "<")
141
+ .replace(/&gt;/g, ">")
142
+ .replace(/&amp;/g, "&")
143
+ .replace(/&quot;/g, '"');
144
+ return `\n\`\`\`\n${decoded}\n\`\`\`\n\n`;
145
+ })
146
+ .replace(/<code[^>]*>(.*?)<\/code>/gi, "`$1`")
147
+ .replace(/<a[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)")
148
+ .replace(/<strong[^>]*>(.*?)<\/strong>/gi, "**$1**")
149
+ .replace(/<em[^>]*>(.*?)<\/em>/gi, "*$1*")
150
+ .replace(/<li[^>]*>(.*?)<\/li>/gi, "- $1\n")
151
+ .replace(/<\/?(ul|ol|p|br)[^>]*>/gi, "\n")
152
+ .replace(/<[^>]+>/g, "")
153
+ .replace(/&lt;/g, "<")
154
+ .replace(/&gt;/g, ">")
155
+ .replace(/&amp;/g, "&")
156
+ .replace(/&quot;/g, '"')
157
+ .replace(/&#39;/g, "'")
158
+ .replace(/&nbsp;/g, " ")
159
+ .replace(/\n{3,}/g, "\n\n")
160
+ .trim();
161
+
162
+ if (readme.length > 100) {
163
+ md += `## README\n\n${readme}\n`;
164
+ }
165
+ }
166
+ }
167
+ } catch {
168
+ // README fetch failed, continue without it
169
+ }
170
+
171
+ const output = finalizeOutput(md);
172
+ return {
173
+ url,
174
+ finalUrl: url,
175
+ contentType: "text/markdown",
176
+ method: "pub.dev",
177
+ content: output.content,
178
+ fetchedAt,
179
+ truncated: output.truncated,
180
+ notes: ["Fetched via pub.dev API"],
181
+ };
182
+ } catch {}
183
+
184
+ return null;
185
+ };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * PubMed handler for web-fetch
3
+ */
4
+
5
+ import type { RenderResult, SpecialHandler } from "./types";
6
+ import { finalizeOutput, loadPage } from "./types";
7
+
8
+ /**
9
+ * Handle PubMed URLs - fetch article metadata, abstract, MeSH terms
10
+ */
11
+ export const handlePubMed: SpecialHandler = async (
12
+ url: string,
13
+ timeout: number,
14
+ signal?: AbortSignal,
15
+ ): Promise<RenderResult | null> => {
16
+ try {
17
+ const parsed = new URL(url);
18
+
19
+ // Match pubmed.ncbi.nlm.nih.gov/{pmid} or ncbi.nlm.nih.gov/pubmed/{pmid}
20
+ if (
21
+ parsed.hostname !== "pubmed.ncbi.nlm.nih.gov" &&
22
+ !(parsed.hostname === "ncbi.nlm.nih.gov" && parsed.pathname.startsWith("/pubmed"))
23
+ ) {
24
+ return null;
25
+ }
26
+
27
+ // Extract PMID from URL
28
+ let pmid: string | null = null;
29
+ if (parsed.hostname === "pubmed.ncbi.nlm.nih.gov") {
30
+ // Format: pubmed.ncbi.nlm.nih.gov/12345678/
31
+ const match = parsed.pathname.match(/\/(\d+)/);
32
+ if (match) pmid = match[1];
33
+ } else {
34
+ // Format: ncbi.nlm.nih.gov/pubmed/12345678
35
+ const match = parsed.pathname.match(/\/pubmed\/(\d+)/);
36
+ if (match) pmid = match[1];
37
+ }
38
+
39
+ if (!pmid) return null;
40
+
41
+ const fetchedAt = new Date().toISOString();
42
+ const notes: string[] = [];
43
+
44
+ // Fetch summary metadata
45
+ const summaryUrl = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&id=${pmid}&retmode=json`;
46
+ const summaryResult = await loadPage(summaryUrl, { timeout, signal });
47
+
48
+ if (!summaryResult.ok) return null;
49
+
50
+ let summaryData: {
51
+ result?: {
52
+ [pmid: string]: {
53
+ title?: string;
54
+ authors?: Array<{ name: string }>;
55
+ fulljournalname?: string;
56
+ pubdate?: string;
57
+ volume?: string;
58
+ issue?: string;
59
+ pages?: string;
60
+ elocationid?: string; // DOI
61
+ articleids?: Array<{ idtype: string; value: string }>;
62
+ };
63
+ };
64
+ };
65
+
66
+ try {
67
+ summaryData = JSON.parse(summaryResult.content);
68
+ } catch {
69
+ return null;
70
+ }
71
+
72
+ const article = summaryData.result?.[pmid];
73
+ if (!article) return null;
74
+
75
+ // Fetch abstract
76
+ const abstractUrl = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=pubmed&id=${pmid}&rettype=abstract&retmode=text`;
77
+ const abstractResult = await loadPage(abstractUrl, { timeout, signal });
78
+
79
+ let abstractText = "";
80
+ if (abstractResult.ok) {
81
+ abstractText = abstractResult.content.trim();
82
+ notes.push("Fetched abstract via NCBI E-utilities");
83
+ }
84
+
85
+ // Extract DOI and PMCID
86
+ let doi = "";
87
+ let pmcid = "";
88
+ if (article.articleids) {
89
+ for (const id of article.articleids) {
90
+ if (id.idtype === "doi") doi = id.value;
91
+ if (id.idtype === "pmc") pmcid = id.value;
92
+ }
93
+ }
94
+ if (!doi && article.elocationid) {
95
+ doi = article.elocationid;
96
+ }
97
+
98
+ // Build markdown output
99
+ let md = `# ${article.title || "PubMed Article"}\n\n`;
100
+
101
+ // Authors
102
+ if (article.authors && article.authors.length > 0) {
103
+ const authorNames = article.authors.map((a) => a.name).join(", ");
104
+ md += `**Authors:** ${authorNames}\n`;
105
+ }
106
+
107
+ // Journal info
108
+ if (article.fulljournalname) {
109
+ md += `**Journal:** ${article.fulljournalname}`;
110
+ if (article.pubdate) md += ` (${article.pubdate})`;
111
+ md += "\n";
112
+ }
113
+
114
+ // Volume/Issue/Pages
115
+ const citation: string[] = [];
116
+ if (article.volume) citation.push(`Vol ${article.volume}`);
117
+ if (article.issue) citation.push(`Issue ${article.issue}`);
118
+ if (article.pages) citation.push(`pp ${article.pages}`);
119
+ if (citation.length > 0) {
120
+ md += `**Citation:** ${citation.join(", ")}\n`;
121
+ }
122
+
123
+ // IDs
124
+ md += `**PMID:** ${pmid}\n`;
125
+ if (doi) md += `**DOI:** ${doi}\n`;
126
+ if (pmcid) md += `**PMCID:** ${pmcid}\n`;
127
+
128
+ md += "\n---\n\n";
129
+
130
+ // Abstract section
131
+ if (abstractText) {
132
+ md += `## Abstract\n\n${abstractText}\n`;
133
+ } else {
134
+ md += `## Abstract\n\nNo abstract available.\n`;
135
+ }
136
+
137
+ // Try to fetch MeSH terms
138
+ try {
139
+ const meshUrl = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=pubmed&id=${pmid}&rettype=medline&retmode=text`;
140
+ const meshResult = await loadPage(meshUrl, { timeout: Math.min(timeout, 5), signal });
141
+
142
+ if (meshResult.ok) {
143
+ const meshTerms: string[] = [];
144
+ const lines = meshResult.content.split("\n");
145
+ for (const line of lines) {
146
+ if (line.startsWith("MH - ")) {
147
+ const term = line.slice(6).trim();
148
+ meshTerms.push(term);
149
+ }
150
+ }
151
+
152
+ if (meshTerms.length > 0) {
153
+ md += `\n## MeSH Terms\n\n`;
154
+ for (const term of meshTerms) {
155
+ md += `- ${term}\n`;
156
+ }
157
+ notes.push("Fetched MeSH terms via NCBI E-utilities");
158
+ }
159
+ }
160
+ } catch {
161
+ // MeSH terms are optional
162
+ }
163
+
164
+ const output = finalizeOutput(md);
165
+ return {
166
+ url,
167
+ finalUrl: url,
168
+ contentType: "text/markdown",
169
+ method: "pubmed",
170
+ content: output.content,
171
+ fetchedAt,
172
+ truncated: output.truncated,
173
+ notes: notes.length > 0 ? notes : ["Fetched via NCBI E-utilities"],
174
+ };
175
+ } catch {
176
+ return null;
177
+ }
178
+ };
@@ -0,0 +1,129 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ /**
5
+ * Handle PyPI URLs via JSON API
6
+ */
7
+ export const handlePyPI: SpecialHandler = async (
8
+ url: string,
9
+ timeout: number,
10
+ signal?: AbortSignal,
11
+ ): Promise<RenderResult | null> => {
12
+ try {
13
+ const parsed = new URL(url);
14
+ if (parsed.hostname !== "pypi.org" && parsed.hostname !== "www.pypi.org") return null;
15
+
16
+ // Extract package name from /project/{package} or /project/{package}/{version}
17
+ const match = parsed.pathname.match(/^\/project\/([^/]+)/);
18
+ if (!match) return null;
19
+
20
+ const packageName = decodeURIComponent(match[1]);
21
+ const fetchedAt = new Date().toISOString();
22
+
23
+ // Fetch from PyPI JSON API
24
+ const apiUrl = `https://pypi.org/pypi/${packageName}/json`;
25
+ const downloadsUrl = `https://pypistats.org/api/packages/${packageName}/recent`;
26
+
27
+ // Fetch package info and download stats in parallel
28
+ const [result, downloadsResult] = await Promise.all([
29
+ loadPage(apiUrl, { timeout, signal }),
30
+ loadPage(downloadsUrl, { timeout: Math.min(timeout, 5), signal }),
31
+ ]);
32
+
33
+ if (!result.ok) return null;
34
+
35
+ // Parse download stats
36
+ let weeklyDownloads: number | null = null;
37
+ if (downloadsResult.ok) {
38
+ try {
39
+ const dlData = JSON.parse(downloadsResult.content) as { data?: { last_week?: number } };
40
+ weeklyDownloads = dlData.data?.last_week ?? null;
41
+ } catch {}
42
+ }
43
+
44
+ let pkg: {
45
+ info: {
46
+ name: string;
47
+ version: string;
48
+ summary?: string;
49
+ description?: string;
50
+ author?: string;
51
+ author_email?: string;
52
+ license?: string;
53
+ home_page?: string;
54
+ project_urls?: Record<string, string>;
55
+ requires_python?: string;
56
+ keywords?: string;
57
+ classifiers?: string[];
58
+ };
59
+ urls?: Array<{ filename: string; size: number; upload_time: string }>;
60
+ releases?: Record<string, unknown>;
61
+ requires_dist?: string[];
62
+ };
63
+
64
+ try {
65
+ pkg = JSON.parse(result.content);
66
+ } catch {
67
+ return null; // JSON parse failed
68
+ }
69
+
70
+ const info = pkg.info;
71
+ let md = `# ${info.name}\n\n`;
72
+ if (info.summary) md += `${info.summary}\n\n`;
73
+
74
+ md += `**Latest:** ${info.version}`;
75
+ if (info.license) md += ` · **License:** ${info.license}`;
76
+ md += "\n";
77
+
78
+ if (weeklyDownloads !== null) {
79
+ md += `**Weekly Downloads:** ${formatCount(weeklyDownloads)}\n`;
80
+ }
81
+
82
+ md += "\n";
83
+
84
+ if (info.author) {
85
+ md += `**Author:** ${info.author}`;
86
+ if (info.author_email) md += ` <${info.author_email}>`;
87
+ md += "\n";
88
+ }
89
+
90
+ if (info.requires_python) md += `**Python:** ${info.requires_python}\n`;
91
+ if (info.home_page) md += `**Homepage:** ${info.home_page}\n`;
92
+
93
+ if (info.project_urls && Object.keys(info.project_urls).length > 0) {
94
+ md += "\n**Project URLs:**\n";
95
+ for (const [label, url] of Object.entries(info.project_urls)) {
96
+ md += `- ${label}: ${url}\n`;
97
+ }
98
+ }
99
+
100
+ if (info.keywords) md += `\n**Keywords:** ${info.keywords}\n`;
101
+
102
+ // Dependencies
103
+ if (pkg.requires_dist && pkg.requires_dist.length > 0) {
104
+ md += `\n## Dependencies\n\n`;
105
+ for (const dep of pkg.requires_dist) {
106
+ md += `- ${dep}\n`;
107
+ }
108
+ }
109
+
110
+ // README/Description
111
+ if (info.description) {
112
+ md += `\n---\n\n## Description\n\n${info.description}\n`;
113
+ }
114
+
115
+ const output = finalizeOutput(md);
116
+ return {
117
+ url,
118
+ finalUrl: url,
119
+ contentType: "text/markdown",
120
+ method: "pypi",
121
+ content: output.content,
122
+ fetchedAt,
123
+ truncated: output.truncated,
124
+ notes: ["Fetched via PyPI JSON API"],
125
+ };
126
+ } catch {}
127
+
128
+ return null;
129
+ };
@@ -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
+ }