@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
@@ -13,9 +13,64 @@ export interface RenderResult {
13
13
  notes: string[];
14
14
  }
15
15
 
16
- export type SpecialHandler = (url: string, timeout: number) => Promise<RenderResult | null>;
16
+ export type SpecialHandler = (url: string, timeout: number, signal?: AbortSignal) => Promise<RenderResult | null>;
17
17
 
18
18
  export const MAX_OUTPUT_CHARS = 500_000;
19
+ const MAX_BYTES = 50 * 1024 * 1024;
20
+
21
+ const USER_AGENTS = [
22
+ "curl/8.0",
23
+ "Mozilla/5.0 (compatible; TextBot/1.0)",
24
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
25
+ ];
26
+
27
+ export interface RequestSignal {
28
+ signal: AbortSignal;
29
+ cleanup: () => void;
30
+ }
31
+
32
+ export function createRequestSignal(timeoutMs: number, signal?: AbortSignal): RequestSignal {
33
+ const controller = new AbortController();
34
+ let timeoutId: ReturnType<typeof setTimeout> | undefined = setTimeout(() => controller.abort(), timeoutMs);
35
+ const abortHandler = () => controller.abort();
36
+
37
+ if (signal) {
38
+ if (signal.aborted) {
39
+ clearTimeout(timeoutId);
40
+ timeoutId = undefined;
41
+ controller.abort();
42
+ } else {
43
+ signal.addEventListener("abort", abortHandler, { once: true });
44
+ }
45
+ }
46
+
47
+ const cleanup = () => {
48
+ if (timeoutId !== undefined) {
49
+ clearTimeout(timeoutId);
50
+ timeoutId = undefined;
51
+ }
52
+ if (signal) {
53
+ signal.removeEventListener("abort", abortHandler);
54
+ }
55
+ };
56
+
57
+ return { signal: controller.signal, cleanup };
58
+ }
59
+
60
+ function isBotBlocked(status: number, content: string): boolean {
61
+ if (status === 403 || status === 503) {
62
+ const lower = content.toLowerCase();
63
+ return (
64
+ lower.includes("cloudflare") ||
65
+ lower.includes("captcha") ||
66
+ lower.includes("challenge") ||
67
+ lower.includes("blocked") ||
68
+ lower.includes("access denied") ||
69
+ lower.includes("bot detection")
70
+ );
71
+ }
72
+ return false;
73
+ }
19
74
 
20
75
  /**
21
76
  * Truncate and cleanup output
@@ -29,30 +84,41 @@ export function finalizeOutput(content: string): { content: string; truncated: b
29
84
  };
30
85
  }
31
86
 
87
+ export interface LoadPageOptions {
88
+ timeout?: number;
89
+ headers?: Record<string, string>;
90
+ method?: string;
91
+ body?: string;
92
+ maxBytes?: number;
93
+ signal?: AbortSignal;
94
+ }
95
+
96
+ export interface LoadPageResult {
97
+ content: string;
98
+ contentType: string;
99
+ finalUrl: string;
100
+ ok: boolean;
101
+ status?: number;
102
+ }
103
+
32
104
  /**
33
105
  * Fetch a page with timeout and size limit
34
106
  */
