@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,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
|
+
};
|