@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,243 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface CvssV31 {
5
+ baseScore: number;
6
+ baseSeverity: string;
7
+ vectorString: string;
8
+ }
9
+
10
+ interface CvssV2 {
11
+ baseScore: number;
12
+ severity?: string;
13
+ vectorString: string;
14
+ }
15
+
16
+ interface CvssMetric {
17
+ cvssData: CvssV31 | CvssV2;
18
+ exploitabilityScore?: number;
19
+ impactScore?: number;
20
+ }
21
+
22
+ interface CpeMatch {
23
+ criteria: string;
24
+ vulnerable: boolean;
25
+ versionStartIncluding?: string;
26
+ versionEndExcluding?: string;
27
+ versionEndIncluding?: string;
28
+ }
29
+
30
+ interface Configuration {
31
+ nodes?: Array<{
32
+ operator?: string;
33
+ cpeMatch?: CpeMatch[];
34
+ }>;
35
+ }
36
+
37
+ interface Reference {
38
+ url: string;
39
+ source?: string;
40
+ tags?: string[];
41
+ }
42
+
43
+ interface Description {
44
+ lang: string;
45
+ value: string;
46
+ }
47
+
48
+ interface Weakness {
49
+ description: Description[];
50
+ }
51
+
52
+ interface CveItem {
53
+ id: string;
54
+ sourceIdentifier?: string;
55
+ published: string;
56
+ lastModified: string;
57
+ vulnStatus?: string;
58
+ descriptions: Description[];
59
+ metrics?: {
60
+ cvssMetricV31?: CvssMetric[];
61
+ cvssMetricV30?: CvssMetric[];
62
+ cvssMetricV2?: CvssMetric[];
63
+ };
64
+ weaknesses?: Weakness[];
65
+ configurations?: Configuration[];
66
+ references?: Reference[];
67
+ }
68
+
69
+ interface NvdResponse {
70
+ vulnerabilities?: Array<{ cve: CveItem }>;
71
+ }
72
+
73
+ /**
74
+ * Handle NVD (National Vulnerability Database) CVE URLs
75
+ */
76
+ export const handleNvd: SpecialHandler = async (
77
+ url: string,
78
+ timeout: number,
79
+ signal?: AbortSignal,
80
+ ): Promise<RenderResult | null> => {
81
+ try {
82
+ const parsed = new URL(url);
83
+ if (!parsed.hostname.includes("nvd.nist.gov")) return null;
84
+
85
+ // Extract CVE ID from /vuln/detail/{CVE-ID}
86
+ const match = parsed.pathname.match(/\/vuln\/detail\/(CVE-\d{4}-\d+)/i);
87
+ if (!match) return null;
88
+
89
+ const cveId = match[1].toUpperCase();
90
+ const fetchedAt = new Date().toISOString();
91
+
92
+ // Fetch from NVD API
93
+ const apiUrl = `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${cveId}`;
94
+ const result = await loadPage(apiUrl, {
95
+ timeout,
96
+ headers: { Accept: "application/json" },
97
+ signal,
98
+ });
99
+
100
+ if (!result.ok) return null;
101
+
102
+ let data: NvdResponse;
103
+ try {
104
+ data = JSON.parse(result.content);
105
+ } catch {
106
+ return null;
107
+ }
108
+
109
+ const vuln = data.vulnerabilities?.[0]?.cve;
110
+ if (!vuln) return null;
111
+
112
+ let md = `# ${vuln.id}\n\n`;
113
+
114
+ // Status and dates
115
+ if (vuln.vulnStatus) {
116
+ md += `**Status:** ${vuln.vulnStatus}\n`;
117
+ }
118
+ md += `**Published:** ${formatDate(vuln.published)}`;
119
+ md += ` · **Modified:** ${formatDate(vuln.lastModified)}\n\n`;
120
+
121
+ // Description
122
+ const desc = vuln.descriptions.find((d) => d.lang === "en")?.value;
123
+ if (desc) {
124
+ md += `## Description\n\n${desc}\n\n`;
125
+ }
126
+
127
+ // CVSS Scores
128
+ const cvss31 = vuln.metrics?.cvssMetricV31?.[0];
129
+ const cvss30 = vuln.metrics?.cvssMetricV30?.[0];
130
+ const cvss2 = vuln.metrics?.cvssMetricV2?.[0];
131
+
132
+ if (cvss31 || cvss30 || cvss2) {
133
+ md += `## CVSS Scores\n\n`;
134
+
135
+ if (cvss31) {
136
+ const data = cvss31.cvssData as CvssV31;
137
+ md += `### CVSS 3.1\n\n`;
138
+ md += `- **Base Score:** ${data.baseScore} (${data.baseSeverity})\n`;
139
+ md += `- **Vector:** \`${data.vectorString}\`\n`;
140
+ if (cvss31.exploitabilityScore !== undefined) {
141
+ md += `- **Exploitability:** ${cvss31.exploitabilityScore}\n`;
142
+ }
143
+ if (cvss31.impactScore !== undefined) {
144
+ md += `- **Impact:** ${cvss31.impactScore}\n`;
145
+ }
146
+ md += "\n";
147
+ }
148
+
149
+ if (cvss30 && !cvss31) {
150
+ const data = cvss30.cvssData as CvssV31;
151
+ md += `### CVSS 3.0\n\n`;
152
+ md += `- **Base Score:** ${data.baseScore} (${data.baseSeverity})\n`;
153
+ md += `- **Vector:** \`${data.vectorString}\`\n`;
154
+ md += "\n";
155
+ }
156
+
157
+ if (cvss2) {
158
+ const data = cvss2.cvssData as CvssV2;
159
+ md += `### CVSS 2.0\n\n`;
160
+ md += `- **Base Score:** ${data.baseScore}`;
161
+ if (data.severity) md += ` (${data.severity})`;
162
+ md += `\n- **Vector:** \`${data.vectorString}\`\n\n`;
163
+ }
164
+ }
165
+
166
+ // Weaknesses (CWE)
167
+ const cwes = vuln.weaknesses
168
+ ?.flatMap((w) => w.description)
169
+ .filter((d) => d.lang === "en" && d.value !== "NVD-CWE-Other" && d.value !== "NVD-CWE-noinfo");
170
+
171
+ if (cwes?.length) {
172
+ md += `## Weaknesses\n\n`;
173
+ for (const cwe of cwes) {
174
+ md += `- ${cwe.value}\n`;
175
+ }
176
+ md += "\n";
177
+ }
178
+
179
+ // Affected Products (CPE)
180
+ const cpes = extractCpes(vuln.configurations);
181
+ if (cpes.length > 0) {
182
+ md += `## Affected Products\n\n`;
183
+ const shown = cpes.slice(0, 20);
184
+ for (const cpe of shown) {
185
+ md += `- \`${cpe}\`\n`;
186
+ }
187
+ if (cpes.length > 20) {
188
+ md += `\n*...and ${cpes.length - 20} more*\n`;
189
+ }
190
+ md += "\n";
191
+ }
192
+
193
+ // References
194
+ if (vuln.references?.length) {
195
+ md += `## References\n\n`;
196
+ for (const ref of vuln.references.slice(0, 15)) {
197
+ const tags = ref.tags?.length ? ` (${ref.tags.join(", ")})` : "";
198
+ md += `- ${ref.url}${tags}\n`;
199
+ }
200
+ if (vuln.references.length > 15) {
201
+ md += `\n*...and ${vuln.references.length - 15} more references*\n`;
202
+ }
203
+ }
204
+
205
+ const output = finalizeOutput(md);
206
+ return {
207
+ url,
208
+ finalUrl: url,
209
+ contentType: "text/markdown",
210
+ method: "nvd",
211
+ content: output.content,
212
+ fetchedAt,
213
+ truncated: output.truncated,
214
+ notes: ["Fetched via NVD API"],
215
+ };
216
+ } catch {}
217
+
218
+ return null;
219
+ };
220
+
221
+ function formatDate(iso: string): string {
222
+ try {
223
+ return new Date(iso).toISOString().split("T")[0];
224
+ } catch {
225
+ return iso;
226
+ }
227
+ }
228
+
229
+ function extractCpes(configurations?: Configuration[]): string[] {
230
+ if (!configurations) return [];
231
+
232
+ const cpes: string[] = [];
233
+ for (const config of configurations) {
234
+ for (const node of config.nodes ?? []) {
235
+ for (const match of node.cpeMatch ?? []) {
236
+ if (match.vulnerable && match.criteria) {
237
+ cpes.push(match.criteria);
238
+ }
239
+ }
240
+ }
241
+ }
242
+ return Array.from(new Set(cpes));
243
+ }
@@ -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(/&amp;/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
+ };