@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.
- package/CHANGELOG.md +90 -0
- package/package.json +5 -5
- package/src/cli/args.ts +4 -0
- package/src/core/agent-session.ts +29 -2
- package/src/core/bash-executor.ts +2 -1
- package/src/core/custom-commands/bundled/review/index.ts +369 -14
- package/src/core/custom-commands/bundled/wt/index.ts +1 -1
- package/src/core/session-manager.ts +158 -246
- package/src/core/session-storage.ts +379 -0
- package/src/core/settings-manager.ts +155 -4
- package/src/core/system-prompt.ts +62 -64
- package/src/core/tools/ask.ts +5 -4
- package/src/core/tools/bash-interceptor.ts +26 -61
- package/src/core/tools/bash.ts +13 -8
- package/src/core/tools/complete.ts +2 -4
- package/src/core/tools/edit-diff.ts +11 -4
- package/src/core/tools/edit.ts +7 -13
- package/src/core/tools/find.ts +111 -50
- package/src/core/tools/gemini-image.ts +128 -147
- package/src/core/tools/grep.ts +397 -415
- package/src/core/tools/index.test.ts +5 -1
- package/src/core/tools/index.ts +6 -8
- package/src/core/tools/jtd-to-json-schema.ts +174 -196
- package/src/core/tools/ls.ts +12 -10
- package/src/core/tools/lsp/client.ts +58 -9
- package/src/core/tools/lsp/config.ts +205 -656
- package/src/core/tools/lsp/defaults.json +465 -0
- package/src/core/tools/lsp/index.ts +55 -32
- package/src/core/tools/lsp/rust-analyzer.ts +49 -10
- package/src/core/tools/lsp/types.ts +1 -0
- package/src/core/tools/lsp/utils.ts +1 -1
- package/src/core/tools/read.ts +152 -76
- package/src/core/tools/render-utils.ts +70 -10
- package/src/core/tools/review.ts +38 -126
- package/src/core/tools/task/artifacts.ts +5 -4
- package/src/core/tools/task/executor.ts +204 -67
- package/src/core/tools/task/index.ts +129 -92
- package/src/core/tools/task/name-generator.ts +1544 -214
- package/src/core/tools/task/parallel.ts +30 -3
- package/src/core/tools/task/render.ts +85 -39
- package/src/core/tools/task/types.ts +34 -11
- package/src/core/tools/task/worker.ts +152 -27
- package/src/core/tools/web-fetch.ts +220 -1657
- package/src/core/tools/web-scrapers/academic.test.ts +239 -0
- package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
- package/src/core/tools/web-scrapers/arxiv.ts +88 -0
- package/src/core/tools/web-scrapers/aur.ts +175 -0
- package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
- package/src/core/tools/web-scrapers/bluesky.ts +284 -0
- package/src/core/tools/web-scrapers/brew.ts +177 -0
- package/src/core/tools/web-scrapers/business.test.ts +82 -0
- package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
- package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
- package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
- package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
- package/src/core/tools/web-scrapers/clojars.ts +180 -0
- package/src/core/tools/web-scrapers/coingecko.ts +184 -0
- package/src/core/tools/web-scrapers/crates-io.ts +128 -0
- package/src/core/tools/web-scrapers/crossref.ts +149 -0
- package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
- package/src/core/tools/web-scrapers/devto.ts +177 -0
- package/src/core/tools/web-scrapers/discogs.ts +308 -0
- package/src/core/tools/web-scrapers/discourse.ts +221 -0
- package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
- package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
- package/src/core/tools/web-scrapers/fdroid.ts +158 -0
- package/src/core/tools/web-scrapers/finance-media.test.ts +144 -0
- package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
- package/src/core/tools/web-scrapers/flathub.ts +239 -0
- package/src/core/tools/web-scrapers/git-hosting.test.ts +272 -0
- package/src/core/tools/web-scrapers/github-gist.ts +68 -0
- package/src/core/tools/web-scrapers/github.ts +455 -0
- package/src/core/tools/web-scrapers/gitlab.ts +456 -0
- package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
- package/src/core/tools/web-scrapers/hackage.ts +94 -0
- package/src/core/tools/web-scrapers/hackernews.ts +208 -0
- package/src/core/tools/web-scrapers/hex.ts +121 -0
- package/src/core/tools/web-scrapers/huggingface.ts +385 -0
- package/src/core/tools/web-scrapers/iacr.ts +86 -0
- package/src/core/tools/web-scrapers/index.ts +250 -0
- package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
- package/src/core/tools/web-scrapers/lemmy.ts +220 -0
- package/src/core/tools/web-scrapers/lobsters.ts +186 -0
- package/src/core/tools/web-scrapers/mastodon.ts +310 -0
- package/src/core/tools/web-scrapers/maven.ts +152 -0
- package/src/core/tools/web-scrapers/mdn.ts +174 -0
- package/src/core/tools/web-scrapers/media.test.ts +138 -0
- package/src/core/tools/web-scrapers/metacpan.ts +253 -0
- package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
- package/src/core/tools/web-scrapers/npm.ts +114 -0
- package/src/core/tools/web-scrapers/nuget.ts +205 -0
- package/src/core/tools/web-scrapers/nvd.ts +243 -0
- package/src/core/tools/web-scrapers/ollama.ts +267 -0
- package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
- package/src/core/tools/web-scrapers/opencorporates.ts +275 -0
- package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
- package/src/core/tools/web-scrapers/orcid.ts +299 -0
- package/src/core/tools/web-scrapers/osv.ts +189 -0
- package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
- package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
- package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
- package/src/core/tools/web-scrapers/packagist.ts +174 -0
- package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
- package/src/core/tools/web-scrapers/pubmed.ts +178 -0
- package/src/core/tools/web-scrapers/pypi.ts +129 -0
- package/src/core/tools/web-scrapers/rawg.ts +124 -0
- package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
- package/src/core/tools/web-scrapers/reddit.ts +104 -0
- package/src/core/tools/web-scrapers/repology.ts +262 -0
- package/src/core/tools/web-scrapers/research.test.ts +107 -0
- package/src/core/tools/web-scrapers/rfc.ts +209 -0
- package/src/core/tools/web-scrapers/rubygems.ts +117 -0
- package/src/core/tools/web-scrapers/searchcode.ts +217 -0
- package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
- package/src/core/tools/web-scrapers/security.test.ts +103 -0
- package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
- package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
- package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
- package/src/core/tools/web-scrapers/social.test.ts +259 -0
- package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
- package/src/core/tools/web-scrapers/spdx.ts +121 -0
- package/src/core/tools/web-scrapers/spotify.ts +218 -0
- package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
- package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
- package/src/core/tools/web-scrapers/standards.test.ts +122 -0
- package/src/core/tools/web-scrapers/terraform.ts +304 -0
- package/src/core/tools/web-scrapers/tldr.ts +51 -0
- package/src/core/tools/web-scrapers/twitter.ts +96 -0
- package/src/core/tools/web-scrapers/types.ts +234 -0
- package/src/core/tools/web-scrapers/utils.ts +162 -0
- package/src/core/tools/web-scrapers/vimeo.ts +152 -0
- package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
- package/src/core/tools/web-scrapers/w3c.ts +163 -0
- package/src/core/tools/web-scrapers/wikidata.ts +357 -0
- package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
- package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
- package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
- package/src/core/tools/web-scrapers/youtube.ts +371 -0
- package/src/core/tools/write.ts +21 -18
- package/src/core/voice.ts +3 -2
- package/src/lib/worktree/collapse.ts +2 -1
- package/src/lib/worktree/git.ts +2 -18
- package/src/main.ts +59 -3
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
- package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
- package/src/modes/interactive/components/hook-editor.ts +2 -1
- package/src/modes/interactive/components/model-selector.ts +19 -4
- package/src/modes/interactive/interactive-mode.ts +41 -38
- package/src/modes/interactive/theme/theme.ts +58 -58
- package/src/modes/rpc/rpc-mode.ts +10 -9
- package/src/prompts/review-request.md +27 -0
- package/src/prompts/reviewer.md +64 -68
- package/src/prompts/tools/output.md +22 -3
- package/src/prompts/tools/task.md +32 -33
- package/src/utils/clipboard.ts +2 -1
- package/src/utils/tools-manager.ts +110 -8
- 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(/&/g, "&")
|
|
46
|
+
.replace(/</g, "<")
|
|
47
|
+
.replace(/>/g, ">")
|
|
48
|
+
.replace(/"/g, '"')
|
|
49
|
+
.replace(/'/g, "'")
|
|
50
|
+
.replace(/ /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
|
+
};
|