@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,180 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ function isRecord(value: unknown): value is Record<string, unknown> {
5
+ return typeof value === "object" && value !== null;
6
+ }
7
+
8
+ function asString(value: unknown): string | null {
9
+ if (typeof value !== "string") return null;
10
+ const trimmed = value.trim();
11
+ return trimmed.length > 0 ? trimmed : null;
12
+ }
13
+
14
+ function asNumber(value: unknown): number | null {
15
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
16
+ }
17
+
18
+ function formatLicenses(licenses: unknown): string[] {
19
+ if (!Array.isArray(licenses)) return [];
20
+ const output: string[] = [];
21
+ for (const license of licenses) {
22
+ if (typeof license === "string") {
23
+ const trimmed = license.trim();
24
+ if (trimmed) output.push(trimmed);
25
+ continue;
26
+ }
27
+ if (isRecord(license)) {
28
+ const name = asString(license.name);
29
+ const url = asString(license.url);
30
+ if (name && url) {
31
+ output.push(`${name} (${url})`);
32
+ } else if (name) {
33
+ output.push(name);
34
+ } else if (url) {
35
+ output.push(url);
36
+ }
37
+ }
38
+ }
39
+ return output;
40
+ }
41
+
42
+ function formatDependencies(deps: unknown): string[] {
43
+ const output: string[] = [];
44
+ if (Array.isArray(deps)) {
45
+ for (const dep of deps) {
46
+ if (typeof dep === "string") {
47
+ const trimmed = dep.trim();
48
+ if (trimmed) output.push(trimmed);
49
+ continue;
50
+ }
51
+ if (Array.isArray(dep)) {
52
+ const name = asString(dep[0]);
53
+ const version = asString(dep[1]);
54
+ if (name && version) {
55
+ output.push(`${name}: ${version}`);
56
+ } else if (name) {
57
+ output.push(name);
58
+ }
59
+ continue;
60
+ }
61
+ if (isRecord(dep)) {
62
+ const name = asString(dep.name) ?? asString(dep.artifact) ?? asString(dep.jar_name);
63
+ const version = asString(dep.version);
64
+ if (name && version) {
65
+ output.push(`${name}: ${version}`);
66
+ } else if (name) {
67
+ output.push(name);
68
+ }
69
+ }
70
+ }
71
+ return output;
72
+ }
73
+
74
+ if (isRecord(deps)) {
75
+ for (const [name, version] of Object.entries(deps)) {
76
+ const versionText = asString(version);
77
+ if (versionText) {
78
+ output.push(`${name}: ${versionText}`);
79
+ } else if (name.trim()) {
80
+ output.push(name);
81
+ }
82
+ }
83
+ }
84
+
85
+ return output;
86
+ }
87
+
88
+ /**
89
+ * Handle Clojars URLs via API
90
+ */
91
+ export const handleClojars: SpecialHandler = async (
92
+ url: string,
93
+ timeout: number,
94
+ signal?: AbortSignal,
95
+ ): Promise<RenderResult | null> => {
96
+ try {
97
+ const parsed = new URL(url);
98
+ if (parsed.hostname !== "clojars.org" && parsed.hostname !== "www.clojars.org") return null;
99
+
100
+ const path = parsed.pathname.replace(/^\/+|\/+$/g, "");
101
+ if (!path) return null;
102
+
103
+ const segments = path.split("/").filter(Boolean);
104
+ if (segments.length < 1 || segments.length > 2) return null;
105
+
106
+ const groupFromUrl = segments.length === 2 ? decodeURIComponent(segments[0]) : null;
107
+ const artifactFromUrl = decodeURIComponent(segments[segments.length - 1]);
108
+
109
+ const apiUrl =
110
+ segments.length === 2
111
+ ? `https://clojars.org/api/artifacts/${encodeURIComponent(groupFromUrl ?? "")}/${encodeURIComponent(artifactFromUrl)}`
112
+ : `https://clojars.org/api/artifacts/${encodeURIComponent(artifactFromUrl)}`;
113
+
114
+ const fetchedAt = new Date().toISOString();
115
+
116
+ const result = await loadPage(apiUrl, {
117
+ timeout,
118
+ headers: { Accept: "application/json" },
119
+ signal,
120
+ });
121
+
122
+ if (!result.ok) return null;
123
+
124
+ let payload: unknown;
125
+ try {
126
+ payload = JSON.parse(result.content);
127
+ } catch {
128
+ return null;
129
+ }
130
+
131
+ const data = Array.isArray(payload) ? payload[0] : payload;
132
+ if (!isRecord(data)) return null;
133
+
134
+ const groupName = asString(data.group_name) ?? asString(data.group) ?? groupFromUrl;
135
+ const artifactName = asString(data.jar_name) ?? asString(data.artifact) ?? asString(data.name) ?? artifactFromUrl;
136
+ const version = asString(data.latest_version) ?? asString(data.version);
137
+ const description = asString(data.description) ?? asString(data.summary);
138
+ const downloads =
139
+ asNumber(data.downloads) ?? asNumber(data.downloads_total) ?? asNumber(data.total_downloads) ?? null;
140
+ const homepage = asString(data.homepage) ?? asString(data.url);
141
+ const licenses = formatLicenses(data.licenses);
142
+ const dependencies = formatDependencies(data.dependencies ?? data.deps);
143
+
144
+ const displayName =
145
+ groupName && artifactName && groupName !== artifactName
146
+ ? `${groupName}/${artifactName}`
147
+ : (artifactName ?? groupName ?? "Clojars artifact");
148
+
149
+ let md = `# ${displayName}\n\n`;
150
+ if (description) md += `${description}\n\n`;
151
+
152
+ if (groupName) md += `**Group:** ${groupName}\n`;
153
+ if (artifactName) md += `**Artifact:** ${artifactName}\n`;
154
+ if (version) md += `**Latest:** ${version}\n`;
155
+ if (downloads !== null) md += `**Downloads:** ${formatCount(downloads)}\n`;
156
+ if (homepage) md += `**Homepage:** ${homepage}\n`;
157
+ if (licenses.length > 0) md += `**Licenses:** ${licenses.join(", ")}\n`;
158
+
159
+ if (dependencies.length > 0) {
160
+ md += "\n## Dependencies\n\n";
161
+ for (const dep of dependencies) {
162
+ md += `- ${dep}\n`;
163
+ }
164
+ }
165
+
166
+ const output = finalizeOutput(md);
167
+ return {
168
+ url,
169
+ finalUrl: url,
170
+ contentType: "text/markdown",
171
+ method: "clojars",
172
+ content: output.content,
173
+ fetchedAt,
174
+ truncated: output.truncated,
175
+ notes: ["Fetched via Clojars API"],
176
+ };
177
+ } catch {}
178
+
179
+ return null;
180
+ };
@@ -0,0 +1,184 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface CoinGeckoResponse {
5
+ id: string;
6
+ symbol: string;
7
+ name: string;
8
+ description?: { en?: string };
9
+ links?: {
10
+ homepage?: string[];
11
+ blockchain_site?: string[];
12
+ repos_url?: { github?: string[] };
13
+ };
14
+ market_data?: {
15
+ current_price?: { usd?: number };
16
+ market_cap?: { usd?: number };
17
+ total_volume?: { usd?: number };
18
+ price_change_percentage_24h?: number;
19
+ ath?: { usd?: number };
20
+ ath_date?: { usd?: string };
21
+ circulating_supply?: number;
22
+ total_supply?: number;
23
+ max_supply?: number;
24
+ };
25
+ categories?: string[];
26
+ genesis_date?: string;
27
+ }
28
+
29
+ /**
30
+ * Handle CoinGecko cryptocurrency URLs via API
31
+ */
32
+ export const handleCoinGecko: SpecialHandler = async (
33
+ url: string,
34
+ timeout: number,
35
+ signal?: AbortSignal,
36
+ ): Promise<RenderResult | null> => {
37
+ try {
38
+ const parsed = new URL(url);
39
+ if (!parsed.hostname.includes("coingecko.com")) return null;
40
+
41
+ // Extract coin ID from /coins/{id} or /en/coins/{id}
42
+ const match = parsed.pathname.match(/^(?:\/[a-z]{2})?\/coins\/([^/?#]+)/);
43
+ if (!match) return null;
44
+
45
+ const coinId = decodeURIComponent(match[1]);
46
+ const fetchedAt = new Date().toISOString();
47
+
48
+ // Fetch from CoinGecko API
49
+ const apiUrl = `https://api.coingecko.com/api/v3/coins/${coinId}?localization=false&tickers=false&community_data=false&developer_data=false`;
50
+ const result = await loadPage(apiUrl, {
51
+ timeout,
52
+ headers: { Accept: "application/json" },
53
+ signal,
54
+ });
55
+
56
+ if (!result.ok) return null;
57
+
58
+ let coin: CoinGeckoResponse;
59
+ try {
60
+ coin = JSON.parse(result.content);
61
+ } catch {
62
+ return null;
63
+ }
64
+
65
+ const market = coin.market_data;
66
+
67
+ let md = `# ${coin.name} (${coin.symbol.toUpperCase()})\n\n`;
68
+
69
+ // Price and market data
70
+ if (market?.current_price?.usd !== undefined) {
71
+ md += `**Price:** $${formatPrice(market.current_price.usd)}`;
72
+ if (market.price_change_percentage_24h !== undefined) {
73
+ const change = market.price_change_percentage_24h;
74
+ const sign = change >= 0 ? "+" : "";
75
+ md += ` (${sign}${change.toFixed(2)}% 24h)`;
76
+ }
77
+ md += "\n";
78
+ }
79
+
80
+ if (market?.market_cap?.usd) {
81
+ md += `**Market Cap:** $${formatLargeNumber(market.market_cap.usd)}\n`;
82
+ }
83
+
84
+ if (market?.total_volume?.usd) {
85
+ md += `**24h Volume:** $${formatLargeNumber(market.total_volume.usd)}\n`;
86
+ }
87
+
88
+ if (market?.ath?.usd !== undefined) {
89
+ md += `**All-Time High:** $${formatPrice(market.ath.usd)}`;
90
+ if (market.ath_date?.usd) {
91
+ const athDate = new Date(market.ath_date.usd).toLocaleDateString("en-US", {
92
+ year: "numeric",
93
+ month: "short",
94
+ day: "numeric",
95
+ });
96
+ md += ` (${athDate})`;
97
+ }
98
+ md += "\n";
99
+ }
100
+
101
+ md += "\n";
102
+
103
+ // Supply info
104
+ if (market?.circulating_supply) {
105
+ md += `**Circulating Supply:** ${formatCount(Math.round(market.circulating_supply))}`;
106
+ if (market.max_supply) {
107
+ const percent = ((market.circulating_supply / market.max_supply) * 100).toFixed(1);
108
+ md += ` / ${formatCount(Math.round(market.max_supply))} (${percent}%)`;
109
+ } else if (market.total_supply) {
110
+ md += ` / ${formatCount(Math.round(market.total_supply))} total`;
111
+ }
112
+ md += "\n";
113
+ }
114
+
115
+ if (coin.genesis_date) {
116
+ md += `**Launch Date:** ${coin.genesis_date}\n`;
117
+ }
118
+
119
+ if (coin.categories?.length) {
120
+ md += `**Categories:** ${coin.categories.join(", ")}\n`;
121
+ }
122
+
123
+ // Links
124
+ const links: string[] = [];
125
+ if (coin.links?.homepage?.[0]) {
126
+ links.push(`[Website](${coin.links.homepage[0]})`);
127
+ }
128
+ if (coin.links?.blockchain_site?.[0]) {
129
+ links.push(`[Explorer](${coin.links.blockchain_site[0]})`);
130
+ }
131
+ if (coin.links?.repos_url?.github?.[0]) {
132
+ links.push(`[GitHub](${coin.links.repos_url.github[0]})`);
133
+ }
134
+ if (links.length) {
135
+ md += `**Links:** ${links.join(" · ")}\n`;
136
+ }
137
+
138
+ // Description
139
+ if (coin.description?.en) {
140
+ const desc = coin.description.en
141
+ .replace(/<[^>]+>/g, "") // Strip HTML
142
+ .replace(/\r\n/g, "\n")
143
+ .trim();
144
+ if (desc) {
145
+ md += `\n## About\n\n${desc}\n`;
146
+ }
147
+ }
148
+
149
+ const output = finalizeOutput(md);
150
+ return {
151
+ url,
152
+ finalUrl: url,
153
+ contentType: "text/markdown",
154
+ method: "coingecko",
155
+ content: output.content,
156
+ fetchedAt,
157
+ truncated: output.truncated,
158
+ notes: ["Fetched via CoinGecko API"],
159
+ };
160
+ } catch {}
161
+
162
+ return null;
163
+ };
164
+
165
+ /**
166
+ * Format price with appropriate decimal places
167
+ */
168
+ function formatPrice(price: number): string {
169
+ if (price >= 1000) return price.toLocaleString("en-US", { maximumFractionDigits: 2 });
170
+ if (price >= 1) return price.toFixed(2);
171
+ if (price >= 0.01) return price.toFixed(4);
172
+ if (price >= 0.0001) return price.toFixed(6);
173
+ return price.toFixed(8);
174
+ }
175
+
176
+ /**
177
+ * Format large numbers with B/M/K suffixes
178
+ */
179
+ function formatLargeNumber(n: number): string {
180
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
181
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
182
+ if (n >= 1_000) return `${(n / 1_000).toFixed(2)}K`;
183
+ return n.toFixed(2);
184
+ }
@@ -0,0 +1,128 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ /**
5
+ * Check if content looks like HTML
6
+ */
7
+ function looksLikeHtml(content: string): boolean {
8
+ const trimmed = content.trim().toLowerCase();
9
+ return (
10
+ trimmed.startsWith("<!doctype") ||
11
+ trimmed.startsWith("<html") ||
12
+ trimmed.startsWith("<head") ||
13
+ trimmed.startsWith("<body")
14
+ );
15
+ }
16
+
17
+ /**
18
+ * Handle crates.io URLs via API
19
+ */
20
+ export const handleCratesIo: SpecialHandler = async (
21
+ url: string,
22
+ timeout: number,
23
+ signal?: AbortSignal,
24
+ ): Promise<RenderResult | null> => {
25
+ try {
26
+ const parsed = new URL(url);
27
+ if (parsed.hostname !== "crates.io" && parsed.hostname !== "www.crates.io") return null;
28
+
29
+ // Extract crate name from /crates/name or /crates/name/version
30
+ const match = parsed.pathname.match(/^\/crates\/([^/]+)/);
31
+ if (!match) return null;
32
+
33
+ const crateName = decodeURIComponent(match[1]);
34
+ const fetchedAt = new Date().toISOString();
35
+
36
+ // Fetch from crates.io API
37
+ const apiUrl = `https://crates.io/api/v1/crates/${crateName}`;
38
+ const result = await loadPage(apiUrl, {
39
+ timeout,
40
+ signal,
41
+ headers: { "User-Agent": "omp-web-fetch/1.0 (https://github.com/anthropics)" },
42
+ });
43
+
44
+ if (!result.ok) return null;
45
+
46
+ let data: {
47
+ crate: {
48
+ name: string;
49
+ description: string | null;
50
+ downloads: number;
51
+ recent_downloads: number;
52
+ max_version: string;
53
+ repository: string | null;
54
+ homepage: string | null;
55
+ documentation: string | null;
56
+ categories: string[];
57
+ keywords: string[];
58
+ created_at: string;
59
+ updated_at: string;
60
+ };
61
+ versions: Array<{
62
+ num: string;
63
+ downloads: number;
64
+ created_at: string;
65
+ license: string | null;
66
+ rust_version: string | null;
67
+ }>;
68
+ };
69
+
70
+ try {
71
+ data = JSON.parse(result.content);
72
+ } catch {
73
+ return null;
74
+ }
75
+
76
+ const crate = data.crate;
77
+ const latestVersion = data.versions?.[0];
78
+
79
+ // Format download counts
80
+ const formatDownloads = (n: number): string =>
81
+ n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(1)}K` : String(n);
82
+
83
+ let md = `# ${crate.name}\n\n`;
84
+ if (crate.description) md += `${crate.description}\n\n`;
85
+
86
+ md += `**Latest:** ${crate.max_version}`;
87
+ if (latestVersion?.license) md += ` · **License:** ${latestVersion.license}`;
88
+ if (latestVersion?.rust_version) md += ` · **MSRV:** ${latestVersion.rust_version}`;
89
+ md += "\n";
90
+ md += `**Downloads:** ${formatDownloads(crate.downloads)} total · ${formatDownloads(crate.recent_downloads)} recent\n\n`;
91
+
92
+ if (crate.repository) md += `**Repository:** ${crate.repository}\n`;
93
+ if (crate.homepage && crate.homepage !== crate.repository) md += `**Homepage:** ${crate.homepage}\n`;
94
+ if (crate.documentation) md += `**Docs:** ${crate.documentation}\n`;
95
+ if (crate.keywords?.length) md += `**Keywords:** ${crate.keywords.join(", ")}\n`;
96
+ if (crate.categories?.length) md += `**Categories:** ${crate.categories.join(", ")}\n`;
97
+
98
+ // Show recent versions
99
+ if (data.versions?.length > 0) {
100
+ md += `\n## Recent Versions\n\n`;
101
+ for (const ver of data.versions.slice(0, 5)) {
102
+ const date = ver.created_at.split("T")[0];
103
+ md += `- **${ver.num}** (${date}) - ${formatDownloads(ver.downloads)} downloads\n`;
104
+ }
105
+ }
106
+
107
+ // Try to fetch README from docs.rs or repository
108
+ const docsRsUrl = `https://docs.rs/crate/${crateName}/${crate.max_version}/source/README.md`;
109
+ const readmeResult = await loadPage(docsRsUrl, { timeout: Math.min(timeout, 5), signal });
110
+ if (readmeResult.ok && readmeResult.content.length > 100 && !looksLikeHtml(readmeResult.content)) {
111
+ md += `\n---\n\n## README\n\n${readmeResult.content}\n`;
112
+ }
113
+
114
+ const output = finalizeOutput(md);
115
+ return {
116
+ url,
117
+ finalUrl: url,
118
+ contentType: "text/markdown",
119
+ method: "crates.io",
120
+ content: output.content,
121
+ fetchedAt,
122
+ truncated: output.truncated,
123
+ notes: ["Fetched via crates.io API"],
124
+ };
125
+ } catch {}
126
+
127
+ return null;
128
+ };
@@ -0,0 +1,149 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ interface CrossrefAuthor {
5
+ given?: string;
6
+ family?: string;
7
+ name?: string;
8
+ }
9
+
10
+ interface CrossrefDate {
11
+ "date-parts"?: number[][];
12
+ }
13
+
14
+ interface CrossrefMessage {
15
+ title?: string[];
16
+ author?: CrossrefAuthor[];
17
+ "container-title"?: string[];
18
+ "short-container-title"?: string[];
19
+ publisher?: string;
20
+ published?: CrossrefDate;
21
+ "published-print"?: CrossrefDate;
22
+ "published-online"?: CrossrefDate;
23
+ issued?: CrossrefDate;
24
+ created?: CrossrefDate;
25
+ DOI?: string;
26
+ abstract?: string;
27
+ type?: string;
28
+ }
29
+
30
+ interface CrossrefResponse {
31
+ message?: CrossrefMessage;
32
+ }
33
+
34
+ const DOI_HOSTS = new Set(["doi.org", "dx.doi.org", "www.doi.org"]);
35
+
36
+ function extractDoi(pathname: string): string | null {
37
+ const raw = pathname.replace(/^\/+/, "");
38
+ if (!raw) return null;
39
+ return decodeURIComponent(raw);
40
+ }
41
+
42
+ function formatAuthors(authors?: CrossrefAuthor[]): string | null {
43
+ if (!authors || authors.length === 0) return null;
44
+ const names = authors
45
+ .map((author) => {
46
+ if (author.name) return author.name;
47
+ const parts = [author.given, author.family].filter(Boolean);
48
+ return parts.length > 0 ? parts.join(" ") : null;
49
+ })
50
+ .filter((name): name is string => Boolean(name));
51
+ if (names.length === 0) return null;
52
+ return names.join(", ");
53
+ }
54
+
55
+ function formatDate(date?: CrossrefDate): string | null {
56
+ const parts = date?.["date-parts"]?.[0];
57
+ if (!parts || parts.length === 0) return null;
58
+ const [year, month, day] = parts;
59
+ if (!year) return null;
60
+ const formatted = [
61
+ String(year),
62
+ month ? String(month).padStart(2, "0") : "",
63
+ day ? String(day).padStart(2, "0") : "",
64
+ ].filter(Boolean);
65
+ return formatted.join("-");
66
+ }
67
+
68
+ function formatAbstract(abstract?: string): string | null {
69
+ if (!abstract) return null;
70
+ const normalized = abstract.replace(/<\/?jats:p[^>]*>/g, (match) => (match.startsWith("</") ? "</p>" : "<p>"));
71
+ const markdown = htmlToBasicMarkdown(normalized);
72
+ return markdown.trim().length > 0 ? markdown : null;
73
+ }
74
+
75
+ export const handleCrossref: SpecialHandler = async (
76
+ url: string,
77
+ timeout: number,
78
+ signal?: AbortSignal,
79
+ ): Promise<RenderResult | null> => {
80
+ try {
81
+ const parsed = new URL(url);
82
+ if (!DOI_HOSTS.has(parsed.hostname.toLowerCase())) return null;
83
+
84
+ const doi = extractDoi(parsed.pathname);
85
+ if (!doi) return null;
86
+
87
+ const fetchedAt = new Date().toISOString();
88
+ const apiUrl = `https://api.crossref.org/works/${encodeURIComponent(doi)}`;
89
+ const result = await loadPage(apiUrl, {
90
+ timeout,
91
+ signal,
92
+ headers: {
93
+ Accept: "application/json",
94
+ },
95
+ });
96
+
97
+ if (!result.ok) return null;
98
+
99
+ let data: CrossrefResponse;
100
+ try {
101
+ data = JSON.parse(result.content);
102
+ } catch {
103
+ return null;
104
+ }
105
+
106
+ const message = data.message;
107
+ if (!message) return null;
108
+
109
+ const title = message.title?.[0]?.trim() || "CrossRef Record";
110
+ const authors = formatAuthors(message.author);
111
+ const journal = message["container-title"]?.[0] || message["short-container-title"]?.[0];
112
+ const publisher = message.publisher;
113
+ const published =
114
+ formatDate(message.published) ||
115
+ formatDate(message["published-print"]) ||
116
+ formatDate(message["published-online"]) ||
117
+ formatDate(message.issued) ||
118
+ formatDate(message.created);
119
+ const doiValue = message.DOI || doi;
120
+ const abstract = formatAbstract(message.abstract);
121
+ const type = message.type?.replace(/-/g, " ");
122
+
123
+ let md = `# ${title}\n\n`;
124
+ if (authors) md += `**Authors:** ${authors}\n`;
125
+ if (journal) md += `**Journal:** ${journal}\n`;
126
+ if (publisher) md += `**Publisher:** ${publisher}\n`;
127
+ if (published) md += `**Published:** ${published}\n`;
128
+ md += `**DOI:** ${doiValue}\n`;
129
+ if (type) md += `**Type:** ${type}\n`;
130
+ md += "\n---\n\n";
131
+ md += "## Abstract\n\n";
132
+ md += abstract || "No abstract available.";
133
+ md += "\n";
134
+
135
+ const output = finalizeOutput(md);
136
+ return {
137
+ url,
138
+ finalUrl: url,
139
+ contentType: "text/markdown",
140
+ method: "crossref",
141
+ content: output.content,
142
+ fetchedAt,
143
+ truncated: output.truncated,
144
+ notes: ["Fetched via CrossRef API"],
145
+ };
146
+ } catch {}
147
+
148
+ return null;
149
+ };