35
- export async function loadPage(
36
- url: string,
37
- options: { timeout?: number; headers?: Record<string, string>; maxBytes?: number } = {},
38
- ): Promise<{ content: string; contentType: string; finalUrl: string; ok: boolean; status?: number }> {
39
- const { timeout = 20, headers = {}, maxBytes = 50 * 1024 * 1024 } = options;
107
+ export async function loadPage(url: string, options: LoadPageOptions = {}): Promise<LoadPageResult> {
108
+ const { timeout = 20, headers = {}, maxBytes = MAX_BYTES, signal, method = "GET", body } = options;
40
109
 
41
- const userAgents = [
42
- "curl/8.0",
43
- "Mozilla/5.0 (compatible; TextBot/1.0)",
44
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
45
- ];
110
+ for (let attempt = 0; attempt < USER_AGENTS.length; attempt++) {
111
+ if (signal?.aborted) {
112
+ return { content: "", contentType: "", finalUrl: url, ok: false };
113
+ }
46
114
 
47
- for (let attempt = 0; attempt < userAgents.length; attempt++) {
48
- const userAgent = userAgents[attempt];
115
+ const userAgent = USER_AGENTS[attempt];
116
+ const { signal: requestSignal, cleanup } = createRequestSignal(timeout * 1000, signal);
49
117
 
50
118
  try {
51
- const controller = new AbortController();
52
- const timeoutId = setTimeout(() => controller.abort(), timeout * 1000);
53
-
54
- const response = await fetch(url, {
55
- signal: controller.signal,
119
+ const requestInit: RequestInit = {
120
+ signal: requestSignal,
121
+ method,
56
122
  headers: {
57
123
  "User-Agent": userAgent,
58
124
  Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
@@ -60,9 +126,13 @@ export async function loadPage(
60
126
  ...headers,
61
127
  },
62
128
  redirect: "follow",
63
- });
129
+ };
64
130
 
65
- clearTimeout(timeoutId);
131
+ if (body !== undefined) {
132
+ requestInit.body = body;
133
+ }
134
+
135
+ const response = await fetch(url, requestInit);
66
136
 
67
137
  const contentType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
68
138
  const finalUrl = response.url;
@@ -91,12 +161,8 @@ export async function loadPage(
91
161
  const decoder = new TextDecoder();
92
162
  const content = decoder.decode(Buffer.concat(chunks));
93
163
 
94
- // Check if blocked
95
- if ((response.status === 403 || response.status === 503) && attempt < userAgents.length - 1) {
96
- const lower = content.toLowerCase();
97
- if (lower.includes("cloudflare") || lower.includes("captcha") || lower.includes("blocked")) {
98
- continue;
99
- }
164
+ if (isBotBlocked(response.status, content) && attempt < USER_AGENTS.length - 1) {
165
+ continue;
100
166
  }
101
167
 
102
168
  if (!response.ok) {
@@ -105,9 +171,14 @@ export async function loadPage(
105
171
 
106
172
  return { content, contentType, finalUrl, ok: true, status: response.status };
107
173
  } catch (_err) {
108
- if (attempt === userAgents.length - 1) {
174
+ if (signal?.aborted) {
175
+ return { content: "", contentType: "", finalUrl: url, ok: false };
176
+ }
177
+ if (attempt === USER_AGENTS.length - 1) {
109
178
  return { content: "", contentType: "", finalUrl: url, ok: false };
110
179
  }
180
+ } finally {
181
+ cleanup();
111
182
  }
112
183
  }
113
184
 
@@ -0,0 +1,162 @@
1
+ import { tmpdir } from "node:os";
2
+ import * as path from "node:path";
3
+ import { nanoid } from "nanoid";
4
+ import { ensureTool } from "../../../utils/tools-manager";
5
+ import { createRequestSignal } from "./types";
6
+
7
+ const MAX_BYTES = 50 * 1024 * 1024; // 50MB for binary files
8
+
9
+ interface ExecResult {
10
+ stdout: string;
11
+ stderr: string;
12
+ ok: boolean;
13
+ exitCode: number;
14
+ }
15
+
16
+ type SpawnSyncOptions = NonNullable<Parameters<typeof Bun.spawnSync>[1]>;
17
+
18
+ function exec(cmd: string, args: string[], options?: { timeout?: number; input?: string | Buffer }): ExecResult {
19
+ const stdin = (options?.input ?? "ignore") as SpawnSyncOptions["stdin"];
20
+ const result = Bun.spawnSync([cmd, ...args], {
21
+ stdin,
22
+ stdout: "pipe",
23
+ stderr: "pipe",
24
+ });
25
+ return {
26
+ stdout: result.stdout?.toString() ?? "",
27
+ stderr: result.stderr?.toString() ?? "",
28
+ ok: result.exitCode === 0,
29
+ exitCode: result.exitCode ?? -1,
30
+ };
31
+ }
32
+
33
+ export interface ConvertResult {
34
+ content: string;
35
+ ok: boolean;
36
+ error?: string;
37
+ }
38
+
39
+ export interface BinaryFetchResult {
40
+ buffer: Buffer;
41
+ contentType: string;
42
+ contentDisposition?: string;
43
+ ok: boolean;
44
+ status?: number;
45
+ error?: string;
46
+ }
47
+
48
+ export async function convertWithMarkitdown(
49
+ content: Buffer,
50
+ extensionHint: string,
51
+ timeout: number,
52
+ signal?: AbortSignal,
53
+ ): Promise<ConvertResult> {
54
+ if (signal?.aborted) {
55
+ return { content: "", ok: false, error: "aborted" };
56
+ }
57
+
58
+ const markitdown = await ensureTool("markitdown", true);
59
+ if (!markitdown) {
60
+ return { content: "", ok: false, error: "markitdown not available" };
61
+ }
62
+
63
+ // Write to temp file with extension hint
64
+ const ext = extensionHint || ".bin";
65
+ const tmpDir = tmpdir();
66
+ const tmpFile = path.join(tmpDir, `omp-convert-${nanoid()}${ext}`);
67
+
68
+ if (content.length > MAX_BYTES) {
69
+ return { content: "", ok: false, error: `content exceeds ${MAX_BYTES} bytes` };
70
+ }
71
+
72
+ try {
73
+ await Bun.write(tmpFile, content);
74
+ const result = exec(markitdown, [tmpFile], { timeout });
75
+ if (!result.ok) {
76
+ const stderr = result.stderr.trim();
77
+ return {
78
+ content: result.stdout,
79
+ ok: false,
80
+ error: stderr.length > 0 ? stderr : `markitdown failed (exit ${result.exitCode})`,
81
+ };
82
+ }
83
+ return { content: result.stdout, ok: true };
84
+ } finally {
85
+ try {
86
+ await Bun.$`rm ${tmpFile}`.quiet();
87
+ } catch {}
88
+ }
89
+ }
90
+
91
+ export async function fetchBinary(url: string, timeout: number, signal?: AbortSignal): Promise<BinaryFetchResult> {
92
+ if (signal?.aborted) {
93
+ return { buffer: Buffer.alloc(0), contentType: "", ok: false, error: "aborted" };
94
+ }
95
+
96
+ const { signal: requestSignal, cleanup } = createRequestSignal(timeout * 1000, signal);
97
+
98
+ try {
99
+ const response = await fetch(url, {
100
+ signal: requestSignal,
101
+ headers: {
102
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0",
103
+ },
104
+ redirect: "follow",
105
+ });
106
+
107
+ const contentType = response.headers.get("content-type") ?? "";
108
+ const contentDisposition = response.headers.get("content-disposition") ?? undefined;
109
+
110
+ if (!response.ok) {
111
+ return {
112
+ buffer: Buffer.alloc(0),
113
+ contentType,
114
+ contentDisposition,
115
+ ok: false,
116
+ status: response.status,
117
+ error: `status ${response.status}`,
118
+ };
119
+ }
120
+
121
+ const contentLength = response.headers.get("content-length");
122
+ if (contentLength) {
123
+ const size = Number.parseInt(contentLength, 10);
124
+ if (Number.isFinite(size) && size > MAX_BYTES) {
125
+ return {
126
+ buffer: Buffer.alloc(0),
127
+ contentType,
128
+ contentDisposition,
129
+ ok: false,
130
+ status: response.status,
131
+ error: `content-length ${size} exceeds ${MAX_BYTES}`,
132
+ };
133
+ }
134
+ }
135
+
136
+ const buffer = Buffer.from(await response.arrayBuffer());
137
+ if (buffer.length > MAX_BYTES) {
138
+ return {
139
+ buffer: Buffer.alloc(0),
140
+ contentType,
141
+ contentDisposition,
142
+ ok: false,
143
+ status: response.status,
144
+ error: `response exceeds ${MAX_BYTES} bytes`,
145
+ };
146
+ }
147
+
148
+ return { buffer, contentType, contentDisposition, ok: true, status: response.status };
149
+ } catch (err) {
150
+ if (signal?.aborted) {
151
+ return { buffer: Buffer.alloc(0), contentType: "", ok: false, error: "aborted" };
152
+ }
153
+ return {
154
+ buffer: Buffer.alloc(0),
155
+ contentType: "",
156
+ ok: false,
157
+ error: `request failed: ${String(err)}`,
158
+ };
159
+ } finally {
160
+ cleanup();
161
+ }
162
+ }
@@ -79,7 +79,7 @@ function extractVideoId(url: string): string | null {
79
79
  /**
80
80
  * Handle Vimeo URLs via oEmbed API
81
81
  */
82
- export const handleVimeo: SpecialHandler = async (url: string, timeout: number) => {
82
+ export const handleVimeo: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => {
83
83
  try {
84
84
  const parsed = new URL(url);
85
85
  if (!parsed.hostname.includes("vimeo.com")) return null;
@@ -92,7 +92,7 @@ export const handleVimeo: SpecialHandler = async (url: string, timeout: number)
92
92
  // Use canonical URL for oEmbed (handles staffpicks and other URL formats)
93
93
  const canonicalUrl = `https://vimeo.com/${videoId}`;
94
94
  const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(canonicalUrl)}`;
95
- const oembedResult = await loadPage(oembedUrl, { timeout });
95
+ const oembedResult = await loadPage(oembedUrl, { timeout, signal });
96
96
 
97
97
  if (!oembedResult.ok) return null;
98
98
 
@@ -117,7 +117,7 @@ export const handleVimeo: SpecialHandler = async (url: string, timeout: number)
117
117
  // Try to get additional details from video config
118
118
  try {
119
119
  const configUrl = `https://player.vimeo.com/video/${videoId}/config`;
120
- const configResult = await loadPage(configUrl, { timeout: Math.min(timeout, 5) });
120
+ const configResult = await loadPage(configUrl, { timeout: Math.min(timeout, 5), signal });
121
121
 
122
122
  if (configResult.ok) {
123
123
  const config = JSON.parse(configResult.content) as VimeoVideoConfig;
@@ -0,0 +1,195 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface MarketplaceProperty {
5
+ key?: string;
6
+ value?: string;
7
+ }
8
+
9
+ interface MarketplaceVersion {
10
+ version?: string;
11
+ properties?: MarketplaceProperty[];
12
+ }
13
+
14
+ interface MarketplaceStatistic {
15
+ statisticName?: string;
16
+ value?: number;
17
+ }
18
+
19
+ interface MarketplacePublisher {
20
+ publisherName?: string;
21
+ displayName?: string;
22
+ }
23
+
24
+ interface MarketplaceExtension {
25
+ extensionName?: string;
26
+ displayName?: string;
27
+ shortDescription?: string;
28
+ description?: string;
29
+ publisher?: MarketplacePublisher;
30
+ versions?: MarketplaceVersion[];
31
+ statistics?: MarketplaceStatistic[];
32
+ categories?: string[];
33
+ tags?: string[];
34
+ properties?: MarketplaceProperty[];
35
+ }
36
+
37
+ interface MarketplaceResponse {
38
+ results?: Array<{ extensions?: MarketplaceExtension[] }>;
39
+ }
40
+
41
+ const MARKETPLACE_HOSTS = new Set(["marketplace.visualstudio.com", "www.marketplace.visualstudio.com"]);
42
+
43
+ function getItemName(parsed: URL): string | null {
44
+ if (!parsed.pathname.startsWith("/items")) return null;
45
+ const itemName = parsed.searchParams.get("itemName");
46
+ if (!itemName) return null;
47
+ const decoded = decodeURIComponent(itemName);
48
+ if (!decoded.includes(".")) return null;
49
+ return decoded;
50
+ }
51
+
52
+ function toStatMap(stats: MarketplaceStatistic[] | undefined): Map<string, number> {
53
+ const map = new Map<string, number>();
54
+ if (!stats) return map;
55
+ for (const stat of stats) {
56
+ if (!stat.statisticName || typeof stat.value !== "number") continue;
57
+ map.set(stat.statisticName.trim().toLowerCase(), stat.value);
58
+ }
59
+ return map;
60
+ }
61
+
62
+ function formatRating(averageRating?: number, ratingCount?: number): string | null {
63
+ if (averageRating === undefined && ratingCount === undefined) return null;
64
+ if (averageRating !== undefined) {
65
+ const formatted = averageRating.toFixed(2).replace(/\.0+$/, "").replace(/\.$/, "");
66
+ if (ratingCount !== undefined) {
67
+ return `${formatted} (${formatCount(ratingCount)} ratings)`;
68
+ }
69
+ return formatted;
70
+ }
71
+ if (ratingCount !== undefined) {
72
+ return `${formatCount(ratingCount)} ratings`;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function extractRepoLink(properties: MarketplaceProperty[] | undefined): string | null {
78
+ if (!properties) return null;
79
+ for (const prop of properties) {
80
+ const key = prop.key?.trim().toLowerCase();
81
+ const value = prop.value?.trim();
82
+ if (!key || !value) continue;
83
+ if (!value.startsWith("http")) continue;
84
+ if (key.includes("links.source") || key.includes("repository")) return value;
85
+ }
86
+ for (const prop of properties) {
87
+ const key = prop.key?.trim().toLowerCase();
88
+ const value = prop.value?.trim();
89
+ if (!key || !value) continue;
90
+ if (!value.startsWith("http")) continue;
91
+ if (key === "source" || key.endsWith(".source")) return value;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Handle VS Code Marketplace URLs via extension query API
98
+ */
99
+ export const handleVscodeMarketplace: SpecialHandler = async (
100
+ url: string,
101
+ timeout: number,
102
+ signal?: AbortSignal,
103
+ ): Promise<RenderResult | null> => {
104
+ try {
105
+ const parsed = new URL(url);
106
+ if (!MARKETPLACE_HOSTS.has(parsed.hostname)) return null;
107
+
108
+ const itemName = getItemName(parsed);
109
+ if (!itemName) return null;
110
+
111
+ const [publisherFromUrl, ...nameParts] = itemName.split(".");
112
+ const extensionFromUrl = nameParts.join(".");
113
+
114
+ const fetchedAt = new Date().toISOString();
115
+ const apiUrl = "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery";
116
+ const payload = JSON.stringify({
117
+ filters: [
118
+ {
119
+ criteria: [{ filterType: 7, value: itemName }],
120
+ },
121
+ ],
122
+ flags: 950,
123
+ });
124
+
125
+ const result = await loadPage(apiUrl, {
126
+ timeout,
127
+ signal,
128
+ method: "POST",
129
+ body: payload,
130
+ headers: {
131
+ "Content-Type": "application/json",
132
+ Accept: "application/json;api-version=7.2-preview.1",
133
+ },
134
+ });
135
+
136
+ if (!result.ok) return null;
137
+
138
+ let data: MarketplaceResponse;
139
+ try {
140
+ data = JSON.parse(result.content) as MarketplaceResponse;
141
+ } catch {
142
+ return null;
143
+ }
144
+
145
+ const extension = data.results?.[0]?.extensions?.[0];
146
+ if (!extension) return null;
147
+
148
+ const extensionName = extension.extensionName ?? extensionFromUrl;
149
+ const displayName = extension.displayName ?? extensionName ?? itemName;
150
+ const description = extension.shortDescription ?? extension.description;
151
+
152
+ const publisherName = extension.publisher?.publisherName ?? publisherFromUrl;
153
+ const publisherDisplayName = extension.publisher?.displayName;
154
+ const publisherLabel =
155
+ publisherDisplayName && publisherName && publisherDisplayName !== publisherName
156
+ ? `${publisherDisplayName} (${publisherName})`
157
+ : (publisherDisplayName ?? publisherName);
158
+
159
+ const version = extension.versions?.[0]?.version;
160
+ const statMap = toStatMap(extension.statistics);
161
+ const installs = statMap.get("install") ?? statMap.get("installs");
162
+ const averageRating = statMap.get("averagerating");
163
+ const ratingCount = statMap.get("ratingcount");
164
+ const ratingLabel = formatRating(averageRating, ratingCount);
165
+
166
+ const repoLink = extractRepoLink(extension.versions?.[0]?.properties) ?? extractRepoLink(extension.properties);
167
+
168
+ const identifier = publisherName && extensionName ? `${publisherName}.${extensionName}` : itemName;
169
+
170
+ let md = `# ${displayName}\n\n`;
171
+ if (description) md += `${description}\n\n`;
172
+ md += `**Identifier:** ${identifier}\n`;
173
+ if (publisherLabel) md += `**Publisher:** ${publisherLabel}\n`;
174
+ if (version) md += `**Version:** ${version}\n`;
175
+ if (installs !== undefined) md += `**Installs:** ${formatCount(installs)}\n`;
176
+ if (ratingLabel) md += `**Rating:** ${ratingLabel}\n`;
177
+ if (extension.categories?.length) md += `**Categories:** ${extension.categories.join(", ")}\n`;
178
+ if (extension.tags?.length) md += `**Tags:** ${extension.tags.join(", ")}\n`;
179
+ if (repoLink) md += `**Repository:** ${repoLink}\n`;
180
+
181
+ const output = finalizeOutput(md);
182
+ return {
183
+ url,
184
+ finalUrl: url,
185
+ contentType: "text/markdown",
186
+ method: "vscode-marketplace",
187
+ content: output.content,
188
+ fetchedAt,
189
+ truncated: output.truncated,
190
+ notes: ["Fetched via VS Code Marketplace API"],
191
+ };
192
+ } catch {}
193
+
194
+ return null;
195
+ };