@oh-my-pi/pi-coding-agent 3.30.0 → 3.32.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 (158) hide show
  1. package/CHANGELOG.md +85 -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 +367 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/sdk.ts +10 -2
  9. package/src/core/session-manager.ts +158 -246
  10. package/src/core/session-storage.ts +379 -0
  11. package/src/core/settings-manager.ts +155 -4
  12. package/src/core/slash-commands.ts +39 -13
  13. package/src/core/system-prompt.ts +62 -64
  14. package/src/core/tools/ask.ts +5 -4
  15. package/src/core/tools/bash-interceptor.ts +26 -61
  16. package/src/core/tools/bash.ts +13 -8
  17. package/src/core/tools/edit-diff.ts +11 -4
  18. package/src/core/tools/edit.ts +7 -13
  19. package/src/core/tools/find.ts +111 -50
  20. package/src/core/tools/gemini-image.ts +128 -147
  21. package/src/core/tools/grep.ts +397 -415
  22. package/src/core/tools/index.test.ts +5 -1
  23. package/src/core/tools/index.ts +8 -4
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +84 -19
  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 +72 -35
  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 +150 -74
  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/commands.ts +4 -0
  37. package/src/core/tools/task/executor.ts +94 -83
  38. package/src/core/tools/task/index.ts +130 -92
  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 +15 -6
  42. package/src/core/tools/task/worker.ts +124 -89
  43. package/src/core/tools/web-fetch.ts +112 -377
  44. package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
  45. package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
  46. package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
  47. package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
  48. package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
  49. package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
  50. package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
  51. package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
  52. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  53. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  54. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  55. package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
  56. package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
  57. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  58. package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
  59. package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
  60. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  61. package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
  62. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  63. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  64. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  65. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
  66. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
  67. package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
  68. package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
  69. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
  70. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
  71. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
  72. package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
  73. package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
  74. package/src/core/tools/web-scrapers/index.ts +250 -0
  75. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  76. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  77. package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
  78. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
  79. package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
  80. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
  81. package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
  82. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  83. package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
  84. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
  85. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
  86. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  87. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  88. package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
  89. package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
  90. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  91. package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
  92. package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
  93. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
  94. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
  95. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
  96. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  97. package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
  98. package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
  99. package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
  100. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
  101. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
  102. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  103. package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
  104. package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
  105. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  106. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  107. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  108. package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
  109. package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
  110. package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
  111. package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
  112. package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
  113. package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
  114. package/src/core/tools/web-scrapers/utils.ts +162 -0
  115. package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
  116. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  117. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  118. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
  119. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
  120. package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
  121. package/src/core/tools/write.ts +21 -18
  122. package/src/core/voice.ts +3 -2
  123. package/src/lib/worktree/collapse.ts +2 -1
  124. package/src/lib/worktree/git.ts +2 -18
  125. package/src/main.ts +59 -3
  126. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  127. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  128. package/src/modes/interactive/components/hook-editor.ts +2 -1
  129. package/src/modes/interactive/components/model-selector.ts +19 -4
  130. package/src/modes/interactive/interactive-mode.ts +41 -63
  131. package/src/modes/interactive/theme/theme.ts +58 -58
  132. package/src/modes/rpc/rpc-mode.ts +10 -9
  133. package/src/prompts/review-request.md +27 -0
  134. package/src/prompts/reviewer.md +64 -68
  135. package/src/prompts/tools/output.md +22 -3
  136. package/src/prompts/tools/task.md +32 -33
  137. package/src/utils/clipboard.ts +2 -1
  138. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  139. package/src/core/tools/web-fetch-handlers/index.ts +0 -69
  140. package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
  141. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
  142. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
  143. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
  144. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
  145. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
  146. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
  147. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
  148. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
  149. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
  150. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
  151. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
  152. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
  153. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
  154. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
  155. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
  156. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
  157. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
  158. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
