@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,174 @@
1
+ import type { SpecialHandler } from "./types";
2
+ import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ interface MDNSection {
5
+ type: string;
6
+ value: {
7
+ id?: string;
8
+ title?: string;
9
+ content?: string;
10
+ isH3?: boolean;
11
+ code?: string;
12
+ language?: string;
13
+ items?: Array<{ term: string; description: string }>;
14
+ rows?: string[][];
15
+ };
16
+ }
17
+
18
+ interface MDNDoc {
19
+ doc: {
20
+ title: string;
21
+ summary: string;
22
+ mdn_url: string;
23
+ body: MDNSection[];
24
+ browserCompat?: unknown;
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Convert MDN body sections to markdown
30
+ */
31
+ function convertMDNBody(sections: MDNSection[]): string {
32
+ const parts: string[] = [];
33
+
34
+ for (const section of sections) {
35
+ const { type, value } = section;
36
+
37
+ switch (type) {
38
+ case "prose":
39
+ if (value.content) {
40
+ const markdown = htmlToBasicMarkdown(value.content);
41
+ if (value.title) {
42
+ const level = value.isH3 ? "###" : "##";
43
+ parts.push(`${level} ${value.title}\n\n${markdown}`);
44
+ } else {
45
+ parts.push(markdown);
46
+ }
47
+ }
48
+ break;
49
+
50
+ case "browser_compatibility":
51
+ if (value.title) {
52
+ parts.push(`## ${value.title}\n\n(See browser compatibility data at MDN)`);
53
+ }
54
+ break;
55
+
56
+ case "specifications":
57
+ if (value.title) {
58
+ parts.push(`## ${value.title}\n\n(See specifications at MDN)`);
59
+ }
60
+ break;
61
+
62
+ case "code_example":
63
+ if (value.title) {
64
+ parts.push(`### ${value.title}`);
65
+ }
66
+ if (value.code) {
67
+ const lang = value.language || "";
68
+ parts.push(`\`\`\`${lang}\n${value.code}\n\`\`\``);
69
+ }
70
+ break;
71
+
72
+ case "definition_list":
73
+ if (value.items) {
74
+ for (const item of value.items) {
75
+ parts.push(`**${item.term}**`);
76
+ const desc = htmlToBasicMarkdown(item.description);
77
+ parts.push(desc);
78
+ }
79
+ }
80
+ break;
81
+
82
+ case "table":
83
+ if (value.rows && value.rows.length > 0) {
84
+ // Simple markdown table
85
+ const header = value.rows[0].map((cell) => htmlToBasicMarkdown(cell)).join(" | ");
86
+ const separator = value.rows[0].map(() => "---").join(" | ");
87
+ const bodyRows = value.rows
88
+ .slice(1)
89
+ .map((row) => row.map((cell) => htmlToBasicMarkdown(cell)).join(" | "));
90
+
91
+ parts.push(`| ${header} |`);
92
+ parts.push(`| ${separator} |`);
93
+ for (const row of bodyRows) {
94
+ parts.push(`| ${row} |`);
95
+ }
96
+ }
97
+ break;
98
+
99
+ default:
100
+ // Skip unknown types
101
+ break;
102
+ }
103
+ }
104
+
105
+ return parts.join("\n\n");
106
+ }
107
+
108
+ export const handleMDN: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => {
109
+ const urlObj = new URL(url);
110
+
111
+ // Only handle developer.mozilla.org
112
+ if (!urlObj.hostname.includes("developer.mozilla.org")) {
113
+ return null;
114
+ }
115
+
116
+ // Only handle docs paths
117
+ if (!urlObj.pathname.includes("/docs/")) {
118
+ return null;
119
+ }
120
+
121
+ const notes: string[] = [];
122
+
123
+ // Construct JSON API URL
124
+ const jsonUrl = url.replace(/\/?$/, "/index.json");
125
+
126
+ try {
127
+ const result = await loadPage(jsonUrl, { timeout, signal, headers: { Accept: "application/json" } });
128
+
129
+ if (!result.ok) {
130
+ notes.push(`Failed to fetch MDN JSON API (status ${result.status || "unknown"})`);
131
+ return null;
132
+ }
133
+
134
+ const data: MDNDoc = JSON.parse(result.content);
135
+ const { doc } = data;
136
+
137
+ if (!doc || !doc.title) {
138
+ notes.push("Invalid MDN JSON structure");
139
+ return null;
140
+ }
141
+
142
+ // Build markdown content
143
+ const parts: string[] = [];
144
+
145
+ parts.push(`# ${doc.title}`);
146
+
147
+ if (doc.summary) {
148
+ const summary = htmlToBasicMarkdown(doc.summary);
149
+ parts.push(summary);
150
+ }
151
+
152
+ if (doc.body && doc.body.length > 0) {
153
+ const bodyMarkdown = convertMDNBody(doc.body);
154
+ parts.push(bodyMarkdown);
155
+ }
156
+
157
+ const rawContent = parts.join("\n\n");
158
+ const { content, truncated } = finalizeOutput(rawContent);
159
+
160
+ return {
161
+ url,
162
+ finalUrl: doc.mdn_url || result.finalUrl,
163
+ contentType: "text/markdown",
164
+ method: "mdn",
165
+ content,
166
+ fetchedAt: new Date().toISOString(),
167
+ truncated,
168
+ notes,
169
+ };
170
+ } catch (err) {
171
+ notes.push(`MDN handler error: ${err instanceof Error ? err.message : String(err)}`);
172
+ return null;
173
+ }
174
+ };
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { handleHuggingFace } from "./huggingface";
3
+ import { handleSpotify } from "./spotify";
4
+ import { handleVimeo } from "./vimeo";
5
+
6
+ const SKIP = !process.env.WEB_FETCH_INTEGRATION;
7
+
8
+ describe.skipIf(SKIP)("handleVimeo", () => {
9
+ it("returns null for non-Vimeo URLs", async () => {
10
+ const result = await handleVimeo("https://example.com", 10);
11
+ expect(result).toBeNull();
12
+ });
13
+
14
+ it("returns null for invalid Vimeo URLs", async () => {
15
+ const result = await handleVimeo("https://vimeo.com/invalid", 10);
16
+ expect(result).toBeNull();
17
+ });
18
+
19
+ it("fetches video metadata via oEmbed", async () => {
20
+ const result = await handleVimeo("https://vimeo.com/1084537", 20);
21
+ expect(result).not.toBeNull();
22
+ expect(result?.method).toBe("vimeo");
23
+ expect(result?.contentType).toBe("text/markdown");
24
+ expect(result?.content).toContain("Video ID");
25
+ expect(result?.notes).toContain("Fetched via Vimeo oEmbed API");
26
+ });
27
+
28
+ it("handles player.vimeo.com URLs", async () => {
29
+ const result = await handleVimeo("https://player.vimeo.com/video/1084537", 20);
30
+ expect(result).not.toBeNull();
31
+ expect(result?.method).toBe("vimeo");
32
+ expect(result?.content).toContain("Video ID");
33
+ });
34
+
35
+ it("handles vimeo.com/user/video format", async () => {
36
+ const result = await handleVimeo("https://vimeo.com/staffpicks/1084537", 20);
37
+ expect(result).not.toBeNull();
38
+ expect(result?.method).toBe("vimeo");
39
+ });
40
+ });
41
+
42
+ describe.skipIf(SKIP)("handleSpotify", () => {
43
+ it("returns null for non-Spotify URLs", async () => {
44
+ const result = await handleSpotify("https://example.com", 10);
45
+ expect(result).toBeNull();
46
+ });
47
+
48
+ it("returns null for invalid Spotify URLs", async () => {
49
+ const result = await handleSpotify("https://open.spotify.com/invalid/xyz", 10);
50
+ expect(result).toBeNull();
51
+ });
52
+
53
+ it("identifies track URLs", async () => {
54
+ const result = await handleSpotify("https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", 20);
55
+ expect(result).not.toBeNull();
56
+ expect(result?.method).toBe("spotify");
57
+ expect(result?.contentType).toBe("text/markdown");
58
+ expect(result?.content).toContain("Type");
59
+ expect(result?.content).toContain("track");
60
+ });
61
+
62
+ it("identifies album URLs", async () => {
63
+ const result = await handleSpotify("https://open.spotify.com/album/2ODvWsOgouMbaA5xf0RkJe", 20);
64
+ expect(result).not.toBeNull();
65
+ expect(result?.method).toBe("spotify");
66
+ expect(result?.content).toContain("album");
67
+ });
68
+
69
+ it("identifies playlist URLs", async () => {
70
+ const result = await handleSpotify("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", 20);
71
+ expect(result).not.toBeNull();
72
+ expect(result?.method).toBe("spotify");
73
+ expect(result?.content).toContain("playlist");
74
+ });
75
+
76
+ it("identifies podcast episode URLs", async () => {
77
+ const result = await handleSpotify("https://open.spotify.com/episode/0Q86acNRm6V9GYx55SXKwf", 20);
78
+ expect(result).not.toBeNull();
79
+ expect(result?.method).toBe("spotify");
80
+ expect(result?.content).toContain("podcast-episode");
81
+ });
82
+
83
+ it("identifies podcast show URLs", async () => {
84
+ const result = await handleSpotify("https://open.spotify.com/show/2MAi0BvDc6GTFvKFPXnkCL", 20);
85
+ expect(result).not.toBeNull();
86
+ expect(result?.method).toBe("spotify");
87
+ expect(result?.content).toContain("podcast-show");
88
+ });
89
+ });
90
+
91
+ describe.skipIf(SKIP)("handleHuggingFace", () => {
92
+ it("returns null for non-HF URLs", async () => {
93
+ const result = await handleHuggingFace("https://example.com", 10);
94
+ expect(result).toBeNull();
95
+ });
96
+
97
+ it("returns null for invalid HF URLs", async () => {
98
+ const result = await handleHuggingFace("https://huggingface.co", 10);
99
+ expect(result).toBeNull();
100
+ });
101
+
102
+ it("fetches model info", async () => {
103
+ const result = await handleHuggingFace("https://huggingface.co/bert-base-uncased", 20);
104
+ expect(result).not.toBeNull();
105
+ expect(result?.method).toBe("huggingface");
106
+ expect(result?.contentType).toBe("text/markdown");
107
+ expect(result?.content).toContain("bert-base-uncased");
108
+ });
109
+
110
+ it("fetches dataset info", async () => {
111
+ const result = await handleHuggingFace("https://huggingface.co/datasets/squad", 20);
112
+ expect(result).not.toBeNull();
113
+ expect(result?.method).toBe("huggingface");
114
+ expect(result?.content).toContain("squad");
115
+ });
116
+
117
+ it("fetches space info", async () => {
118
+ const result = await handleHuggingFace("https://huggingface.co/spaces/gradio/hello_world", 20);
119
+ expect(result).not.toBeNull();
120
+ expect(result?.method).toBe("huggingface");
121
+ expect(result?.content).toContain("gradio/hello_world");
122
+ });
123
+
124
+ it("fetches model without org prefix", async () => {
125
+ // Some models like bert-base-uncased don't have an org prefix
126
+ const result = await handleHuggingFace("https://huggingface.co/bert-base-uncased", 20);
127
+ expect(result).not.toBeNull();
128
+ expect(result?.method).toBe("huggingface");
129
+ expect(result?.content).toContain("bert-base-uncased");
130
+ });
131
+
132
+ it("handles org/model format", async () => {
133
+ const result = await handleHuggingFace("https://huggingface.co/google/bert_uncased_L-2_H-128_A-2", 20);
134
+ expect(result).not.toBeNull();
135
+ expect(result?.method).toBe("huggingface");
136
+ expect(result?.content).toContain("google/bert_uncased_L-2_H-128_A-2");
137
+ });
138
+ });
@@ -0,0 +1,253 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface ModuleResponse {
5
+ name: string;
6
+ version: string;
7
+ abstract?: string;
8
+ author: string;
9
+ distribution: string;
10
+ release: string;
11
+ path: string;
12
+ pod?: string;
13
+ }
14
+
15
+ interface ReleaseResponse {
16
+ name: string;
17
+ version: string;
18
+ abstract?: string;
19
+ author: string;
20
+ distribution: string;
21
+ license?: string[];
22
+ stat?: { mtime: number };
23
+ download_url?: string;
24
+ dependency?: Array<{
25
+ module: string;
26
+ version: string;
27
+ phase: string;
28
+ relationship: string;
29
+ }>;
30
+ metadata?: {
31
+ resources?: {
32
+ repository?: { url?: string; web?: string };
33
+ homepage?: string;
34
+ bugtracker?: { web?: string };
35
+ };
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Handle MetaCPAN URLs via fastapi.metacpan.org
41
+ */
42
+ export const handleMetaCPAN: SpecialHandler = async (
43
+ url: string,
44
+ timeout: number,
45
+ signal?: AbortSignal,
46
+ ): Promise<RenderResult | null> => {
47
+ try {
48
+ const parsed = new URL(url);
49
+ if (parsed.hostname !== "metacpan.org" && parsed.hostname !== "www.metacpan.org") return null;
50
+
51
+ const fetchedAt = new Date().toISOString();
52
+
53
+ // Match /pod/Module::Name pattern
54
+ const podMatch = parsed.pathname.match(/^\/pod\/(.+?)(?:\/|$)/);
55
+ if (podMatch) {
56
+ const moduleName = decodeURIComponent(podMatch[1]);
57
+ return await fetchModule(url, moduleName, timeout, fetchedAt, signal);
58
+ }
59
+
60
+ // Match /release/AUTHOR/Distribution pattern
61
+ const releaseMatch = parsed.pathname.match(/^\/release\/([^/]+)\/([^/]+)/);
62
+ if (releaseMatch) {
63
+ const distribution = decodeURIComponent(releaseMatch[2]);
64
+ return await fetchRelease(url, distribution, timeout, fetchedAt, signal);
65
+ }
66
+
67
+ // Match /release/Distribution pattern (without author)
68
+ const simpleReleaseMatch = parsed.pathname.match(/^\/release\/([^/]+)$/);
69
+ if (simpleReleaseMatch) {
70
+ const distribution = decodeURIComponent(simpleReleaseMatch[1]);
71
+ return await fetchRelease(url, distribution, timeout, fetchedAt, signal);
72
+ }
73
+
74
+ return null;
75
+ } catch {}
76
+
77
+ return null;
78
+ };
79
+
80
+ async function fetchModule(
81
+ url: string,
82
+ moduleName: string,
83
+ timeout: number,
84
+ fetchedAt: string,
85
+ signal?: AbortSignal,
86
+ ): Promise<RenderResult | null> {
87
+ const apiUrl = `https://fastapi.metacpan.org/v1/module/${moduleName}`;
88
+ const result = await loadPage(apiUrl, { timeout, signal });
89
+
90
+ if (!result.ok) return null;
91
+
92
+ let module: ModuleResponse;
93
+ try {
94
+ module = JSON.parse(result.content);
95
+ } catch {
96
+ return null;
97
+ }
98
+
99
+ // Fetch additional release info for dependencies and metadata
100
+ const releaseUrl = `https://fastapi.metacpan.org/v1/release/${module.distribution}`;
101
+ const releaseResult = await loadPage(releaseUrl, { timeout: Math.min(timeout, 5), signal });
102
+
103
+ let release: ReleaseResponse | null = null;
104
+ if (releaseResult.ok) {
105
+ try {
106
+ release = JSON.parse(releaseResult.content);
107
+ } catch {}
108
+ }
109
+
110
+ const md = formatModuleMarkdown(module, release);
111
+ const output = finalizeOutput(md);
112
+
113
+ return {
114
+ url,
115
+ finalUrl: url,
116
+ contentType: "text/markdown",
117
+ method: "metacpan",
118
+ content: output.content,
119
+ fetchedAt,
120
+ truncated: output.truncated,
121
+ notes: ["Fetched via MetaCPAN API"],
122
+ };
123
+ }
124
+
125
+ async function fetchRelease(
126
+ url: string,
127
+ distribution: string,
128
+ timeout: number,
129
+ fetchedAt: string,
130
+ signal?: AbortSignal,
131
+ ): Promise<RenderResult | null> {
132
+ const apiUrl = `https://fastapi.metacpan.org/v1/release/${distribution}`;
133
+ const result = await loadPage(apiUrl, { timeout, signal });
134
+
135
+ if (!result.ok) return null;
136
+
137
+ let release: ReleaseResponse;
138
+ try {
139
+ release = JSON.parse(result.content);
140
+ } catch {
141
+ return null;
142
+ }
143
+
144
+ const md = formatReleaseMarkdown(release);
145
+ const output = finalizeOutput(md);
146
+
147
+ return {
148
+ url,
149
+ finalUrl: url,
150
+ contentType: "text/markdown",
151
+ method: "metacpan",
152
+ content: output.content,
153
+ fetchedAt,
154
+ truncated: output.truncated,
155
+ notes: ["Fetched via MetaCPAN API"],
156
+ };
157
+ }
158
+
159
+ function formatModuleMarkdown(module: ModuleResponse, release: ReleaseResponse | null): string {
160
+ let md = `# ${module.name}\n\n`;
161
+ if (module.abstract) md += `${module.abstract}\n\n`;
162
+
163
+ md += `**Version:** ${module.version}`;
164
+ md += ` · **Distribution:** ${module.distribution}`;
165
+ md += ` · **Author:** [${module.author}](https://metacpan.org/author/${module.author})\n`;
166
+
167
+ if (release) {
168
+ if (release.license?.length) {
169
+ md += `**License:** ${release.license.join(", ")}\n`;
170
+ }
171
+
172
+ const resources = release.metadata?.resources;
173
+ if (resources?.repository?.web || resources?.repository?.url) {
174
+ const repoUrl = resources.repository.web || resources.repository.url;
175
+ md += `**Repository:** ${repoUrl}\n`;
176
+ }
177
+ if (resources?.homepage) {
178
+ md += `**Homepage:** ${resources.homepage}\n`;
179
+ }
180
+ if (resources?.bugtracker?.web) {
181
+ md += `**Issues:** ${resources.bugtracker.web}\n`;
182
+ }
183
+
184
+ // Show runtime dependencies
185
+ const runtimeDeps = release.dependency?.filter(
186
+ (d) => d.phase === "runtime" && d.relationship === "requires" && d.module !== "perl",
187
+ );
188
+ if (runtimeDeps?.length) {
189
+ md += `\n## Dependencies\n\n`;
190
+ for (const dep of runtimeDeps.slice(0, 20)) {
191
+ md += `- **${dep.module}**`;
192
+ if (dep.version && dep.version !== "0") md += ` >= ${dep.version}`;
193
+ md += "\n";
194
+ }
195
+ if (runtimeDeps.length > 20) {
196
+ md += `\n*...and ${runtimeDeps.length - 20} more*\n`;
197
+ }
198
+ }
199
+ }
200
+
201
+ md += `\n## Installation\n\n\`\`\`bash\ncpanm ${module.name}\n\`\`\`\n`;
202
+
203
+ return md;
204
+ }
205
+
206
+ function formatReleaseMarkdown(release: ReleaseResponse): string {
207
+ let md = `# ${release.distribution}\n\n`;
208
+ if (release.abstract) md += `${release.abstract}\n\n`;
209
+
210
+ md += `**Version:** ${release.version}`;
211
+ md += ` · **Author:** [${release.author}](https://metacpan.org/author/${release.author})\n`;
212
+
213
+ if (release.license?.length) {
214
+ md += `**License:** ${release.license.join(", ")}\n`;
215
+ }
216
+
217
+ if (release.stat?.mtime) {
218
+ const date = new Date(release.stat.mtime * 1000).toISOString().split("T")[0];
219
+ md += `**Released:** ${date}\n`;
220
+ }
221
+
222
+ const resources = release.metadata?.resources;
223
+ if (resources?.repository?.web || resources?.repository?.url) {
224
+ const repoUrl = resources.repository.web || resources.repository.url;
225
+ md += `**Repository:** ${repoUrl}\n`;
226
+ }
227
+ if (resources?.homepage) {
228
+ md += `**Homepage:** ${resources.homepage}\n`;
229
+ }
230
+ if (resources?.bugtracker?.web) {
231
+ md += `**Issues:** ${resources.bugtracker.web}\n`;
232
+ }
233
+
234
+ // Show runtime dependencies
235
+ const runtimeDeps = release.dependency?.filter(
236
+ (d) => d.phase === "runtime" && d.relationship === "requires" && d.module !== "perl",
237
+ );
238
+ if (runtimeDeps?.length) {
239
+ md += `\n## Dependencies\n\n`;
240
+ for (const dep of runtimeDeps.slice(0, 20)) {
241
+ md += `- **${dep.module}**`;
242
+ if (dep.version && dep.version !== "0") md += ` >= ${dep.version}`;
243
+ md += "\n";
244
+ }
245
+ if (runtimeDeps.length > 20) {
246
+ md += `\n*...and ${runtimeDeps.length - 20} more*\n`;
247
+ }
248
+ }
249
+
250
+ md += `\n## Installation\n\n\`\`\`bash\ncpanm ${release.distribution}\n\`\`\`\n`;
251
+
252
+ return md;
253
+ }