@oh-my-pi/pi-coding-agent 3.30.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 (155) hide show
  1. package/CHANGELOG.md +71 -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/edit-diff.ts +11 -4
  16. package/src/core/tools/edit.ts +7 -13
  17. package/src/core/tools/find.ts +111 -50
  18. package/src/core/tools/gemini-image.ts +128 -147
  19. package/src/core/tools/grep.ts +397 -415
  20. package/src/core/tools/index.test.ts +5 -1
  21. package/src/core/tools/index.ts +6 -8
  22. package/src/core/tools/ls.ts +12 -10
  23. package/src/core/tools/lsp/client.ts +58 -9
  24. package/src/core/tools/lsp/config.ts +205 -656
  25. package/src/core/tools/lsp/defaults.json +465 -0
  26. package/src/core/tools/lsp/index.ts +55 -32
  27. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  28. package/src/core/tools/lsp/types.ts +1 -0
  29. package/src/core/tools/lsp/utils.ts +1 -1
  30. package/src/core/tools/read.ts +150 -74
  31. package/src/core/tools/render-utils.ts +70 -10
  32. package/src/core/tools/review.ts +38 -126
  33. package/src/core/tools/task/artifacts.ts +5 -4
  34. package/src/core/tools/task/executor.ts +94 -83
  35. package/src/core/tools/task/index.ts +129 -92
  36. package/src/core/tools/task/parallel.ts +30 -3
  37. package/src/core/tools/task/render.ts +85 -39
  38. package/src/core/tools/task/types.ts +15 -6
  39. package/src/core/tools/task/worker.ts +124 -89
  40. package/src/core/tools/web-fetch.ts +112 -377
  41. package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
  42. package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
  43. package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
  44. package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
  45. package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
  46. package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
  47. package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
  48. package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
  49. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  50. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  51. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  52. package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
  53. package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
  54. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  55. package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
  56. package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
  57. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  58. package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
  59. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  60. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  61. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  62. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
  63. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
  64. package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
  65. package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
  66. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
  67. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
  68. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
  69. package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
  70. package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
  71. package/src/core/tools/web-scrapers/index.ts +250 -0
  72. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  73. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  74. package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
  75. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
  76. package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
  77. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
  78. package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
  79. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  80. package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
  81. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
  82. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
  83. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  84. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  85. package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
  86. package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
  87. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  88. package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
  89. package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
  90. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
  91. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
  92. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
  93. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  94. package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
  95. package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
  96. package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
  97. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
  98. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
  99. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  100. package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
  101. package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
  102. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  103. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  104. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  105. package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
  106. package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
  107. package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
  108. package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
  109. package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
  110. package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
  111. package/src/core/tools/web-scrapers/utils.ts +162 -0
  112. package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
  113. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  114. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  115. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
  116. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
  117. package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
  118. package/src/core/tools/write.ts +21 -18
  119. package/src/core/voice.ts +3 -2
  120. package/src/lib/worktree/collapse.ts +2 -1
  121. package/src/lib/worktree/git.ts +2 -18
  122. package/src/main.ts +59 -3
  123. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  124. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  125. package/src/modes/interactive/components/hook-editor.ts +2 -1
  126. package/src/modes/interactive/components/model-selector.ts +19 -4
  127. package/src/modes/interactive/interactive-mode.ts +41 -38
  128. package/src/modes/interactive/theme/theme.ts +58 -58
  129. package/src/modes/rpc/rpc-mode.ts +10 -9
  130. package/src/prompts/review-request.md +27 -0
  131. package/src/prompts/reviewer.md +64 -68
  132. package/src/prompts/tools/output.md +22 -3
  133. package/src/prompts/tools/task.md +32 -33
  134. package/src/utils/clipboard.ts +2 -1
  135. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  136. package/src/core/tools/web-fetch-handlers/index.ts +0 -69
  137. package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
  138. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
  139. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
  140. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
  141. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
  142. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
  143. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
  144. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
  145. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
  146. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
  147. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
  148. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
  149. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
  150. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
  151. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
  152. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
  153. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
  154. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
  155. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
