@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,275 @@
1
+ import { parse as parseHtml } from "node-html-parser";
2
+ import type { RenderResult, SpecialHandler } from "./types";
3
+ import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
4
+
5
+ interface GoModuleInfo {
6
+ Version: string;
7
+ Time: string;
8
+ }
9
+
10
+ /**
11
+ * Handle pkg.go.dev URLs via proxy API and page parsing
12
+ */
13
+ export const handleGoPkg: SpecialHandler = async (
14
+ url: string,
15
+ timeout: number,
16
+ signal?: AbortSignal,
17
+ ): Promise<RenderResult | null> => {
18
+ try {
19
+ const parsed = new URL(url);
20
+ if (parsed.hostname !== "pkg.go.dev") return null;
21
+
22
+ // Extract module path and version from URL
23
+ // Patterns: /module, /module@version, /module/subpackage
24
+ const pathname = parsed.pathname.slice(1); // remove leading /
25
+ if (!pathname) return null;
26
+
27
+ let modulePath: string;
28
+ let version = "latest";
29
+ let _subpackage = "";
30
+
31
+ // Parse @version if present
32
+ const atIndex = pathname.indexOf("@");
33
+ if (atIndex !== -1) {
34
+ const beforeAt = pathname.slice(0, atIndex);
35
+ const afterAt = pathname.slice(atIndex + 1);
36
+
37
+ // Check if there's a subpackage after version
38
+ const slashIndex = afterAt.indexOf("/");
39
+ if (slashIndex !== -1) {
40
+ version = afterAt.slice(0, slashIndex);
41
+ const remainder = afterAt.slice(slashIndex + 1);
42
+ modulePath = beforeAt;
43
+ _subpackage = remainder;
44
+ } else {
45
+ version = afterAt;
46
+ modulePath = beforeAt;
47
+ }
48
+ } else {
49
+ // No version specified, check for subpackage
50
+ // Need to determine where module ends and subpackage begins
51
+ // For now, treat the whole path as module path (we'll refine from proxy response)
52
+ modulePath = pathname;
53
+ }
54
+
55
+ const notes: string[] = [];
56
+ const sections: string[] = [];
57
+
58
+ // Fetch module info from proxy
59
+ let moduleInfo: GoModuleInfo | null = null;
60
+ let actualModulePath = modulePath;
61
+
62
+ if (version === "latest") {
63
+ try {
64
+ const proxyUrl = `https://proxy.golang.org/${encodeURIComponent(modulePath)}/@latest`;
65
+ const proxyResult = await loadPage(proxyUrl, { timeout, signal });
66
+
67
+ if (proxyResult.ok) {
68
+ moduleInfo = JSON.parse(proxyResult.content) as GoModuleInfo;
69
+ version = moduleInfo.Version;
70
+ }
71
+ } catch {
72
+ // If @latest fails, might be a subpackage - will extract from page
73
+ }
74
+ } else {
75
+ try {
76
+ const proxyUrl = `https://proxy.golang.org/${encodeURIComponent(modulePath)}/@v/${encodeURIComponent(version)}.info`;
77
+ const proxyResult = await loadPage(proxyUrl, { timeout, signal });
78
+
79
+ if (proxyResult.ok) {
80
+ moduleInfo = JSON.parse(proxyResult.content) as GoModuleInfo;
81
+ }
82
+ } catch {
83
+ // Proxy lookup failed, will rely on page data
84
+ }
85
+ }
86
+
87
+ // Fetch the pkg.go.dev page
88
+ const pageResult = await loadPage(url, { timeout, signal });
89
+ if (!pageResult.ok) {
90
+ return {
91
+ url,
92
+ finalUrl: pageResult.finalUrl,
93
+ contentType: "text/plain",
94
+ method: "go-pkg",
95
+ content: `Failed to fetch pkg.go.dev page (status: ${pageResult.status ?? "unknown"})`,
96
+ fetchedAt: new Date().toISOString(),
97
+ truncated: false,
98
+ notes: ["error"],
99
+ };
100
+ }
101
+
102
+ const doc = parseHtml(pageResult.content);
103
+
104
+ // Extract module/package information
105
+ const breadcrumb = doc.querySelector(".go-Breadcrumb");
106
+ const _headerDiv = doc.querySelector(".go-Main-header");
107
+
108
+ // Extract actual module path from breadcrumb or header
109
+ if (breadcrumb) {
110
+ const moduleLink = breadcrumb.querySelector("a[href^='/']");
111
+ if (moduleLink) {
112
+ const href = moduleLink.getAttribute("href");
113
+ if (href) {
114
+ actualModulePath = href.slice(1).split("@")[0];
115
+ }
116
+ }
117
+ }
118
+
119
+ // Extract version if not from proxy
120
+ if (!moduleInfo) {
121
+ const versionBadge = doc.querySelector(".go-Chip");
122
+ if (versionBadge) {
123
+ const versionText = versionBadge.textContent?.trim();
124
+ if (versionText?.startsWith("v")) {
125
+ version = versionText;
126
+ }
127
+ }
128
+ }
129
+
130
+ // Extract license
131
+ const licenseLink = doc.querySelector("a[data-test-id='UnitHeader-license']");
132
+ const license = licenseLink?.textContent?.trim() || "Unknown";
133
+
134
+ // Extract import path
135
+ const importPathInput = doc.querySelector("input[data-test-id='UnitHeader-importPath']");
136
+ const importPath = importPathInput?.getAttribute("value") || actualModulePath;
137
+
138
+ // Build header
139
+ sections.push(`# ${importPath}`);
140
+ sections.push("");
141
+ sections.push(`**Module:** ${actualModulePath}`);
142
+ sections.push(`**Version:** ${version}`);
143
+ sections.push(`**License:** ${license}`);
144
+ sections.push("");
145
+
146
+ // Extract package synopsis
147
+ const synopsis = doc.querySelector(".go-Main-headerContent p");
148
+ if (synopsis) {
149
+ const synopsisText = synopsis.textContent?.trim();
150
+ if (synopsisText) {
151
+ sections.push(`## Synopsis`);
152
+ sections.push("");
153
+ sections.push(synopsisText);
154
+ sections.push("");
155
+ }
156
+ }
157
+
158
+ // Extract documentation overview
159
+ const docSection = doc.querySelector("#section-documentation");
160
+ if (docSection) {
161
+ sections.push("## Documentation");
162
+ sections.push("");
163
+
164
+ // Get overview paragraph
165
+ const overview = docSection.querySelector(".go-Message");
166
+ if (overview) {
167
+ const overviewMd = htmlToBasicMarkdown(overview.innerHTML);
168
+ sections.push(overviewMd);
169
+ sections.push("");
170
+ }
171
+
172
+ // Get package-level documentation
173
+ const docContent = docSection.querySelector(".Documentation-content");
174
+ if (docContent) {
175
+ // Extract first few paragraphs
176
+ const paragraphs = docContent.querySelectorAll("p");
177
+ const docParts: string[] = [];
178
+ for (let i = 0; i < Math.min(3, paragraphs.length); i++) {
179
+ const p = paragraphs[i];
180
+ const text = htmlToBasicMarkdown(p.innerHTML).trim();
181
+ if (text) {
182
+ docParts.push(text);
183
+ }
184
+ }
185
+
186
+ if (docParts.length > 0) {
187
+ sections.push(docParts.join("\n\n"));
188
+ sections.push("");
189
+ }
190
+ }
191
+ }
192
+
193
+ // Extract index of exported identifiers
194
+ const indexSection = doc.querySelector("#section-index");
195
+ if (indexSection) {
196
+ const indexList = indexSection.querySelector(".Documentation-indexList");
197
+ if (indexList) {
198
+ sections.push("## Index");
199
+ sections.push("");
200
+
201
+ const items = indexList.querySelectorAll("li");
202
+ const exported: string[] = [];
203
+
204
+ for (const item of items) {
205
+ const link = item.querySelector("a");
206
+ if (link) {
207
+ const name = link.textContent?.trim();
208
+ if (name) {
209
+ exported.push(`- ${name}`);
210
+ }
211
+ }
212
+ }
213
+
214
+ if (exported.length > 0) {
215
+ // Limit to first 50 exports
216
+ sections.push(exported.slice(0, 50).join("\n"));
217
+ if (exported.length > 50) {
218
+ notes.push(`showing 50 of ${exported.length} exports`);
219
+ sections.push(`\n... and ${exported.length - 50} more`);
220
+ }
221
+ sections.push("");
222
+ }
223
+ }
224
+ }
225
+
226
+ // Extract dependencies/imports
227
+ const importsSection = doc.querySelector("#section-imports");
228
+ if (importsSection) {
229
+ const importsList = importsSection.querySelector(".go-Message");
230
+ if (importsList) {
231
+ sections.push("## Imports");
232
+ sections.push("");
233
+
234
+ const links = importsList.querySelectorAll("a");
235
+ const imports: string[] = [];
236
+
237
+ for (const link of links) {
238
+ const imp = link.textContent?.trim();
239
+ if (imp) {
240
+ imports.push(`- ${imp}`);
241
+ }
242
+ }
243
+
244
+ if (imports.length > 0) {
245
+ sections.push(imports.slice(0, 20).join("\n"));
246
+ if (imports.length > 20) {
247
+ notes.push(`showing 20 of ${imports.length} imports`);
248
+ sections.push(`\n... and ${imports.length - 20} more`);
249
+ }
250
+ sections.push("");
251
+ }
252
+ }
253
+ }
254
+
255
+ if (moduleInfo) {
256
+ notes.push(`published ${moduleInfo.Time}`);
257
+ }
258
+
259
+ const content = sections.join("\n");
260
+ const { content: finalContent, truncated } = finalizeOutput(content);
261
+
262
+ return {
263
+ url,
264
+ finalUrl: pageResult.finalUrl,
265
+ contentType: "text/markdown",
266
+ method: "go-pkg",
267
+ content: finalContent,
268
+ fetchedAt: new Date().toISOString(),
269
+ truncated,
270
+ notes,
271
+ };
272
+ } catch {
273
+ return null;
274
+ }
275
+ };
@@ -0,0 +1,94 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface HackagePackage {
5
+ name: string;
6
+ synopsis: string;
7
+ description: string;
8
+ license: string;
9
+ author: string;
10
+ maintainer: string;
11
+ version: string;
12
+ homepage?: string;
13
+ "bug-reports"?: string;
14
+ category?: string;
15
+ stability?: string;
16
+ dependencies?: Record<string, string>;
17
+ }
18
+
19
+ /**
20
+ * Handle Hackage (Haskell package registry) URLs via JSON API
21
+ */
22
+ export const handleHackage: SpecialHandler = async (
23
+ url: string,
24
+ timeout: number,
25
+ signal?: AbortSignal,
26
+ ): Promise<RenderResult | null> => {
27
+ try {
28
+ const parsed = new URL(url);
29
+ if (parsed.hostname !== "hackage.haskell.org") return null;
30
+
31
+ // Match /package/{name} or /package/{name}-{version}
32
+ const match = parsed.pathname.match(/^\/package\/([^/]+)(?:\/|$)/);
33
+ if (!match) return null;
34
+
35
+ const packageId = decodeURIComponent(match[1]);
36
+ const fetchedAt = new Date().toISOString();
37
+
38
+ // Fetch package info with JSON accept header
39
+ const apiUrl = `https://hackage.haskell.org/package/${encodeURIComponent(packageId)}`;
40
+ const result = await loadPage(apiUrl, {
41
+ timeout,
42
+ headers: { Accept: "application/json" },
43
+ signal,
44
+ });
45
+
46
+ if (!result.ok) return null;
47
+
48
+ let pkg: HackagePackage;
49
+ try {
50
+ pkg = JSON.parse(result.content);
51
+ } catch {
52
+ return null;
53
+ }
54
+
55
+ let md = `# ${pkg.name}\n\n`;
56
+ if (pkg.synopsis) md += `${pkg.synopsis}\n\n`;
57
+
58
+ md += `**Version:** ${pkg.version}`;
59
+ if (pkg.license) md += ` · **License:** ${pkg.license}`;
60
+ md += "\n";
61
+
62
+ if (pkg.author) md += `**Author:** ${pkg.author}\n`;
63
+ if (pkg.maintainer) md += `**Maintainer:** ${pkg.maintainer}\n`;
64
+ if (pkg.category) md += `**Category:** ${pkg.category}\n`;
65
+ if (pkg.stability) md += `**Stability:** ${pkg.stability}\n`;
66
+ if (pkg.homepage) md += `**Homepage:** ${pkg.homepage}\n`;
67
+ if (pkg["bug-reports"]) md += `**Bug Reports:** ${pkg["bug-reports"]}\n`;
68
+
69
+ if (pkg.description) {
70
+ md += `\n## Description\n\n${pkg.description}\n`;
71
+ }
72
+
73
+ if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
74
+ md += `\n## Dependencies\n\n`;
75
+ for (const [dep, version] of Object.entries(pkg.dependencies)) {
76
+ md += `- ${dep}: ${version}\n`;
77
+ }
78
+ }
79
+
80
+ const output = finalizeOutput(md);
81
+ return {
82
+ url,
83
+ finalUrl: url,
84
+ contentType: "text/markdown",
85
+ method: "hackage",
86
+ content: output.content,
87
+ fetchedAt,
88
+ truncated: output.truncated,
89
+ notes: ["Fetched via Hackage API"],
90
+ };
91
+ } catch {}
92
+
93
+ return null;
94
+ };
@@ -0,0 +1,208 @@
1
+ import type { SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface HNItem {
5
+ id: number;
6
+ deleted?: boolean;
7
+ type?: "job" | "story" | "comment" | "poll" | "pollopt";
8
+ by?: string;
9
+ time?: number;
10
+ text?: string;
11
+ dead?: boolean;
12
+ parent?: number;
13
+ poll?: number;
14
+ kids?: number[];
15
+ url?: string;
16
+ score?: number;
17
+ title?: string;
18
+ parts?: number[];
19
+ descendants?: number;
20
+ }
21
+
22
+ const API_BASE = "https://hacker-news.firebaseio.com/v0";
23
+
24
+ async function fetchItem(id: number, timeout: number, signal?: AbortSignal): Promise<HNItem | null> {
25
+ const url = `${API_BASE}/item/${id}.json`;
26
+ const { content, ok } = await loadPage(url, { timeout, signal });
27
+ if (!ok) return null;
28
+ return JSON.parse(content) as HNItem;
29
+ }
30
+
31
+ async function fetchItems(ids: number[], timeout: number, limit = 20, signal?: AbortSignal): Promise<HNItem[]> {
32
+ const promises = ids.slice(0, limit).map((id) => fetchItem(id, timeout, signal));
33
+ const results = await Promise.all(promises);
34
+ return results.filter((item): item is HNItem => item !== null && !item.deleted && !item.dead);
35
+ }
36
+
37
+ function decodeHNText(html: string): string {
38
+ return html
39
+ .replace(/<p>/g, "\n\n")
40
+ .replace(/<\/p>/g, "")
41
+ .replace(/<pre><code>/g, "\n```\n")
42
+ .replace(/<\/code><\/pre>/g, "\n```\n")
43
+ .replace(/<code>/g, "`")
44
+ .replace(/<\/code>/g, "`")
45
+ .replace(/<i>/g, "*")
46
+ .replace(/<\/i>/g, "*")
47
+ .replace(/<a href="([^"]+)"[^>]*>([^<]*)<\/a>/g, "[$2]($1)")
48
+ .replace(/<[^>]+>/g, "")
49
+ .replace(/&quot;/g, '"')
50
+ .replace(/&#x27;/g, "'")
51
+ .replace(/&#x2F;/g, "/")
52
+ .replace(/&lt;/g, "<")
53
+ .replace(/&gt;/g, ">")
54
+ .replace(/&amp;/g, "&")
55
+ .trim();
56
+ }
57
+
58
+ function formatTimestamp(unixTime: number): string {
59
+ const date = new Date(unixTime * 1000);
60
+ const now = Date.now();
61
+ const diff = now - date.getTime();
62
+ const hours = Math.floor(diff / (1000 * 60 * 60));
63
+ const days = Math.floor(hours / 24);
64
+
65
+ if (days > 7) return date.toISOString().split("T")[0];
66
+ if (days > 0) return `${days}d ago`;
67
+ if (hours > 0) return `${hours}h ago`;
68
+ const minutes = Math.floor(diff / (1000 * 60));
69
+ return `${minutes}m ago`;
70
+ }
71
+
72
+ async function renderStory(item: HNItem, timeout: number, depth = 0, signal?: AbortSignal): Promise<string> {
73
+ let output = "";
74
+
75
+ if (depth === 0) {
76
+ output += `# ${item.title}\n\n`;
77
+ if (item.url) {
78
+ output += `**URL:** ${item.url}\n\n`;
79
+ }
80
+ output += `**Posted by:** ${item.by} | **Score:** ${item.score ?? 0} | **Time:** ${formatTimestamp(item.time ?? 0)}`;
81
+ if (item.descendants) {
82
+ output += ` | **Comments:** ${item.descendants}`;
83
+ }
84
+ output += "\n\n";
85
+ }
86
+
87
+ if (item.text) {
88
+ output += `${decodeHNText(item.text)}\n\n`;
89
+ }
90
+
91
+ if (item.kids && item.kids.length > 0 && depth < 2) {
92
+ const topComments = item.kids.slice(0, depth === 0 ? 20 : 10);
93
+ const comments = await fetchItems(topComments, timeout, topComments.length, signal);
94
+
95
+ if (comments.length > 0) {
96
+ if (depth === 0) output += "---\n\n## Comments\n\n";
97
+
98
+ for (const comment of comments) {
99
+ const indent = " ".repeat(depth);
100
+ output += `${indent}**${comment.by}** (${formatTimestamp(comment.time ?? 0)})`;
101
+ if (comment.score !== undefined) output += ` [${comment.score}]`;
102
+ output += "\n";
103
+ if (comment.text) {
104
+ const text = decodeHNText(comment.text);
105
+ const lines = text.split("\n");
106
+ output += `${lines.map((line) => `${indent}${line}`).join("\n")}\n\n`;
107
+ }
108
+
109
+ if (comment.kids && comment.kids.length > 0 && depth < 1) {
110
+ const childOutput = await renderStory(comment, timeout, depth + 1, signal);
111
+ output += childOutput;
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ return output;
118
+ }
119
+
120
+ async function renderListing(ids: number[], timeout: number, title: string, signal?: AbortSignal): Promise<string> {
121
+ let output = `# ${title}\n\n`;
122
+ const stories = await fetchItems(ids, timeout, 20, signal);
123
+
124
+ for (let i = 0; i < stories.length; i++) {
125
+ const story = stories[i];
126
+ output += `${i + 1}. **${story.title}**\n`;
127
+ if (story.url) {
128
+ output += ` ${story.url}\n`;
129
+ }
130
+ output += ` ${story.score ?? 0} points by ${story.by} | ${formatTimestamp(story.time ?? 0)}`;
131
+ if (story.descendants) {
132
+ output += ` | ${story.descendants} comments`;
133
+ }
134
+ output += `\n https://news.ycombinator.com/item?id=${story.id}\n\n`;
135
+ }
136
+
137
+ return output;
138
+ }
139
+
140
+ export const handleHackerNews: SpecialHandler = async (url, timeout, signal) => {
141
+ const parsed = new URL(url);
142
+ if (!parsed.hostname.includes("news.ycombinator.com")) return null;
143
+
144
+ const notes: string[] = [];
145
+ let content = "";
146
+ const fetchedAt = new Date().toISOString();
147
+
148
+ try {
149
+ const itemId = parsed.searchParams.get("id");
150
+
151
+ if (itemId) {
152
+ const item = await fetchItem(parseInt(itemId, 10), timeout, signal);
153
+ if (!item) throw new Error(`Failed to fetch item ${itemId}`);
154
+
155
+ content = await renderStory(item, timeout, 0, signal);
156
+ notes.push(`Fetched HN item ${itemId} with top-level comments (depth 2)`);
157
+ } else if (parsed.pathname === "/" || parsed.pathname === "/news") {
158
+ const { content: raw, ok } = await loadPage(`${API_BASE}/topstories.json`, { timeout, signal });
159
+ if (!ok) throw new Error("Failed to fetch top stories");
160
+ const ids = JSON.parse(raw) as number[];
161
+ content = await renderListing(ids, timeout, "Hacker News - Top Stories", signal);
162
+ notes.push("Fetched top 20 stories from HN front page");
163
+ } else if (parsed.pathname === "/newest") {
164
+ const { content: raw, ok } = await loadPage(`${API_BASE}/newstories.json`, { timeout, signal });
165
+ if (!ok) throw new Error("Failed to fetch new stories");
166
+ const ids = JSON.parse(raw) as number[];
167
+ content = await renderListing(ids, timeout, "Hacker News - New Stories", signal);
168
+ notes.push("Fetched top 20 new stories");
169
+ } else if (parsed.pathname === "/best") {
170
+ const { content: raw, ok } = await loadPage(`${API_BASE}/beststories.json`, { timeout, signal });
171
+ if (!ok) throw new Error("Failed to fetch best stories");
172
+ const ids = JSON.parse(raw) as number[];
173
+ content = await renderListing(ids, timeout, "Hacker News - Best Stories", signal);
174
+ notes.push("Fetched top 20 best stories");
175
+ } else {
176
+ return null;
177
+ }
178
+
179
+ const { content: finalContent, truncated } = finalizeOutput(content);
180
+
181
+ return {
182
+ url,
183
+ finalUrl: url,
184
+ contentType: "text/markdown",
185
+ method: "hackernews",
186
+ content: finalContent,
187
+ fetchedAt,
188
+ truncated,
189
+ notes,
190
+ };
191
+ } catch (err) {
192
+ const errorMsg = err instanceof Error ? err.message : String(err);
193
+ notes.push(`Error: ${errorMsg}`);
194
+ const { content: finalContent, truncated } = finalizeOutput(
195
+ `# Error fetching Hacker News content\n\n${errorMsg}`,
196
+ );
197
+ return {
198
+ url,
199
+ finalUrl: url,
200
+ contentType: "text/markdown",
201
+ method: "hackernews",
202
+ content: finalContent,
203
+ fetchedAt,
204
+ truncated,
205
+ notes,
206
+ };
207
+ }
208
+ };
@@ -0,0 +1,121 @@
1
+ import type { SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ /**
5
+ * Handle Hex.pm (Elixir package registry) URLs via API
6
+ */
7
+ export const handleHex: SpecialHandler = async (url, timeout, signal) => {
8
+ try {
9
+ const parsed = new URL(url);
10
+ if (parsed.hostname !== "hex.pm" && parsed.hostname !== "www.hex.pm") return null;
11
+
12
+ // Extract package name from /packages/name or /packages/name/version
13
+ const match = parsed.pathname.match(/^\/packages\/([^/]+)/);
14
+ if (!match) return null;
15
+
16
+ const packageName = decodeURIComponent(match[1]);
17
+ const fetchedAt = new Date().toISOString();
18
+
19
+ // Fetch from Hex.pm API
20
+ const apiUrl = `https://hex.pm/api/packages/${packageName}`;
21
+ const result = await loadPage(apiUrl, { timeout, signal });
22
+
23
+ if (!result.ok) return null;
24
+
25
+ let data: {
26
+ name: string;
27
+ meta?: {
28
+ description?: string;
29
+ links?: Record<string, string>;
30
+ licenses?: string[];
31
+ };
32
+ releases?: Array<{
33
+ version: string;
34
+ inserted_at: string;
35
+ }>;
36
+ downloads?: {
37
+ all?: number;
38
+ week?: number;
39
+ day?: number;
40
+ };
41
+ latest_version?: string;
42
+ latest_stable_version?: string;
43
+ };
44
+
45
+ try {
46
+ data = JSON.parse(result.content);
47
+ } catch {
48
+ return null;
49
+ }
50
+
51
+ let md = `# ${data.name}\n\n`;
52
+ if (data.meta?.description) md += `${data.meta.description}\n\n`;
53
+
54
+ const version = data.latest_stable_version || data.latest_version || "unknown";
55
+ md += `**Latest:** ${version}`;
56
+ if (data.meta?.licenses?.length) md += ` · **License:** ${data.meta.licenses.join(", ")}`;
57
+ md += "\n";
58
+
59
+ if (data.downloads?.all) {
60
+ md += `**Total Downloads:** ${formatCount(data.downloads.all)}`;
61
+ if (data.downloads.week) md += ` · **This Week:** ${formatCount(data.downloads.week)}`;
62
+ md += "\n";
63
+ }
64
+ md += "\n";
65
+
66
+ if (data.meta?.links && Object.keys(data.meta.links).length > 0) {
67
+ md += `## Links\n\n`;
68
+ for (const [key, value] of Object.entries(data.meta.links)) {
69
+ md += `- **${key}:** ${value}\n`;
70
+ }
71
+ md += "\n";
72
+ }
73
+
74
+ // Fetch releases if available
75
+ if (data.releases?.length) {
76
+ const releasesUrl = `https://hex.pm/api/packages/${packageName}/releases/${version}`;
77
+ const releaseResult = await loadPage(releasesUrl, { timeout: Math.min(timeout, 5), signal });
78
+
79
+ if (releaseResult.ok) {
80
+ try {
81
+ const releaseData = JSON.parse(releaseResult.content) as {
82
+ requirements?: Record<string, { app?: string; optional: boolean; requirement: string }>;
83
+ };
84
+
85
+ if (releaseData.requirements && Object.keys(releaseData.requirements).length > 0) {
86
+ md += `## Dependencies (${version})\n\n`;
87
+ for (const [dep, info] of Object.entries(releaseData.requirements)) {
88
+ const optional = info.optional ? " (optional)" : "";
89
+ md += `- ${dep}: ${info.requirement}${optional}\n`;
90
+ }
91
+ md += "\n";
92
+ }
93
+ } catch {}
94
+ }
95
+
96
+ // Show recent releases
97
+ const recentReleases = data.releases.slice(0, 10);
98
+ if (recentReleases.length > 0) {
99
+ md += `## Recent Releases\n\n`;
100
+ for (const release of recentReleases) {
101
+ const date = new Date(release.inserted_at).toISOString().split("T")[0];
102
+ md += `- **${release.version}** (${date})\n`;
103
+ }
104
+ }
105
+ }
106
+
107
+ const output = finalizeOutput(md);
108
+ return {
109
+ url,
110
+ finalUrl: url,
111
+ contentType: "text/markdown",
112
+ method: "hex",
113
+ content: output.content,
114
+ fetchedAt,
115
+ truncated: output.truncated,
116
+ notes: ["Fetched via Hex.pm API"],
117
+ };
118
+ } catch {}
119
+
120
+ return null;
121
+ };