@oh-my-pi/pi-coding-agent 3.25.0 → 3.30.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 (85) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +4 -4
  3. package/src/core/tools/complete.ts +2 -4
  4. package/src/core/tools/jtd-to-json-schema.ts +174 -196
  5. package/src/core/tools/read.ts +4 -4
  6. package/src/core/tools/task/executor.ts +146 -20
  7. package/src/core/tools/task/name-generator.ts +1544 -214
  8. package/src/core/tools/task/types.ts +19 -5
  9. package/src/core/tools/task/worker.ts +103 -13
  10. package/src/core/tools/web-fetch-handlers/academic.test.ts +239 -0
  11. package/src/core/tools/web-fetch-handlers/artifacthub.ts +210 -0
  12. package/src/core/tools/web-fetch-handlers/arxiv.ts +84 -0
  13. package/src/core/tools/web-fetch-handlers/aur.ts +171 -0
  14. package/src/core/tools/web-fetch-handlers/biorxiv.ts +136 -0
  15. package/src/core/tools/web-fetch-handlers/bluesky.ts +277 -0
  16. package/src/core/tools/web-fetch-handlers/brew.ts +173 -0
  17. package/src/core/tools/web-fetch-handlers/business.test.ts +82 -0
  18. package/src/core/tools/web-fetch-handlers/cheatsh.ts +73 -0
  19. package/src/core/tools/web-fetch-handlers/chocolatey.ts +153 -0
  20. package/src/core/tools/web-fetch-handlers/coingecko.ts +179 -0
  21. package/src/core/tools/web-fetch-handlers/crates-io.ts +123 -0
  22. package/src/core/tools/web-fetch-handlers/dev-platforms.test.ts +254 -0
  23. package/src/core/tools/web-fetch-handlers/devto.ts +173 -0
  24. package/src/core/tools/web-fetch-handlers/discogs.ts +303 -0
  25. package/src/core/tools/web-fetch-handlers/dockerhub.ts +156 -0
  26. package/src/core/tools/web-fetch-handlers/documentation.test.ts +85 -0
  27. package/src/core/tools/web-fetch-handlers/finance-media.test.ts +144 -0
  28. package/src/core/tools/web-fetch-handlers/git-hosting.test.ts +272 -0
  29. package/src/core/tools/web-fetch-handlers/github-gist.ts +64 -0
  30. package/src/core/tools/web-fetch-handlers/github.ts +424 -0
  31. package/src/core/tools/web-fetch-handlers/gitlab.ts +444 -0
  32. package/src/core/tools/web-fetch-handlers/go-pkg.ts +271 -0
  33. package/src/core/tools/web-fetch-handlers/hackage.ts +89 -0
  34. package/src/core/tools/web-fetch-handlers/hackernews.ts +208 -0
  35. package/src/core/tools/web-fetch-handlers/hex.ts +121 -0
  36. package/src/core/tools/web-fetch-handlers/huggingface.ts +385 -0
  37. package/src/core/tools/web-fetch-handlers/iacr.ts +82 -0
  38. package/src/core/tools/web-fetch-handlers/index.ts +69 -0
  39. package/src/core/tools/web-fetch-handlers/lobsters.ts +186 -0
  40. package/src/core/tools/web-fetch-handlers/mastodon.ts +302 -0
  41. package/src/core/tools/web-fetch-handlers/maven.ts +147 -0
  42. package/src/core/tools/web-fetch-handlers/mdn.ts +174 -0
  43. package/src/core/tools/web-fetch-handlers/media.test.ts +138 -0
  44. package/src/core/tools/web-fetch-handlers/metacpan.ts +247 -0
  45. package/src/core/tools/web-fetch-handlers/npm.ts +107 -0
  46. package/src/core/tools/web-fetch-handlers/nuget.ts +201 -0
  47. package/src/core/tools/web-fetch-handlers/nvd.ts +238 -0
  48. package/src/core/tools/web-fetch-handlers/opencorporates.ts +273 -0
  49. package/src/core/tools/web-fetch-handlers/openlibrary.ts +313 -0
  50. package/src/core/tools/web-fetch-handlers/osv.ts +184 -0
  51. package/src/core/tools/web-fetch-handlers/package-managers-2.test.ts +199 -0
  52. package/src/core/tools/web-fetch-handlers/package-managers.test.ts +171 -0
  53. package/src/core/tools/web-fetch-handlers/package-registries.test.ts +259 -0
  54. package/src/core/tools/web-fetch-handlers/packagist.ts +170 -0
  55. package/src/core/tools/web-fetch-handlers/pub-dev.ts +185 -0
  56. package/src/core/tools/web-fetch-handlers/pubmed.ts +174 -0
  57. package/src/core/tools/web-fetch-handlers/pypi.ts +125 -0
  58. package/src/core/tools/web-fetch-handlers/readthedocs.ts +122 -0
  59. package/src/core/tools/web-fetch-handlers/reddit.ts +100 -0
  60. package/src/core/tools/web-fetch-handlers/repology.ts +257 -0
  61. package/src/core/tools/web-fetch-handlers/research.test.ts +107 -0
  62. package/src/core/tools/web-fetch-handlers/rfc.ts +205 -0
  63. package/src/core/tools/web-fetch-handlers/rubygems.ts +112 -0
  64. package/src/core/tools/web-fetch-handlers/sec-edgar.ts +269 -0
  65. package/src/core/tools/web-fetch-handlers/security.test.ts +103 -0
  66. package/src/core/tools/web-fetch-handlers/semantic-scholar.ts +190 -0
  67. package/src/core/tools/web-fetch-handlers/social-extended.test.ts +192 -0
  68. package/src/core/tools/web-fetch-handlers/social.test.ts +259 -0
  69. package/src/core/tools/web-fetch-handlers/spotify.ts +218 -0
  70. package/src/core/tools/web-fetch-handlers/stackexchange.test.ts +120 -0
  71. package/src/core/tools/web-fetch-handlers/stackoverflow.ts +123 -0
  72. package/src/core/tools/web-fetch-handlers/standards.test.ts +122 -0
  73. package/src/core/tools/web-fetch-handlers/terraform.ts +296 -0
  74. package/src/core/tools/web-fetch-handlers/tldr.ts +47 -0
  75. package/src/core/tools/web-fetch-handlers/twitter.ts +84 -0
  76. package/src/core/tools/web-fetch-handlers/types.ts +163 -0
  77. package/src/core/tools/web-fetch-handlers/utils.ts +91 -0
  78. package/src/core/tools/web-fetch-handlers/vimeo.ts +152 -0
  79. package/src/core/tools/web-fetch-handlers/wikidata.ts +349 -0
  80. package/src/core/tools/web-fetch-handlers/wikipedia.test.ts +73 -0
  81. package/src/core/tools/web-fetch-handlers/wikipedia.ts +91 -0
  82. package/src/core/tools/web-fetch-handlers/youtube.test.ts +198 -0
  83. package/src/core/tools/web-fetch-handlers/youtube.ts +319 -0
  84. package/src/core/tools/web-fetch.ts +152 -1324
  85. package/src/utils/tools-manager.ts +110 -8