@@ -51,11 +51,12 @@ interface MastodonStatus {
51
51
  /**
52
52
  * Check if a domain is a Mastodon instance by probing the API
53
53
  */
54
- async function isMastodonInstance(hostname: string, timeout: number): Promise<boolean> {
54
+ async function isMastodonInstance(hostname: string, timeout: number, signal?: AbortSignal): Promise<boolean> {
55
55
  try {
56
56
  const result = await loadPage(`https://${hostname}/api/v1/instance`, {
57
57
  timeout: Math.min(timeout, 5),
58
58
  headers: { Accept: "application/json" },
59
+ signal,
59
60
  });
60
61
  if (!result.ok) return false;
61
62
  const data = JSON.parse(result.content);
@@ -188,7 +189,11 @@ function formatAccount(account: MastodonAccount): string {
188
189
  /**
189
190
  * Handle Mastodon/Fediverse URLs
190
191
  */
191
- export const handleMastodon: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
192
+ export const handleMastodon: SpecialHandler = async (
193
+ url: string,
194
+ timeout: number,
195
+ signal?: AbortSignal,
196
+ ): Promise<RenderResult | null> => {
192
197
  try {
193
198
  const parsed = new URL(url);
194
199
 
@@ -199,7 +204,7 @@ export const handleMastodon: SpecialHandler = async (url: string, timeout: numbe
199
204
  if (!postMatch && !profileMatch) return null;
200
205
 
201
206
  // Verify this is a Mastodon instance
202
- if (!(await isMastodonInstance(parsed.hostname, timeout))) {
207
+ if (!(await isMastodonInstance(parsed.hostname, timeout, signal))) {
203
208
  return null;
204
209
  }
205
210
 
@@ -214,6 +219,7 @@ export const handleMastodon: SpecialHandler = async (url: string, timeout: numbe
214
219
  const result = await loadPage(apiUrl, {
215
220
  timeout,
216
221
  headers: { Accept: "application/json" },
222
+ signal,
217
223
  });
218
224
 
219
225
  if (!result.ok) return null;
@@ -248,6 +254,7 @@ export const handleMastodon: SpecialHandler = async (url: string, timeout: numbe
248
254
  const result = await loadPage(lookupUrl, {
249
255
  timeout,
250
256
  headers: { Accept: "application/json" },
257
+ signal,
251
258
  });
252
259
 
253
260
  if (!result.ok) return null;
@@ -264,6 +271,7 @@ export const handleMastodon: SpecialHandler = async (url: string, timeout: numbe
264
271
  const statusesResult = await loadPage(statusesUrl, {
265
272
  timeout,
266
273
  headers: { Accept: "application/json" },
274
+ signal,
267
275
  });
268
276
 
269
277
  let md = formatAccount(account);
@@ -25,7 +25,11 @@ interface MavenResponse {
25
25
  * Handle Maven Central URLs via Solr API
26
26
  * Supports: search.maven.org/artifact/... and mvnrepository.com/artifact/...
27
27
  */
28
- export const handleMaven: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
28
+ export const handleMaven: SpecialHandler = async (
29
+ url: string,
30
+ timeout: number,
31
+ signal?: AbortSignal,
32
+ ): Promise<RenderResult | null> => {
29
33
  try {
30
34
  const parsed = new URL(url);
31
35
  const hostname = parsed.hostname;
@@ -65,6 +69,7 @@ export const handleMaven: SpecialHandler = async (url: string, timeout: number):
65
69
  const result = await loadPage(apiUrl, {
66
70
  timeout,
67
71
  headers: { Accept: "application/json" },
72
+ signal,
68
73
  });
69
74
 
70
75
  if (!result.ok) return null;
@@ -105,7 +105,7 @@ function convertMDNBody(sections: MDNSection[]): string {
105
105
  return parts.join("\n\n");
106
106
  }
107
107
 
108
- export const handleMDN: SpecialHandler = async (url: string, timeout: number) => {
108
+ export const handleMDN: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => {
109
109
  const urlObj = new URL(url);
110
110
 
111
111
  // Only handle developer.mozilla.org
@@ -124,7 +124,7 @@ export const handleMDN: SpecialHandler = async (url: string, timeout: number) =>
124
124
  const jsonUrl = url.replace(/\/?$/, "/index.json");
125
125
 
126
126
  try {
127
- const result = await loadPage(jsonUrl, { timeout, headers: { Accept: "application/json" } });
127
+ const result = await loadPage(jsonUrl, { timeout, signal, headers: { Accept: "application/json" } });
128
128
 
129
129
  if (!result.ok) {
130
130
  notes.push(`Failed to fetch MDN JSON API (status ${result.status || "unknown"})`);
@@ -39,7 +39,11 @@ interface ReleaseResponse {
39
39
  /**
40
40
  * Handle MetaCPAN URLs via fastapi.metacpan.org
41
41
  */
42
- export const handleMetaCPAN: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
42
+ export const handleMetaCPAN: SpecialHandler = async (
43
+ url: string,
44
+ timeout: number,
45
+ signal?: AbortSignal,
46
+ ): Promise<RenderResult | null> => {
43
47
  try {
44
48
  const parsed = new URL(url);
45
49
  if (parsed.hostname !== "metacpan.org" && parsed.hostname !== "www.metacpan.org") return null;
@@ -50,21 +54,21 @@ export const handleMetaCPAN: SpecialHandler = async (url: string, timeout: numbe
50
54
  const podMatch = parsed.pathname.match(/^\/pod\/(.+?)(?:\/|$)/);
51
55
  if (podMatch) {
52
56
  const moduleName = decodeURIComponent(podMatch[1]);
53
- return await fetchModule(url, moduleName, timeout, fetchedAt);
57
+ return await fetchModule(url, moduleName, timeout, fetchedAt, signal);
54
58
  }
55
59
 
56
60
  // Match /release/AUTHOR/Distribution pattern
57
61
  const releaseMatch = parsed.pathname.match(/^\/release\/([^/]+)\/([^/]+)/);
58
62
  if (releaseMatch) {
59
63
  const distribution = decodeURIComponent(releaseMatch[2]);
60
- return await fetchRelease(url, distribution, timeout, fetchedAt);
64
+ return await fetchRelease(url, distribution, timeout, fetchedAt, signal);
61
65
  }
62
66
 
63
67
  // Match /release/Distribution pattern (without author)
64
68
  const simpleReleaseMatch = parsed.pathname.match(/^\/release\/([^/]+)$/);
65
69
  if (simpleReleaseMatch) {
66
70
  const distribution = decodeURIComponent(simpleReleaseMatch[1]);
67
- return await fetchRelease(url, distribution, timeout, fetchedAt);
71
+ return await fetchRelease(url, distribution, timeout, fetchedAt, signal);
68
72
  }
69
73
 
70
74
  return null;
@@ -78,9 +82,10 @@ async function fetchModule(
78
82
  moduleName: string,
79
83
  timeout: number,
80
84
  fetchedAt: string,
85
+ signal?: AbortSignal,
81
86
  ): Promise<RenderResult | null> {
82
87
  const apiUrl = `https://fastapi.metacpan.org/v1/module/${moduleName}`;
83
- const result = await loadPage(apiUrl, { timeout });
88
+ const result = await loadPage(apiUrl, { timeout, signal });
84
89
 
85
90
  if (!result.ok) return null;
86
91
 
@@ -93,7 +98,7 @@ async function fetchModule(
93
98
 
94
99
  // Fetch additional release info for dependencies and metadata
95
100
  const releaseUrl = `https://fastapi.metacpan.org/v1/release/${module.distribution}`;
96
- const releaseResult = await loadPage(releaseUrl, { timeout: Math.min(timeout, 5) });
101
+ const releaseResult = await loadPage(releaseUrl, { timeout: Math.min(timeout, 5), signal });
97
102
 
98
103
  let release: ReleaseResponse | null = null;
99
104
  if (releaseResult.ok) {
@@ -122,9 +127,10 @@ async function fetchRelease(
122
127
  distribution: string,
123
128
  timeout: number,
124
129
  fetchedAt: string,
130
+ signal?: AbortSignal,
125
131
  ): Promise<RenderResult | null> {
126
132
  const apiUrl = `https://fastapi.metacpan.org/v1/release/${distribution}`;
127
- const result = await loadPage(apiUrl, { timeout });
133
+ const result = await loadPage(apiUrl, { timeout, signal });
128
134
 
129
135
  if (!result.ok) return null;
130
136
 
@@ -0,0 +1,273 @@
1
+ /**
2
+ * MusicBrainz URL handler for artists, releases, and recordings
3
+ */
4
+
5
+ import type { RenderResult, SpecialHandler } from "./types";
6
+ import { finalizeOutput, loadPage } from "./types";
7
+
8
+ type MusicBrainzEntity = "artist" | "release" | "recording";
9
+
10
+ interface MusicBrainzLifeSpan {
11
+ begin?: string;
12
+ end?: string;
13
+ ended?: boolean;
14
+ }
15
+
16
+ interface MusicBrainzArtist {
17
+ id: string;
18
+ name: string;
19
+ type?: string;
20
+ country?: string;
21
+ "life-span"?: MusicBrainzLifeSpan;
22
+ }
23
+
24
+ interface MusicBrainzArtistCredit {
25
+ name?: string;
26
+ artist?: {
27
+ id?: string;
28
+ name: string;
29
+ };
30
+ }
31
+
32
+ interface MusicBrainzRecording {
33
+ id: string;
34
+ title: string;
35
+ length?: number;
36
+ "artist-credit"?: MusicBrainzArtistCredit[];
37
+ }
38
+
39
+ interface MusicBrainzTrack {
40
+ id?: string;
41
+ title?: string;
42
+ number?: string;
43
+ position?: number;
44
+ length?: number;
45
+ recording?: {
46
+ title?: string;
47
+ length?: number;
48
+ };
49
+ }
50
+
51
+ interface MusicBrainzMedium {
52
+ position?: number;
53
+ format?: string;
54
+ "track-count"?: number;
55
+ tracks?: MusicBrainzTrack[];
56
+ }
57
+
58
+ interface MusicBrainzRelease {
59
+ id: string;
60
+ title: string;
61
+ "track-count"?: number;
62
+ media?: MusicBrainzMedium[];
63
+ }
64
+
65
+ const MUSICBRAINZ_HOSTS = new Set(["musicbrainz.org", "www.musicbrainz.org"]);
66
+ const USER_AGENT = "omp-web-fetch/1.0 (https://github.com/anthropics)";
67
+ const MAX_TRACKS = 50;
68
+
69
+ function parseEntity(url: URL): { entity: MusicBrainzEntity; mbid: string } | null {
70
+ if (!MUSICBRAINZ_HOSTS.has(url.hostname)) return null;
71
+
72
+ const parts = url.pathname.split("/").filter(Boolean);
73
+ if (parts.length < 2) return null;
74
+
75
+ const entity = parts[0] as MusicBrainzEntity;
76
+ if (entity !== "artist" && entity !== "release" && entity !== "recording") return null;
77
+
78
+ const mbid = parts[1];
79
+ if (!/^[0-9a-fA-F-]{36}$/.test(mbid)) return null;
80
+
81
+ return { entity, mbid };
82
+ }
83
+
84
+ async function fetchJson<T>(apiUrl: string, timeout: number, signal?: AbortSignal): Promise<T | null> {
85
+ const result = await loadPage(apiUrl, {
86
+ timeout,
87
+ signal,
88
+ headers: {
89
+ "User-Agent": USER_AGENT,
90
+ Accept: "application/json",
91
+ },
92
+ });
93
+
94
+ if (!result.ok) return null;
95
+
96
+ try {
97
+ return JSON.parse(result.content) as T;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function formatLifeSpan(life: MusicBrainzLifeSpan | undefined): string | null {
104
+ if (!life) return null;
105
+
106
+ const begin = life.begin?.trim();
107
+ const end = life.end?.trim();
108
+
109
+ if (begin && end) return `${begin} - ${end}`;
110
+ if (begin && !end) return `${begin} - ${life.ended ? "ended" : "present"}`;
111
+ if (!begin && end) return `? - ${end}`;
112
+ if (life.ended !== undefined) return life.ended ? "ended" : "present";
113
+
114
+ return null;
115
+ }
116
+
117
+ function formatDurationMs(lengthMs: number | undefined): string | null {
118
+ if (!lengthMs || lengthMs <= 0) return null;
119
+
120
+ const totalSeconds = Math.round(lengthMs / 1000);
121
+ const hours = Math.floor(totalSeconds / 3600);
122
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
123
+ const seconds = totalSeconds % 60;
124
+
125
+ if (hours > 0) {
126
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
127
+ }
128
+
129
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
130
+ }
131
+
132
+ function formatArtistCredits(credits: MusicBrainzArtistCredit[] | undefined): string | null {
133
+ if (!credits?.length) return null;
134
+
135
+ const names = credits
136
+ .map((credit) => credit.name || credit.artist?.name)
137
+ .filter((name): name is string => Boolean(name));
138
+
139
+ if (!names.length) return null;
140
+ return names.join(", ");
141
+ }
142
+
143
+ function formatTrack(track: MusicBrainzTrack): string {
144
+ const title = track.title || track.recording?.title || "Untitled";
145
+ const duration = formatDurationMs(track.length ?? track.recording?.length);
146
+ const number = track.number || (track.position ? String(track.position) : null);
147
+
148
+ const prefix = number ? `${number}. ` : "- ";
149
+ let line = `${prefix}${title}`;
150
+ if (duration) line += ` (${duration})`;
151
+ return line;
152
+ }
153
+
154
+ function buildMediumLabel(medium: MusicBrainzMedium, includePosition: boolean): string | null {
155
+ const parts: string[] = [];
156
+ if (includePosition && medium.position) parts.push(`Disc ${medium.position}`);
157
+ if (medium.format) parts.push(medium.format);
158
+ return parts.length ? parts.join(" - ") : null;
159
+ }
160
+
161
+ function buildArtistMarkdown(artist: MusicBrainzArtist): string {
162
+ let md = `# ${artist.name}\n\n`;
163
+ const meta: string[] = [];
164
+
165
+ if (artist.type) meta.push(`**Type**: ${artist.type}`);
166
+ if (artist.country) meta.push(`**Country**: ${artist.country}`);
167
+
168
+ const lifeSpan = formatLifeSpan(artist["life-span"]);
169
+ if (lifeSpan) meta.push(`**Life Span**: ${lifeSpan}`);
170
+
171
+ if (meta.length) md += `${meta.join("\n")}\n`;
172
+
173
+ return md;
174
+ }
175
+
176
+ function buildReleaseMarkdown(release: MusicBrainzRelease): string {
177
+ let md = `# ${release.title}\n\n`;
178
+
179
+ const media = release.media ?? [];
180
+ const totalTracks =
181
+ release["track-count"] ??
182
+ media.reduce((sum, medium) => sum + (medium["track-count"] ?? medium.tracks?.length ?? 0), 0);
183
+
184
+ if (totalTracks) {
185
+ md += `**Tracks**: ${totalTracks}\n\n`;
186
+ }
187
+
188
+ if (media.length) {
189
+ md += "## Tracks\n\n";
190
+ const includePosition = media.length > 1;
191
+
192
+ for (const medium of media) {
193
+ const label = buildMediumLabel(medium, includePosition);
194
+ if (label) md += `### ${label}\n\n`;
195
+
196
+ const tracks = medium.tracks ?? [];
197
+ if (tracks.length) {
198
+ const lines = tracks.slice(0, MAX_TRACKS).map(formatTrack).join("\n");
199
+ md += `${lines}\n\n`;
200
+
201
+ if (tracks.length > MAX_TRACKS) {
202
+ md += `_Showing first ${MAX_TRACKS} of ${tracks.length} tracks._\n\n`;
203
+ }
204
+ } else if (medium["track-count"]) {
205
+ md += `- ${medium["track-count"]} tracks (details unavailable)\n\n`;
206
+ }
207
+ }
208
+ }
209
+
210
+ return md;
211
+ }
212
+
213
+ function buildRecordingMarkdown(recording: MusicBrainzRecording): string {
214
+ let md = `# ${recording.title}\n\n`;
215
+ const meta: string[] = [];
216
+
217
+ const artists = formatArtistCredits(recording["artist-credit"]);
218
+ if (artists) meta.push(`**Artists**: ${artists}`);
219
+
220
+ const length = formatDurationMs(recording.length);
221
+ if (length) meta.push(`**Length**: ${length}`);
222
+
223
+ if (meta.length) md += `${meta.join("\n")}\n`;
224
+
225
+ return md;
226
+ }
227
+
228
+ export const handleMusicBrainz: SpecialHandler = async (
229
+ url: string,
230
+ timeout: number,
231
+ signal?: AbortSignal,
232
+ ): Promise<RenderResult | null> => {
233
+ try {
234
+ const parsed = new URL(url);
235
+ const parsedEntity = parseEntity(parsed);
236
+ if (!parsedEntity) return null;
237
+
238
+ const { entity, mbid } = parsedEntity;
239
+ const fetchedAt = new Date().toISOString();
240
+ let md = "";
241
+
242
+ if (entity === "artist") {
243
+ const apiUrl = `https://musicbrainz.org/ws/2/artist/${mbid}?fmt=json&inc=url-rels`;
244
+ const artist = await fetchJson<MusicBrainzArtist>(apiUrl, timeout, signal);
245
+ if (!artist) return null;
246
+ md = buildArtistMarkdown(artist);
247
+ } else if (entity === "release") {
248
+ const apiUrl = `https://musicbrainz.org/ws/2/release/${mbid}?fmt=json&inc=recordings`;
249
+ const release = await fetchJson<MusicBrainzRelease>(apiUrl, timeout, signal);
250
+ if (!release) return null;
251
+ md = buildReleaseMarkdown(release);
252
+ } else {
253
+ const apiUrl = `https://musicbrainz.org/ws/2/recording/${mbid}?fmt=json`;
254
+ const recording = await fetchJson<MusicBrainzRecording>(apiUrl, timeout, signal);
255
+ if (!recording) return null;
256
+ md = buildRecordingMarkdown(recording);
257
+ }
258
+
259
+ const output = finalizeOutput(md);
260
+ return {
261
+ url,
262
+ finalUrl: url,
263
+ contentType: "text/markdown",
264
+ method: "musicbrainz-api",
265
+ content: output.content,
266
+ fetchedAt,
267
+ truncated: output.truncated,
268
+ notes: ["Fetched via MusicBrainz API"],
269
+ };
270
+ } catch {}
271
+
272
+ return null;
273
+ };
@@ -4,7 +4,11 @@ import { finalizeOutput, formatCount, loadPage } from "./types";
4
4
  /**
5
5
  * Handle npm URLs via registry API
6
6
  */
7
- export const handleNpm: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
7
+ export const handleNpm: SpecialHandler = async (
8
+ url: string,
9
+ timeout: number,
10
+ signal?: AbortSignal,
11
+ ): Promise<RenderResult | null> => {
8
12
  try {
9
13
  const parsed = new URL(url);
10
14
  if (parsed.hostname !== "www.npmjs.com" && parsed.hostname !== "npmjs.com") return null;
@@ -28,8 +32,8 @@ export const handleNpm: SpecialHandler = async (url: string, timeout: number): P
28
32
 
29
33
  // Fetch package info and download stats in parallel
30
34
  const [result, downloadsResult] = await Promise.all([
31
- loadPage(latestUrl, { timeout }),
32
- loadPage(downloadsUrl, { timeout: Math.min(timeout, 5) }),
35
+ loadPage(latestUrl, { timeout, signal }),
36
+ loadPage(downloadsUrl, { timeout: Math.min(timeout, 5), signal }),
33
37
  ]);
34
38
 
35
39
  if (!result.ok) return null;
@@ -47,7 +51,7 @@ export const handleNpm: SpecialHandler = async (url: string, timeout: number): P
47
51
  name: string;
48
52
  version: string;
49
53
  description?: string;
50
- license?: string;
54
+ license?: string | { type: string };
51
55
  homepage?: string;
52
56
  repository?: { url: string } | string;
53
57
  keywords?: string[];
@@ -66,7 +70,10 @@ export const handleNpm: SpecialHandler = async (url: string, timeout: number): P
66
70
  if (pkg.description) md += `${pkg.description}\n\n`;
67
71
 
68
72
  md += `**Latest:** ${pkg.version || "unknown"}`;
69
- if (pkg.license) md += ` · **License:** ${typeof pkg.license === "string" ? pkg.license : pkg.license}`;
73
+ if (pkg.license) {
74
+ const license = typeof pkg.license === "string" ? pkg.license : (pkg.license.type ?? String(pkg.license));
75
+ md += ` · **License:** ${license}`;
76
+ }
70
77
  md += "\n";
71
78
  if (weeklyDownloads !== null) {
72
79
  md += `**Weekly Downloads:** ${formatCount(weeklyDownloads)}\n`;
@@ -37,7 +37,11 @@ interface NuGetRegistrationIndex {
37
37
  /**
38
38
  * Handle NuGet URLs via API
39
39
  */
40
- export const handleNuGet: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
40
+ export const handleNuGet: SpecialHandler = async (
41
+ url: string,
42
+ timeout: number,
43
+ signal?: AbortSignal,
44
+ ): Promise<RenderResult | null> => {
41
45
  try {
42
46
  const parsed = new URL(url);
43
47
  if (parsed.hostname !== "www.nuget.org" && parsed.hostname !== "nuget.org") return null;
@@ -52,7 +56,7 @@ export const handleNuGet: SpecialHandler = async (url: string, timeout: number):
52
56
 
53
57
  // Fetch from NuGet registration API (package name must be lowercase)
54
58
  const apiUrl = `https://api.nuget.org/v3/registration5-gz-semver2/${packageName.toLowerCase()}/index.json`;
55
- const result = await loadPage(apiUrl, { timeout });
59
+ const result = await loadPage(apiUrl, { timeout, signal });
56
60
 
57
61
  if (!result.ok) return null;
58
62
 
@@ -70,7 +74,7 @@ export const handleNuGet: SpecialHandler = async (url: string, timeout: number):
70
74
 
71
75
  // If items are not inlined, fetch the page
72
76
  if (!latestPage.items && latestPage["@id"]) {
73
- const pageResult = await loadPage(latestPage["@id"], { timeout });
77
+ const pageResult = await loadPage(latestPage["@id"], { timeout, signal });
74
78
  if (!pageResult.ok) return null;
75
79
  try {
76
80
  latestPage = JSON.parse(pageResult.content);
@@ -91,7 +95,7 @@ export const handleNuGet: SpecialHandler = async (url: string, timeout: number):
91
95
 
92
96
  // Fetch page if items not inlined
93
97
  if (!pageItems && page["@id"]) {
94
- const pageResult = await loadPage(page["@id"], { timeout: Math.min(timeout, 5) });
98
+ const pageResult = await loadPage(page["@id"], { timeout: Math.min(timeout, 5), signal });
95
99
  if (pageResult.ok) {
96
100
  try {
97
101
  const fetchedPage = JSON.parse(pageResult.content) as NuGetRegistrationPage;
@@ -121,7 +125,7 @@ export const handleNuGet: SpecialHandler = async (url: string, timeout: number):
121
125
  // Fetch download stats via search API
122
126
  let totalDownloads: number | null = null;
123
127
  const searchUrl = `https://api.nuget.org/v3/query?q=packageid:${encodeURIComponent(packageName)}&prerelease=true&take=1`;
124
- const searchResult = await loadPage(searchUrl, { timeout: Math.min(timeout, 5) });
128
+ const searchResult = await loadPage(searchUrl, { timeout: Math.min(timeout, 5), signal });
125
129
 
126
130
  if (searchResult.ok) {
127
131
  try {
@@ -73,7 +73,11 @@ interface NvdResponse {
73
73
  /**
74
74
  * Handle NVD (National Vulnerability Database) CVE URLs
75
75
  */
76
- export const handleNvd: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
76
+ export const handleNvd: SpecialHandler = async (
77
+ url: string,
78
+ timeout: number,
79
+ signal?: AbortSignal,
80
+ ): Promise<RenderResult | null> => {
77
81
  try {
78
82
  const parsed = new URL(url);
79
83
  if (!parsed.hostname.includes("nvd.nist.gov")) return null;
@@ -90,6 +94,7 @@ export const handleNvd: SpecialHandler = async (url: string, timeout: number): P
90
94
  const result = await loadPage(apiUrl, {
91
95
  timeout,
92
96
  headers: { Accept: "application/json" },
97
+ signal,
93
98
  });
94
99
 
95
100
  if (!result.ok) return null;