@@ -0,0 +1,267 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface OllamaTagDetails {
5
+ parent_model?: string;
6
+ format?: string;
7
+ family?: string;
8
+ families?: string[] | null;
9
+ parameter_size?: string;
10
+ quantization_level?: string;
11
+ }
12
+
13
+ interface OllamaTagModel {
14
+ name?: string;
15
+ model?: string;
16
+ modified_at?: string;
17
+ size?: number;
18
+ digest?: string;
19
+ details?: OllamaTagDetails;
20
+ }
21
+
22
+ interface OllamaTagsResponse {
23
+ models?: OllamaTagModel[];
24
+ }
25
+
26
+ const VALID_HOSTNAMES = new Set(["ollama.com", "www.ollama.com"]);
27
+ const RESERVED_ROOTS = new Set([
28
+ "models",
29
+ "blog",
30
+ "docs",
31
+ "download",
32
+ "cloud",
33
+ "signin",
34
+ "signout",
35
+ "search",
36
+ "api",
37
+ "terms",
38
+ "privacy",
39
+ "license",
40
+ "settings",
41
+ ]);
42
+
43
+ function decodeHtmlEntities(value: string): string {
44
+ return value
45
+ .replace(/&/g, "&")
46
+ .replace(/&lt;/g, "<")
47
+ .replace(/&gt;/g, ">")
48
+ .replace(/&quot;/g, '"')
49
+ .replace(/&#39;/g, "'")
50
+ .replace(/&nbsp;/g, " ");
51
+ }
52
+
53
+ function extractMetaDescription(html: string): string | null {
54
+ const patterns = [
55
+ /<meta[^>]+name=["']description["'][^>]*content=["']([^"']+)["']/i,
56
+ /<meta[^>]+property=["']og:description["'][^>]*content=["']([^"']+)["']/i,
57
+ /<meta[^>]+property=["']twitter:description["'][^>]*content=["']([^"']+)["']/i,
58
+ ];
59
+
60
+ for (const pattern of patterns) {
61
+ const match = html.match(pattern);
62
+ if (match?.[1]) {
63
+ return decodeHtmlEntities(match[1].trim());
64
+ }
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ function extractParameterSizes(html: string): string[] {
71
+ const sizes = new Set<string>();
72
+ const pattern = /x-test-size[^>]*>([^<]+)<\/span>/gi;
73
+ let match = pattern.exec(html);
74
+ while (match) {
75
+ const raw = match[1]?.trim();
76
+ if (raw) {
77
+ sizes.add(raw.toUpperCase());
78
+ }
79
+ match = pattern.exec(html);
80
+ }
81
+
82
+ return Array.from(sizes);
83
+ }
84
+
85
+ function extractTagsFromHtml(html: string, baseRef: string): string[] {
86
+ const tags = new Set<string>();
87
+ const pattern = /href=["']\/library\/([^"']+)["']/gi;
88
+ let match = pattern.exec(html);
89
+ while (match) {
90
+ const raw = match[1]?.trim();
91
+ if (raw) {
92
+ const decoded = decodeHtmlEntities(raw);
93
+ if (decoded === baseRef || decoded.startsWith(`${baseRef}:`)) {
94
+ tags.add(decoded);
95
+ }
96
+ }
97
+ match = pattern.exec(html);
98
+ }
99
+
100
+ return Array.from(tags);
101
+ }
102
+
103
+ function formatSize(bytes: number): string {
104
+ if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)}GB`;
105
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)}MB`;
106
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)}KB`;
107
+ return `${bytes}B`;
108
+ }
109
+
110
+ function buildModelPath(parts: string[]): string {
111
+ return parts.map((part) => encodeURIComponent(part)).join("/");
112
+ }
113
+
114
+ function parseOllamaUrl(url: string): { modelRef: string; baseRef: string; pageUrl: string } | null {
115
+ try {
116
+ const parsed = new URL(url);
117
+ if (!VALID_HOSTNAMES.has(parsed.hostname)) return null;
118
+
119
+ const parts = parsed.pathname.split("/").filter(Boolean);
120
+ if (parts.length === 0) return null;
121
+
122
+ if (parts[0] === "library" && parts.length >= 2) {
123
+ const modelRef = decodeURIComponent(parts[1]);
124
+ const baseRef = modelRef.split(":")[0] ?? modelRef;
125
+ const pageUrl = `${parsed.origin}/${buildModelPath(["library", baseRef])}`;
126
+ return { modelRef, baseRef, pageUrl };
127
+ }
128
+
129
+ if (parts.length >= 2 && !RESERVED_ROOTS.has(parts[0])) {
130
+ const namespace = decodeURIComponent(parts[0]);
131
+ const model = decodeURIComponent(parts[1]);
132
+ const modelBase = model.split(":")[0] ?? model;
133
+ const modelRef = `${namespace}/${model}`;
134
+ const baseRef = `${namespace}/${modelBase}`;
135
+ const pageUrl = `${parsed.origin}/${buildModelPath([namespace, modelBase])}`;
136
+ return { modelRef, baseRef, pageUrl };
137
+ }
138
+ } catch {}
139
+
140
+ return null;
141
+ }
142
+
143
+ function sortTags(tags: string[]): string[] {
144
+ return tags.sort((a, b) => {
145
+ const aLatest = a.endsWith(":latest");
146
+ const bLatest = b.endsWith(":latest");
147
+ if (aLatest && !bLatest) return -1;
148
+ if (!aLatest && bLatest) return 1;
149
+ return a.localeCompare(b);
150
+ });
151
+ }
152
+
153
+ function formatTagList(tags: string[], maxItems: number): string {
154
+ const limited = tags.slice(0, maxItems);
155
+ const formatted = limited.map((tag) => `\`${tag}\``).join(", ");
156
+ if (tags.length > maxItems) {
157
+ return `${formatted} (and ${tags.length - maxItems} more)`;
158
+ }
159
+ return formatted;
160
+ }
161
+
162
+ function collectParameterSizes(models: OllamaTagModel[], htmlSizes: string[]): string[] {
163
+ const sizes = new Set<string>();
164
+ for (const model of models) {
165
+ const param = model.details?.parameter_size?.trim();
166
+ if (param) sizes.add(param.toUpperCase());
167
+ }
168
+ for (const size of htmlSizes) {
169
+ sizes.add(size);
170
+ }
171
+ return Array.from(sizes);
172
+ }
173
+
174
+ export const handleOllama: SpecialHandler = async (
175
+ url: string,
176
+ timeout: number,
177
+ signal?: AbortSignal,
178
+ ): Promise<RenderResult | null> => {
179
+ try {
180
+ const parsed = parseOllamaUrl(url);
181
+ if (!parsed) return null;
182
+
183
+ const { modelRef, baseRef, pageUrl } = parsed;
184
+ const fetchedAt = new Date().toISOString();
185
+
186
+ const tagsUrl = "https://ollama.com/api/tags";
187
+ const [tagsResult, pageResult] = await Promise.all([
188
+ loadPage(tagsUrl, { timeout, signal, headers: { Accept: "application/json" } }),
189
+ loadPage(pageUrl, { timeout, signal }),
190
+ ]);
191
+
192
+ let tagsData: OllamaTagsResponse | null = null;
193
+ if (tagsResult.ok) {
194
+ try {
195
+ tagsData = JSON.parse(tagsResult.content) as OllamaTagsResponse;
196
+ } catch {
197
+ tagsData = null;
198
+ }
199
+ }
200
+
201
+ const html = pageResult.ok ? pageResult.content : "";
202
+ const description = html ? extractMetaDescription(html) : null;
203
+ const htmlParameterSizes = html ? extractParameterSizes(html) : [];
204
+ const htmlTags = html ? extractTagsFromHtml(html, baseRef) : [];
205
+
206
+ const baseLower = baseRef.toLowerCase();
207
+ const models = tagsData?.models ?? [];
208
+ const matchingModels = models.filter((model) => {
209
+ const name = (model.model ?? model.name ?? "").toLowerCase();
210
+ return name === baseLower || name.startsWith(`${baseLower}:`);
211
+ });
212
+
213
+ const tagRef = modelRef.includes(":") ? modelRef : null;
214
+ const selectedTag = tagRef ? matchingModels.find((model) => (model.model ?? model.name ?? "") === tagRef) : null;
215
+
216
+ const availableTagsRaw = matchingModels
217
+ .map((model) => model.model ?? model.name ?? "")
218
+ .filter((tag) => tag.length > 0);
219
+ const availableTags = sortTags(Array.from(new Set(availableTagsRaw)));
220
+
221
+ const fallbackTags = sortTags(Array.from(new Set(htmlTags)));
222
+ const tagsToUse = availableTags.length > 0 ? availableTags : fallbackTags;
223
+
224
+ const parameterSizes = collectParameterSizes(selectedTag ? [selectedTag] : matchingModels, htmlParameterSizes);
225
+
226
+ const sizes = matchingModels
227
+ .map((model) => model.size)
228
+ .filter((size): size is number => typeof size === "number");
229
+ let sizeLine: string | null = null;
230
+
231
+ if (selectedTag?.size) {
232
+ sizeLine = formatSize(selectedTag.size);
233
+ } else if (sizes.length > 0) {
234
+ const minSize = Math.min(...sizes);
235
+ const maxSize = Math.max(...sizes);
236
+ sizeLine = minSize === maxSize ? formatSize(minSize) : `${formatSize(minSize)} - ${formatSize(maxSize)}`;
237
+ }
238
+
239
+ let md = `# ${baseRef}\n\n`;
240
+ if (description) md += `${description}\n\n`;
241
+
242
+ md += `**Model:** ${baseRef}\n`;
243
+ if (tagRef) md += `**Tag:** ${tagRef}\n`;
244
+ if (parameterSizes.length > 0) md += `**Parameters:** ${parameterSizes.join(", ")}\n`;
245
+ if (sizeLine) {
246
+ const label = sizeLine.includes(" - ") ? "Size Range" : "Size";
247
+ md += `**${label}:** ${sizeLine}\n`;
248
+ }
249
+ if (tagsToUse.length > 0) {
250
+ md += `**Available Tags:** ${formatTagList(tagsToUse, 40)}\n`;
251
+ }
252
+
253
+ const output = finalizeOutput(md);
254
+ return {
255
+ url,
256
+ finalUrl: pageResult.ok ? pageResult.finalUrl : url,
257
+ contentType: "text/markdown",
258
+ method: "ollama",
259
+ content: output.content,
260
+ fetchedAt,
261
+ truncated: output.truncated,
262
+ notes: ["Fetched via Ollama API"],
263
+ };
264
+ } catch {}
265
+
266
+ return null;
267
+ };
@@ -0,0 +1,119 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface OpenVsxFileLinks {
5
+ readme?: string;
6
+ }
7
+
8
+ interface OpenVsxExtension {
9
+ name: string;
10
+ namespace: string;
11
+ version: string;
12
+ displayName?: string;
13
+ description?: string;
14
+ downloadCount?: number;
15
+ averageRating?: number;
16
+ reviewCount?: number;
17
+ repository?: string | { url?: string };
18
+ license?: string;
19
+ categories?: string[];
20
+ homepage?: string;
21
+ files?: OpenVsxFileLinks;
22
+ }
23
+
24
+ /**
25
+ * Handle Open VSX URLs via their API
26
+ */
27
+ export const handleOpenVsx: SpecialHandler = async (
28
+ url: string,
29
+ timeout: number,
30
+ signal?: AbortSignal,
31
+ ): Promise<RenderResult | null> => {
32
+ try {
33
+ const parsed = new URL(url);
34
+ if (parsed.hostname !== "open-vsx.org" && parsed.hostname !== "www.open-vsx.org") return null;
35
+
36
+ const match = parsed.pathname.match(/^\/extension\/([^/]+)\/([^/]+)(?:\/([^/]+))?\/?$/);
37
+ if (!match) return null;
38
+
39
+ const namespace = decodeURIComponent(match[1]);
40
+ const extension = decodeURIComponent(match[2]);
41
+ const version = match[3] ? decodeURIComponent(match[3]) : null;
42
+
43
+ const fetchedAt = new Date().toISOString();
44
+ const baseUrl = `https://open-vsx.org/api/${encodeURIComponent(namespace)}/${encodeURIComponent(extension)}`;
45
+ const apiUrl = version ? `${baseUrl}/${encodeURIComponent(version)}` : baseUrl;
46
+
47
+ const result = await loadPage(apiUrl, { timeout, signal });
48
+ if (!result.ok) return null;
49
+
50
+ let data: OpenVsxExtension;
51
+ try {
52
+ data = JSON.parse(result.content);
53
+ } catch {
54
+ return null;
55
+ }
56
+
57
+ let readme: string | null = null;
58
+ const readmeUrl = data.files?.readme;
59
+ if (readmeUrl) {
60
+ try {
61
+ const readmeResult = await loadPage(readmeUrl, { timeout: Math.min(timeout, 10), signal });
62
+ if (readmeResult.ok) readme = readmeResult.content;
63
+ } catch {}
64
+ }
65
+
66
+ const displayName = data.displayName || data.name || `${namespace}/${extension}`;
67
+ const displayNamespace = data.namespace || namespace;
68
+ const displayVersion = data.version || version || "unknown";
69
+ const downloads = typeof data.downloadCount === "number" ? data.downloadCount : null;
70
+ const rating = typeof data.averageRating === "number" ? data.averageRating : null;
71
+ const reviews = typeof data.reviewCount === "number" ? data.reviewCount : null;
72
+ const repository = typeof data.repository === "string" ? data.repository : data.repository?.url || null;
73
+
74
+ let md = `# ${displayName}\n\n`;
75
+ if (data.description) md += `${data.description}\n\n`;
76
+
77
+ md += `**Namespace:** ${displayNamespace}\n`;
78
+ md += `**Extension:** ${data.name || extension}\n`;
79
+ md += `**Version:** ${displayVersion}`;
80
+ if (data.license) md += ` | **License:** ${data.license}`;
81
+ md += "\n";
82
+
83
+ if (downloads !== null) {
84
+ md += `**Downloads:** ${formatCount(downloads)}\n`;
85
+ }
86
+
87
+ if (rating !== null) {
88
+ const reviewSuffix = reviews !== null ? ` (${reviews} reviews)` : "";
89
+ md += `**Rating:** ${rating}${reviewSuffix}\n`;
90
+ }
91
+
92
+ if (repository) {
93
+ const cleanedRepo = repository.replace(/^git\+/, "").replace(/\.git$/, "");
94
+ md += `**Repository:** ${cleanedRepo}\n`;
95
+ }
96
+
97
+ if (data.homepage) md += `**Homepage:** ${data.homepage}\n`;
98
+ if (data.categories?.length) md += `**Categories:** ${data.categories.join(", ")}\n`;
99
+
100
+ if (readme) {
101
+ md += "\n---\n\n## README\n\n";
102
+ md += `${readme}\n`;
103
+ }
104
+
105
+ const output = finalizeOutput(md);
106
+ return {
107
+ url,
108
+ finalUrl: url,
109
+ contentType: "text/markdown",
110
+ method: "open-vsx",
111
+ content: output.content,
112
+ fetchedAt,
113
+ truncated: output.truncated,
114
+ notes: ["Fetched via Open VSX API"],
115
+ };
116
+ } catch {}
117
+
118
+ return null;
119
+ };
@@ -81,6 +81,7 @@ interface ApiResponse {
81
81
  export const handleOpenCorporates: SpecialHandler = async (
82
82
  url: string,
83
83
  timeout: number,
84
+ signal?: AbortSignal,
84
85
  ): Promise<RenderResult | null> => {
85
86
  try {
86
87
  const parsed = new URL(url);
@@ -100,6 +101,7 @@ export const handleOpenCorporates: SpecialHandler = async (
100
101
  const result = await loadPage(apiUrl, {
101
102
  timeout,
102
103
  headers: { Accept: "application/json" },
104
+ signal,
103
105
  });
104
106
 
105
107
  if (!result.ok) return null;
@@ -67,7 +67,11 @@ interface OpenLibraryBooksApiResponse {
67
67
  /**
68
68
  * Handle Open Library URLs via their API
69
69
  */
70
- export const handleOpenLibrary: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
70
+ export const handleOpenLibrary: SpecialHandler = async (
71
+ url: string,
72
+ timeout: number,
73
+ signal?: AbortSignal,
74
+ ): Promise<RenderResult | null> => {
71
75
  try {
72
76
  const parsed = new URL(url);
73
77
  if (!parsed.hostname.includes("openlibrary.org")) return null;
@@ -83,11 +87,11 @@ export const handleOpenLibrary: SpecialHandler = async (url: string, timeout: nu
83
87
  let md: string | null = null;
84
88
 
85
89
  if (workMatch) {
86
- md = await fetchWork(workMatch[1], timeout);
90
+ md = await fetchWork(workMatch[1], timeout, signal);
87
91
  } else if (editionMatch) {
88
- md = await fetchEdition(editionMatch[1], timeout);
92
+ md = await fetchEdition(editionMatch[1], timeout, signal);
89
93
  } else if (isbnMatch) {
90
- md = await fetchByIsbn(isbnMatch[1], timeout);
94
+ md = await fetchByIsbn(isbnMatch[1], timeout, signal);
91
95
  }
92
96
 
93
97
  if (!md) return null;
@@ -108,9 +112,9 @@ export const handleOpenLibrary: SpecialHandler = async (url: string, timeout: nu
108
112
  return null;
109
113
  };
110
114
 
111
- async function fetchWork(workId: string, timeout: number): Promise<string | null> {
115
+ async function fetchWork(workId: string, timeout: number, signal?: AbortSignal): Promise<string | null> {
112
116
  const apiUrl = `https://openlibrary.org/works/${workId}.json`;
113
- const result = await loadPage(apiUrl, { timeout });
117
+ const result = await loadPage(apiUrl, { timeout, signal });
114
118
  if (!result.ok) return null;
115
119
 
116
120
  let work: OpenLibraryWork;
@@ -127,6 +131,7 @@ async function fetchWork(workId: string, timeout: number): Promise<string | null
127
131
  const authorNames = await fetchAuthorNames(
128
132
  work.authors.map((a) => a.author.key),
129
133
  timeout,
134
+ signal,
130
135
  );
131
136
  if (authorNames.length) {
132
137
  md += `**Authors:** ${authorNames.join(", ")}\n`;
@@ -157,9 +162,9 @@ async function fetchWork(workId: string, timeout: number): Promise<string | null
157
162
  return md;
158
163
  }
159
164
 
160
- async function fetchEdition(editionId: string, timeout: number): Promise<string | null> {
165
+ async function fetchEdition(editionId: string, timeout: number, signal?: AbortSignal): Promise<string | null> {
161
166
  const apiUrl = `https://openlibrary.org/books/${editionId}.json`;
162
- const result = await loadPage(apiUrl, { timeout });
167
+ const result = await loadPage(apiUrl, { timeout, signal });
163
168
  if (!result.ok) return null;
164
169
 
165
170
  let edition: OpenLibraryEdition;
@@ -176,6 +181,7 @@ async function fetchEdition(editionId: string, timeout: number): Promise<string
176
181
  const authorNames = await fetchAuthorNames(
177
182
  edition.authors.map((a) => a.key),
178
183
  timeout,
184
+ signal,
179
185
  );
180
186
  if (authorNames.length) {
181
187
  md += `**Authors:** ${authorNames.join(", ")}\n`;
@@ -225,9 +231,9 @@ async function fetchEdition(editionId: string, timeout: number): Promise<string
225
231
  return md;
226
232
  }
227
233
 
228
- async function fetchByIsbn(isbn: string, timeout: number): Promise<string | null> {
234
+ async function fetchByIsbn(isbn: string, timeout: number, signal?: AbortSignal): Promise<string | null> {
229
235
  const apiUrl = `https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`;
230
- const result = await loadPage(apiUrl, { timeout });
236
+ const result = await loadPage(apiUrl, { timeout, signal });
231
237
  if (!result.ok) return null;
232
238
 
233
239
  let data: OpenLibraryBooksApiResponse;
@@ -281,7 +287,7 @@ async function fetchByIsbn(isbn: string, timeout: number): Promise<string | null
281
287
  return md;
282
288
  }
283
289
 
284
- async function fetchAuthorNames(authorKeys: string[], timeout: number): Promise<string[]> {
290
+ async function fetchAuthorNames(authorKeys: string[], timeout: number, signal?: AbortSignal): Promise<string[]> {
285
291
  const names: string[] = [];
286
292
 
287
293
  // Fetch authors in parallel (limit to first 5)
@@ -289,7 +295,7 @@ async function fetchAuthorNames(authorKeys: string[], timeout: number): Promise<
289
295
  const authorKey = key.startsWith("/authors/") ? key : `/authors/${key}`;
290
296
  const apiUrl = `https://openlibrary.org${authorKey}.json`;
291
297
  try {
292
- const result = await loadPage(apiUrl, { timeout: Math.min(timeout, 5) });
298
+ const result = await loadPage(apiUrl, { timeout: Math.min(timeout, 5), signal });
293
299
  if (result.ok) {
294
300
  const author = JSON.parse(result.content) as { name?: string };
295
301
  return author.name || null;