@@ -0,0 +1,257 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface RepologyPackage {
5
+ repo: string;
6
+ subrepo?: string;
7
+ srcname?: string;
8
+ binname?: string;
9
+ visiblename?: string;
10
+ version: string;
11
+ origversion?: string;
12
+ status:
13
+ | "newest"
14
+ | "devel"
15
+ | "unique"
16
+ | "outdated"
17
+ | "legacy"
18
+ | "rolling"
19
+ | "noscheme"
20
+ | "incorrect"
21
+ | "untrusted"
22
+ | "ignored";
23
+ summary?: string;
24
+ categories?: string[];
25
+ licenses?: string[];
26
+ maintainers?: string[];
27
+ }
28
+
29
+ /**
30
+ * Get emoji indicator for version status
31
+ */
32
+ function statusIndicator(status: string): string {
33
+ switch (status) {
34
+ case "newest":
35
+ return "\u2705"; // green check
36
+ case "devel":
37
+ return "\uD83D\uDEA7"; // construction
38
+ case "unique":
39
+ return "\uD83D\uDD35"; // blue circle
40
+ case "outdated":
41
+ return "\uD83D\uDD34"; // red circle
42
+ case "legacy":
43
+ return "\u26A0\uFE0F"; // warning
44
+ case "rolling":
45
+ return "\uD83D\uDD04"; // arrows
46
+ default:
47
+ return "\u2796"; // minus
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Prettify repository name
53
+ */
54
+ function prettifyRepo(repo: string): string {
55
+ const mapping: Record<string, string> = {
56
+ arch: "Arch Linux",
57
+ aur: "AUR",
58
+ debian_unstable: "Debian Unstable",
59
+ debian_stable: "Debian Stable",
60
+ ubuntu_24_04: "Ubuntu 24.04",
61
+ ubuntu_22_04: "Ubuntu 22.04",
62
+ fedora_rawhide: "Fedora Rawhide",
63
+ fedora_40: "Fedora 40",
64
+ gentoo: "Gentoo",
65
+ nix_unstable: "Nixpkgs Unstable",
66
+ nix_stable: "Nixpkgs Stable",
67
+ homebrew: "Homebrew",
68
+ macports: "MacPorts",
69
+ alpine_edge: "Alpine Edge",
70
+ freebsd: "FreeBSD",
71
+ openbsd: "OpenBSD",
72
+ void_x86_64: "Void Linux",
73
+ opensuse_tumbleweed: "openSUSE Tumbleweed",
74
+ msys2_mingw: "MSYS2",
75
+ chocolatey: "Chocolatey",
76
+ winget: "Winget",
77
+ scoop: "Scoop",
78
+ conda_main: "Conda",
79
+ pypi: "PyPI",
80
+ crates_io: "Crates.io",
81
+ npm: "npm",
82
+ rubygems: "RubyGems",
83
+ cpan: "CPAN",
84
+ hackage: "Hackage",
85
+ };
86
+
87
+ // Check exact match first
88
+ if (mapping[repo]) return mapping[repo];
89
+
90
+ // Check partial matches
91
+ for (const [key, value] of Object.entries(mapping)) {
92
+ if (repo.startsWith(key)) return value;
93
+ }
94
+
95
+ // Fallback: titlecase with underscores replaced
96
+ return repo
97
+ .split("_")
98
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
99
+ .join(" ");
100
+ }
101
+
102
+ /**
103
+ * Handle Repology URLs via API
104
+ */
105
+ export const handleRepology: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
106
+ try {
107
+ const parsed = new URL(url);
108
+ if (parsed.hostname !== "repology.org" && parsed.hostname !== "www.repology.org") return null;
109
+
110
+ // Extract package name from /project/{name}/versions or /project/{name}/information
111
+ const match = parsed.pathname.match(/^\/project\/([^/]+)/);
112
+ if (!match) return null;
113
+
114
+ const packageName = decodeURIComponent(match[1]);
115
+ const fetchedAt = new Date().toISOString();
116
+
117
+ // Fetch from Repology API
118
+ const apiUrl = `https://repology.org/api/v1/project/${encodeURIComponent(packageName)}`;
119
+ const result = await loadPage(apiUrl, {
120
+ timeout,
121
+ headers: { Accept: "application/json" },
122
+ });
123
+
124
+ if (!result.ok) return null;
125
+
126
+ let packages: RepologyPackage[];
127
+ try {
128
+ packages = JSON.parse(result.content);
129
+ } catch {
130
+ return null;
131
+ }
132
+
133
+ // Empty response means package not found
134
+ if (!Array.isArray(packages) || packages.length === 0) return null;
135
+
136
+ // Find newest version(s) and extract metadata
137
+ const newestVersions = new Set<string>();
138
+ let summary: string | undefined;
139
+ let licenses: string[] = [];
140
+ const categories = new Set<string>();
141
+
142
+ for (const pkg of packages) {
143
+ if (pkg.status === "newest" || pkg.status === "unique") {
144
+ newestVersions.add(pkg.version);
145
+ }
146
+ if (!summary && pkg.summary) summary = pkg.summary;
147
+ if (pkg.licenses?.length && !licenses.length) licenses = pkg.licenses;
148
+ if (pkg.categories) {
149
+ for (const cat of pkg.categories) categories.add(cat);
150
+ }
151
+ }
152
+
153
+ // If no newest found, find the highest version
154
+ if (newestVersions.size === 0) {
155
+ const versions = packages.map((p) => p.version);
156
+ if (versions.length > 0) newestVersions.add(versions[0]);
157
+ }
158
+
159
+ // Group packages by status for counting
160
+ const statusCounts: Record<string, number> = {};
161
+ for (const pkg of packages) {
162
+ statusCounts[pkg.status] = (statusCounts[pkg.status] || 0) + 1;
163
+ }
164
+
165
+ // Build markdown
166
+ let md = `# ${packageName}\n\n`;
167
+ if (summary) md += `${summary}\n\n`;
168
+
169
+ md += `**Newest Version:** ${Array.from(newestVersions).join(", ") || "unknown"}\n`;
170
+ md += `**Repositories:** ${packages.length}\n`;
171
+ if (licenses.length) md += `**License:** ${licenses.join(", ")}\n`;
172
+ if (categories.size) md += `**Categories:** ${Array.from(categories).join(", ")}\n`;
173
+ md += "\n";
174
+
175
+ // Status summary
176
+ md += "## Version Status Summary\n\n";
177
+ const statusOrder = [
178
+ "newest",
179
+ "unique",
180
+ "devel",
181
+ "rolling",
182
+ "outdated",
183
+ "legacy",
184
+ "noscheme",
185
+ "incorrect",
186
+ "untrusted",
187
+ "ignored",
188
+ ];
189
+ for (const status of statusOrder) {
190
+ if (statusCounts[status]) {
191
+ md += `- ${statusIndicator(status)} **${status}**: ${statusCounts[status]} repos\n`;
192
+ }
193
+ }
194
+ md += "\n";
195
+
196
+ // Sort packages: newest first, then by repo name
197
+ const sortedPackages = [...packages].sort((a, b) => {
198
+ const statusPriority: Record<string, number> = {
199
+ newest: 0,
200
+ unique: 1,
201
+ devel: 2,
202
+ rolling: 3,
203
+ outdated: 4,
204
+ legacy: 5,
205
+ noscheme: 6,
206
+ incorrect: 7,
207
+ untrusted: 8,
208
+ ignored: 9,
209
+ };
210
+ const aPriority = statusPriority[a.status] ?? 10;
211
+ const bPriority = statusPriority[b.status] ?? 10;
212
+ if (aPriority !== bPriority) return aPriority - bPriority;
213
+ return a.repo.localeCompare(b.repo);
214
+ });
215
+
216
+ // Show top repositories (up to 15)
217
+ md += "## Package Versions by Repository\n\n";
218
+ md += "| Repository | Version | Status |\n";
219
+ md += "|------------|---------|--------|\n";
220
+
221
+ const shownRepos = new Set<string>();
222
+ let count = 0;
223
+ for (const pkg of sortedPackages) {
224
+ // Skip duplicate repos (some have multiple entries)
225
+ const repoKey = pkg.subrepo ? `${pkg.repo}/${pkg.subrepo}` : pkg.repo;
226
+ if (shownRepos.has(repoKey)) continue;
227
+ shownRepos.add(repoKey);
228
+
229
+ const repoName = prettifyRepo(pkg.repo);
230
+ const version = pkg.origversion || pkg.version;
231
+ md += `| ${repoName} | \`${version}\` | ${statusIndicator(pkg.status)} ${pkg.status} |\n`;
232
+
233
+ count++;
234
+ if (count >= 15) break;
235
+ }
236
+
237
+ if (packages.length > 15) {
238
+ md += `\n*...and ${packages.length - 15} more repositories*\n`;
239
+ }
240
+
241
+ md += `\n---\n\n[View on Repology](${url})\n`;
242
+
243
+ const output = finalizeOutput(md);
244
+ return {
245
+ url,
246
+ finalUrl: url,
247
+ contentType: "text/markdown",
248
+ method: "repology",
249
+ content: output.content,
250
+ fetchedAt,
251
+ truncated: output.truncated,
252
+ notes: ["Fetched via Repology API"],
253
+ };
254
+ } catch {}
255
+
256
+ return null;
257
+ };
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { handleBiorxiv } from "./biorxiv";
3
+ import { handleOpenLibrary } from "./openlibrary";
4
+ import { handleWikidata } from "./wikidata";
5
+
6
+ const SKIP = !process.env.WEB_FETCH_INTEGRATION;
7
+
8
+ describe.skipIf(SKIP)("handleWikidata", () => {
9
+ it("returns null for non-matching URLs", async () => {
10
+ const result = await handleWikidata("https://example.com", 20);
11
+ expect(result).toBeNull();
12
+ });
13
+
14
+ it("returns null for non-wikidata URLs", async () => {
15
+ const result = await handleWikidata("https://wikipedia.org/wiki/Apple_Inc", 20);
16
+ expect(result).toBeNull();
17
+ });
18
+
19
+ it("fetches Q312 - Apple Inc", async () => {
20
+ const result = await handleWikidata("https://www.wikidata.org/wiki/Q312", 20);
21
+ expect(result).not.toBeNull();
22
+ expect(result?.method).toBe("wikidata");
23
+ expect(result?.content).toContain("Apple");
24
+ expect(result?.content).toContain("Q312");
25
+ expect(result?.contentType).toBe("text/markdown");
26
+ expect(result?.fetchedAt).toBeTruthy();
27
+ expect(result?.truncated).toBeDefined();
28
+ });
29
+
30
+ it("fetches Q5 - human (entity)", async () => {
31
+ const result = await handleWikidata("https://www.wikidata.org/entity/Q5", 20);
32
+ expect(result).not.toBeNull();
33
+ expect(result?.method).toBe("wikidata");
34
+ expect(result?.content).toContain("human");
35
+ expect(result?.content).toContain("Q5");
36
+ expect(result?.contentType).toBe("text/markdown");
37
+ expect(result?.fetchedAt).toBeTruthy();
38
+ expect(result?.truncated).toBeDefined();
39
+ });
40
+ });
41
+
42
+ describe.skipIf(SKIP)("handleOpenLibrary", () => {
43
+ it("returns null for non-matching URLs", async () => {
44
+ const result = await handleOpenLibrary("https://example.com", 20);
45
+ expect(result).toBeNull();
46
+ });
47
+
48
+ it("returns null for non-openlibrary URLs", async () => {
49
+ const result = await handleOpenLibrary("https://amazon.com/books/123", 20);
50
+ expect(result).toBeNull();
51
+ });
52
+
53
+ it("fetches by ISBN - Fantastic Mr Fox", async () => {
54
+ const result = await handleOpenLibrary("https://openlibrary.org/isbn/9780140328721", 20);
55
+ expect(result).not.toBeNull();
56
+ expect(result?.method).toBe("openlibrary");
57
+ expect(result?.content).toContain("Fantastic Mr");
58
+ expect(result?.content).toContain("Roald Dahl");
59
+ expect(result?.contentType).toBe("text/markdown");
60
+ expect(result?.fetchedAt).toBeTruthy();
61
+ expect(result?.truncated).toBeDefined();
62
+ });
63
+
64
+ it("fetches work OL45804W - The Lord of the Rings", async () => {
65
+ const result = await handleOpenLibrary("https://openlibrary.org/works/OL45804W", 20);
66
+ expect(result).not.toBeNull();
67
+ expect(result?.method).toBe("openlibrary");
68
+ expect(result?.content).toContain("Lord of the Rings");
69
+ expect(result?.content).toContain("Tolkien");
70
+ expect(result?.contentType).toBe("text/markdown");
71
+ expect(result?.fetchedAt).toBeTruthy();
72
+ expect(result?.truncated).toBeDefined();
73
+ });
74
+ });
75
+
76
+ describe.skipIf(SKIP)("handleBiorxiv", () => {
77
+ it("returns null for non-matching URLs", async () => {
78
+ const result = await handleBiorxiv("https://example.com", 20);
79
+ expect(result).toBeNull();
80
+ });
81
+
82
+ it("returns null for non-biorxiv URLs", async () => {
83
+ const result = await handleBiorxiv("https://nature.com/articles/123", 20);
84
+ expect(result).toBeNull();
85
+ });
86
+
87
+ // Using the AlphaFold Protein Structure Database paper - highly cited and stable
88
+ it("fetches bioRxiv preprint - AlphaFold database", async () => {
89
+ const result = await handleBiorxiv("https://www.biorxiv.org/content/10.1101/2021.10.04.463034", 20);
90
+ expect(result).not.toBeNull();
91
+ expect(result?.method).toBe("biorxiv");
92
+ expect(result?.content).toContain("AlphaFold");
93
+ expect(result?.content).toContain("Abstract");
94
+ expect(result?.contentType).toBe("text/markdown");
95
+ expect(result?.fetchedAt).toBeTruthy();
96
+ expect(result?.truncated).toBeDefined();
97
+ });
98
+
99
+ // Testing with version suffix handling
100
+ it("fetches bioRxiv preprint with version suffix", async () => {
101
+ const result = await handleBiorxiv("https://www.biorxiv.org/content/10.1101/2021.10.04.463034v1", 20);
102
+ expect(result).not.toBeNull();
103
+ expect(result?.method).toBe("biorxiv");
104
+ expect(result?.content).toContain("AlphaFold");
105
+ expect(result?.contentType).toBe("text/markdown");
106
+ });
107
+ });
@@ -0,0 +1,205 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface RfcMetadata {
5
+ doc_id: string;
6
+ title: string;
7
+ authors?: Array<{ name: string; affiliation?: string }>;
8
+ pub_status?: string;
9
+ current_status?: string;
10
+ stream?: string;
11
+ area?: string;
12
+ wg_acronym?: string;
13
+ pub_date?: string;
14
+ page_count?: number;
15
+ abstract?: string;
16
+ keywords?: string[];
17
+ obsoletes?: string[];
18
+ obsoleted_by?: string[];
19
+ updates?: string[];
20
+ updated_by?: string[];
21
+ see_also?: string[];
22
+ errata_url?: string;
23
+ }
24
+
25
+ /**
26
+ * Extract RFC number from various URL patterns
27
+ */
28
+ function extractRfcNumber(url: URL): string | null {
29
+ const { hostname, pathname } = url;
30
+
31
+ // https://www.rfc-editor.org/rfc/rfc{number}
32
+ // https://www.rfc-editor.org/rfc/rfc{number}.html
33
+ // https://www.rfc-editor.org/rfc/rfc{number}.txt
34
+ if (hostname === "www.rfc-editor.org" || hostname === "rfc-editor.org") {
35
+ const match = pathname.match(/\/rfc\/rfc(\d+)(?:\.(?:html|txt|pdf))?$/i);
36
+ if (match) return match[1];
37
+ }
38
+
39
+ // https://datatracker.ietf.org/doc/rfc{number}/
40
+ // https://datatracker.ietf.org/doc/html/rfc{number}
41
+ if (hostname === "datatracker.ietf.org") {
42
+ const match = pathname.match(/\/doc\/(?:html\/)?rfc(\d+)\/?$/i);
43
+ if (match) return match[1];
44
+ }
45
+
46
+ // https://tools.ietf.org/html/rfc{number}
47
+ if (hostname === "tools.ietf.org") {
48
+ const match = pathname.match(/\/html\/rfc(\d+)$/i);
49
+ if (match) return match[1];
50
+ }
51
+
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Clean up RFC plain text - remove page headers/footers and extra formatting
57
+ */
58
+ function cleanRfcText(text: string): string {
59
+ const lines = text.split("\n");
60
+ const cleaned: string[] = [];
61
+ let skipNext = 0;
62
+
63
+ for (let i = 0; i < lines.length; i++) {
64
+ const line = lines[i];
65
+
66
+ // Skip lines we've marked to skip (form feeds and surrounding blank lines)
67
+ if (skipNext > 0) {
68
+ skipNext--;
69
+ continue;
70
+ }
71
+
72
+ // Skip form feed characters and page headers (RFC NNNN ... Month Year pattern)
73
+ if (line.includes("\f")) {
74
+ // Skip the form feed line and typically 2-3 following header lines
75
+ skipNext = 3;
76
+ continue;
77
+ }
78
+
79
+ // Skip page footer lines (typically just a page number or "[Page N]")
80
+ if (/^\s*\[Page \d+\]\s*$/.test(line)) {
81
+ continue;
82
+ }
83
+
84
+ cleaned.push(line);
85
+ }
86
+
87
+ return cleaned.join("\n").replace(/\n{4,}/g, "\n\n\n");
88
+ }
89
+
90
+ /**
91
+ * Handle RFC Editor URLs - fetches IETF RFCs
92
+ */
93
+ export const handleRfc: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
94
+ try {
95
+ const parsed = new URL(url);
96
+ const rfcNumber = extractRfcNumber(parsed);
97
+
98
+ if (!rfcNumber) return null;
99
+
100
+ const fetchedAt = new Date().toISOString();
101
+ const notes: string[] = [];
102
+
103
+ // Fetch metadata JSON and plain text in parallel
104
+ const metadataUrl = `https://www.rfc-editor.org/rfc/rfc${rfcNumber}.json`;
105
+ const textUrl = `https://www.rfc-editor.org/rfc/rfc${rfcNumber}.txt`;
106
+
107
+ const [metaResult, textResult] = await Promise.all([
108
+ loadPage(metadataUrl, { timeout: Math.min(timeout, 10) }),
109
+ loadPage(textUrl, { timeout }),
110
+ ]);
111
+
112
+ // We need at least the text content
113
+ if (!textResult.ok) return null;
114
+
115
+ let metadata: RfcMetadata | null = null;
116
+ if (metaResult.ok) {
117
+ try {
118
+ metadata = JSON.parse(metaResult.content);
119
+ notes.push("Metadata from RFC Editor JSON API");
120
+ } catch {
121
+ // JSON parse failed, continue without metadata
122
+ }
123
+ }
124
+
125
+ // Build markdown output
126
+ let md = "";
127
+
128
+ if (metadata) {
129
+ md += `# RFC ${rfcNumber}: ${metadata.title}\n\n`;
130
+
131
+ // Authors
132
+ if (metadata.authors?.length) {
133
+ const authorList = metadata.authors
134
+ .map((a) => (a.affiliation ? `${a.name} (${a.affiliation})` : a.name))
135
+ .join(", ");
136
+ md += `**Authors:** ${authorList}\n`;
137
+ }
138
+
139
+ // Publication info
140
+ if (metadata.pub_date) md += `**Published:** ${metadata.pub_date}\n`;
141
+ if (metadata.current_status) md += `**Status:** ${metadata.current_status}\n`;
142
+ if (metadata.stream) md += `**Stream:** ${metadata.stream}\n`;
143
+ if (metadata.area) md += `**Area:** ${metadata.area}\n`;
144
+ if (metadata.wg_acronym) md += `**Working Group:** ${metadata.wg_acronym}\n`;
145
+ if (metadata.page_count) md += `**Pages:** ${metadata.page_count}\n`;
146
+
147
+ // Related RFCs
148
+ if (metadata.obsoletes?.length) {
149
+ md += `**Obsoletes:** ${metadata.obsoletes.join(", ")}\n`;
150
+ }
151
+ if (metadata.obsoleted_by?.length) {
152
+ md += `**Obsoleted by:** ${metadata.obsoleted_by.join(", ")}\n`;
153
+ }
154
+ if (metadata.updates?.length) {
155
+ md += `**Updates:** ${metadata.updates.join(", ")}\n`;
156
+ }
157
+ if (metadata.updated_by?.length) {
158
+ md += `**Updated by:** ${metadata.updated_by.join(", ")}\n`;
159
+ }
160
+
161
+ // Keywords
162
+ if (metadata.keywords?.length) {
163
+ md += `**Keywords:** ${metadata.keywords.join(", ")}\n`;
164
+ }
165
+
166
+ // Errata
167
+ if (metadata.errata_url) {
168
+ md += `**Errata:** ${metadata.errata_url}\n`;
169
+ }
170
+
171
+ md += "\n";
172
+
173
+ // Abstract from metadata
174
+ if (metadata.abstract) {
175
+ md += `## Abstract\n\n${metadata.abstract}\n\n`;
176
+ }
177
+
178
+ md += "---\n\n";
179
+ } else {
180
+ // No metadata, use simple header
181
+ md += `# RFC ${rfcNumber}\n\n`;
182
+ notes.push("Metadata not available, showing plain text only");
183
+ }
184
+
185
+ // Add full text content
186
+ md += "## Full Text\n\n";
187
+ md += "```\n";
188
+ md += cleanRfcText(textResult.content);
189
+ md += "\n```\n";
190
+
191
+ const output = finalizeOutput(md);
192
+ return {
193
+ url,
194
+ finalUrl: `https://www.rfc-editor.org/rfc/rfc${rfcNumber}`,
195
+ contentType: "text/markdown",
196
+ method: "rfc",
197
+ content: output.content,
198
+ fetchedAt,
199
+ truncated: output.truncated,
200
+ notes: notes.length ? notes : ["Fetched from RFC Editor"],
201
+ };
202
+ } catch {}
203
+
204
+ return null;
205
+ };
@@ -0,0 +1,112 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface RubyGemsDependency {
5
+ name: string;
6
+ requirements: string;
7
+ }
8
+
9
+ interface RubyGemsResponse {
10
+ name: string;
11
+ version: string;
12
+ version_created_at?: string;
13
+ authors?: string;
14
+ info?: string;
15
+ licenses?: string[];
16
+ homepage_uri?: string;
17
+ source_code_uri?: string;
18
+ documentation_uri?: string;
19
+ project_uri?: string;
20
+ downloads: number;
21
+ version_downloads?: number;
22
+ gem_uri?: string;
23
+ dependencies?: {
24
+ development?: RubyGemsDependency[];
25
+ runtime?: RubyGemsDependency[];
26
+ };
27
+ metadata?: Record<string, string>;
28
+ }
29
+
30
+ /**
31
+ * Handle RubyGems URLs via API
32
+ */
33
+ export const handleRubyGems: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
34
+ try {
35
+ const parsed = new URL(url);
36
+ if (parsed.hostname !== "rubygems.org" && parsed.hostname !== "www.rubygems.org") return null;
37
+
38
+ // Extract gem name from /gems/{name}
39
+ const match = parsed.pathname.match(/^\/gems\/([^/]+)/);
40
+ if (!match) return null;
41
+
42
+ const gemName = decodeURIComponent(match[1]);
43
+ const fetchedAt = new Date().toISOString();
44
+
45
+ // Fetch from RubyGems API
46
+ const apiUrl = `https://rubygems.org/api/v1/gems/${encodeURIComponent(gemName)}.json`;
47
+ const result = await loadPage(apiUrl, {
48
+ timeout,
49
+ headers: { Accept: "application/json" },
50
+ });
51
+
52
+ if (!result.ok) return null;
53
+
54
+ let gem: RubyGemsResponse;
55
+ try {
56
+ gem = JSON.parse(result.content);
57
+ } catch {
58
+ return null;
59
+ }
60
+
61
+ let md = `# ${gem.name}\n\n`;
62
+ if (gem.info) md += `${gem.info}\n\n`;
63
+
64
+ // Version and license
65
+ md += `**Version:** ${gem.version}`;
66
+ if (gem.licenses?.length) md += ` · **License:** ${gem.licenses.join(", ")}`;
67
+ md += "\n";
68
+
69
+ // Downloads
70
+ md += `**Total Downloads:** ${formatCount(gem.downloads)}`;
71
+ if (gem.version_downloads) md += ` · **Version Downloads:** ${formatCount(gem.version_downloads)}`;
72
+ md += "\n\n";
73
+
74
+ // Links
75
+ if (gem.homepage_uri) md += `**Homepage:** ${gem.homepage_uri}\n`;
76
+ if (gem.source_code_uri) md += `**Source Code:** ${gem.source_code_uri}\n`;
77
+ if (gem.documentation_uri) md += `**Documentation:** ${gem.documentation_uri}\n`;
78
+ if (gem.authors) md += `**Authors:** ${gem.authors}\n`;
79
+
80
+ // Runtime dependencies
81
+ const runtimeDeps = gem.dependencies?.runtime;
82
+ if (runtimeDeps && runtimeDeps.length > 0) {
83
+ md += `\n## Runtime Dependencies\n\n`;
84
+ for (const dep of runtimeDeps) {
85
+ md += `- ${dep.name} ${dep.requirements}\n`;
86
+ }
87
+ }
88
+
89
+ // Development dependencies
90
+ const devDeps = gem.dependencies?.development;
91
+ if (devDeps && devDeps.length > 0) {
92
+ md += `\n## Development Dependencies\n\n`;
93
+ for (const dep of devDeps) {
94
+ md += `- ${dep.name} ${dep.requirements}\n`;
95
+ }
96
+ }
97
+
98
+ const output = finalizeOutput(md);
99
+ return {
100
+ url,
101
+ finalUrl: url,
102
+ contentType: "text/markdown",
103
+ method: "rubygems",
104
+ content: output.content,
105
+ fetchedAt,
106
+ truncated: output.truncated,
107
+ notes: ["Fetched via RubyGems API"],
108
+ };
109
+ } catch {}
110
+
111
+ return null;
112
+ };