@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
@@ -1,86 +1,26 @@
1
1
  import { tmpdir } from "node:os";
2
2
  import * as path from "node:path";
3
3
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
+ import type { Component } from "@oh-my-pi/pi-tui";
5
+ import { Text } from "@oh-my-pi/pi-tui";
4
6
  import { Type } from "@sinclair/typebox";
7
+ import { nanoid } from "nanoid";
5
8
  import { parse as parseHtml } from "node-html-parser";
9
+ import { type Theme, theme } from "../../modes/interactive/theme/theme";
6
10
  import webFetchDescription from "../../prompts/tools/web-fetch.md" with { type: "text" };
7
11
  import { ensureTool } from "../../utils/tools-manager";
8
- import { logger } from "../logger";
12
+ import type { RenderResultOptions } from "../custom-tools/types";
9
13
  import type { ToolSession } from "./index";
10
- import {
11
- handleArtifactHub,
12
- handleArxiv,
13
- handleAur,
14
- handleBiorxiv,
15
- handleBluesky,
16
- handleBrew,
17
- handleCheatSh,
18
- handleChocolatey,
19
- handleCoinGecko,
20
- handleCratesIo,
21
- handleDevTo,
22
- handleDiscogs,
23
- handleDockerHub,
24
- handleGitHub,
25
- handleGitHubGist,
26
- handleGitLab,
27
- handleGoPkg,
28
- handleHackage,
29
- handleHackerNews,
30
- handleHex,
31
- handleHuggingFace,
32
- handleIacr,
33
- handleLobsters,
34
- handleMastodon,
35
- handleMaven,
36
- handleMDN,
37
- handleMetaCPAN,
38
- handleNpm,
39
- handleNuGet,
40
- handleNvd,
41
- handleOpenCorporates,
42
- handleOpenLibrary,
43
- handleOsv,
44
- handlePackagist,
45
- handlePubDev,
46
- handlePubMed,
47
- handlePyPI,
48
- handleReadTheDocs,
49
- handleReddit,
50
- handleRepology,
51
- handleRfc,
52
- handleRubyGems,
53
- handleSecEdgar,
54
- handleSemanticScholar,
55
- handleSpotify,
56
- handleStackOverflow,
57
- handleTerraform,
58
- handleTldr,
59
- handleTwitter,
60
- handleVimeo,
61
- handleWikidata,
62
- handleWikipedia,
63
- handleYouTube,
64
- } from "./web-fetch-handlers/index";
14
+ import { specialHandlers } from "./web-scrapers/index";
15
+ import type { RenderResult } from "./web-scrapers/types";
16
+ import { finalizeOutput, loadPage } from "./web-scrapers/types";
17
+ import { convertWithMarkitdown, fetchBinary } from "./web-scrapers/utils";
65
18
 
66
19
  // =============================================================================
67
20
  // Types and Constants
68
21
  // =============================================================================
69
22
 
70
- interface RenderResult {
71
- url: string;
72
- finalUrl: string;
73
- contentType: string;
74
- method: string;
75
- content: string;
76
- fetchedAt: string;
77
- truncated: boolean;
78
- notes: string[];
79
- }
80
-
81
23
  const DEFAULT_TIMEOUT = 20;
82
- const MAX_BYTES = 50 * 1024 * 1024; // 50MB for binary files
83
- const MAX_OUTPUT_CHARS = 500_000;
84
24
 
85
25
  // Convertible document types (markitdown supported)
86
26
  const CONVERTIBLE_MIMES = new Set([
@@ -123,124 +63,11 @@ const CONVERTIBLE_EXTENSIONS = new Set([
123
63
  ".ogg",
124
64
  ]);
125
65
 
126
- const USER_AGENTS = [
127
- "curl/8.0",
128
- "Mozilla/5.0 (compatible; TextBot/1.0)",
129
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
130
- ];
131
-
132
66
  // =============================================================================
133
67
  // Utilities
134
68
  // =============================================================================
135
69
 
136
- interface LoadPageResult {
137
- content: string;
138
- contentType: string;
139
- finalUrl: string;
140
- ok: boolean;
141
- status?: number;
142
- }
143
-
144
- interface LoadPageOptions {
145
- timeout?: number;
146
- headers?: Record<string, string>;
147
- maxBytes?: number;
148
- }
149
-
150
- /**
151
- * Check if response indicates bot blocking (Cloudflare, etc.)
152
- */
153
- function isBotBlocked(status: number, content: string): boolean {
154
- if (status === 403 || status === 503) {
155
- const lower = content.toLowerCase();
156
- return (
157
- lower.includes("cloudflare") ||
158
- lower.includes("captcha") ||
159
- lower.includes("challenge") ||
160
- lower.includes("blocked") ||
161
- lower.includes("access denied") ||
162
- lower.includes("bot detection")
163
- );
164
- }
165
- return false;
166
- }
167
-
168
- /**
169
- * Fetch a page with timeout, size limit, and automatic retry with browser UA if blocked
170
- */
171
- async function loadPage(url: string, options: LoadPageOptions = {}): Promise<LoadPageResult> {
172
- const { timeout = 20, headers = {}, maxBytes = MAX_BYTES } = options;
173
-
174
- for (let attempt = 0; attempt < USER_AGENTS.length; attempt++) {
175
- const userAgent = USER_AGENTS[attempt];
176
-
177
- try {
178
- const controller = new AbortController();
179
- const timeoutId = setTimeout(() => controller.abort(), timeout * 1000);
180
-
181
- const response = await fetch(url, {
182
- signal: controller.signal,
183
- headers: {
184
- "User-Agent": userAgent,
185
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
186
- "Accept-Language": "en-US,en;q=0.5",
187
- ...headers,
188
- },
189
- redirect: "follow",
190
- });
191
-
192
- clearTimeout(timeoutId);
193
-
194
- const contentType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
195
- const finalUrl = response.url;
196
-
197
- // Read with size limit
198
- const reader = response.body?.getReader();
199
- if (!reader) {
200
- return { content: "", contentType, finalUrl, ok: false, status: response.status };
201
- }
202
-
203
- const chunks: Uint8Array[] = [];
204
- let totalSize = 0;
205
-
206
- while (true) {
207
- const { done, value } = await reader.read();
208
- if (done) break;
209
-
210
- chunks.push(value);
211
- totalSize += value.length;
212
-
213
- if (totalSize > maxBytes) {
214
- reader.cancel();
215
- break;
216
- }
217
- }
218
-
219
- const decoder = new TextDecoder();
220
- const content = decoder.decode(Buffer.concat(chunks));
221
-
222
- // Check if we got blocked and should retry with browser UA
223
- if (isBotBlocked(response.status, content) && attempt < USER_AGENTS.length - 1) {
224
- continue;
225
- }
226
-
227
- if (!response.ok) {
228
- return { content, contentType, finalUrl, ok: false, status: response.status };
229
- }
230
-
231
- return { content, contentType, finalUrl, ok: true, status: response.status };
232
- } catch (err) {
233
- // On last attempt, return failure
234
- if (attempt === USER_AGENTS.length - 1) {
235
- logger.debug("Web fetch failed after retries", { url, error: String(err) });
236
- return { content: "", contentType: "", finalUrl: url, ok: false };
237
- }
238
- // Otherwise retry with next UA
239
- }
240
- }
241
-
242
- return { content: "", contentType: "", finalUrl: url, ok: false };
243
- }
70
+ type SpawnSyncOptions = NonNullable<Parameters<typeof Bun.spawnSync>[1]>;
244
71
 
245
72
  /**
246
73
  * Execute a command and return stdout
@@ -250,8 +77,9 @@ function exec(
250
77
  args: string[],
251
78
  options?: { timeout?: number; input?: string | Buffer },
252
79
  ): { stdout: string; stderr: string; ok: boolean } {
80
+ const stdin = (options?.input ?? "ignore") as SpawnSyncOptions["stdin"];
253
81
  const result = Bun.spawnSync([cmd, ...args], {
254
- stdin: options?.input ? (options.input as any) : "ignore",
82
+ stdin,
255
83
  stdout: "pipe",
256
84
  stderr: "pipe",
257
85
  });
@@ -344,39 +172,10 @@ function looksLikeHtml(content: string): boolean {
344
172
  );
345
173
  }
346
174
 
347
- /**
348
- * Convert binary file to markdown using markitdown
349
- */
350
- async function convertWithMarkitdown(
351
- content: Buffer,
352
- extensionHint: string,
353
- timeout: number,
354
- ): Promise<{ content: string; ok: boolean }> {
355
- const markitdown = await ensureTool("markitdown", true);
356
- if (!markitdown) {
357
- return { content: "", ok: false };
358
- }
359
-
360
- // Write to temp file with extension hint
361
- const ext = extensionHint || ".bin";
362
- const tmpDir = tmpdir();
363
- const tmpFile = path.join(tmpDir, `omp-convert-${Date.now()}${ext}`);
364
-
365
- try {
366
- await Bun.write(tmpFile, content);
367
- const result = exec(markitdown, [tmpFile], { timeout });
368
- return { content: result.stdout, ok: result.ok };
369
- } finally {
370
- try {
371
- await Bun.$`rm ${tmpFile}`.quiet();
372
- } catch {}
373
- }
374
- }
375
-
376
175
  /**
377
176
  * Try fetching URL with .md appended (llms.txt convention)
378
177
  */
379
- async function tryMdSuffix(url: string, timeout: number): Promise<string | null> {
178
+ async function tryMdSuffix(url: string, timeout: number, signal?: AbortSignal): Promise<string | null> {
380
179
  const candidates: string[] = [];
381
180
 
382
181
  try {
@@ -397,8 +196,15 @@ async function tryMdSuffix(url: string, timeout: number): Promise<string | null>
397
196
  return null;
398
197
  }
399
198
 
199
+ if (signal?.aborted) {
200
+ return null;
201
+ }
202
+
400
203
  for (const candidate of candidates) {
401
- const result = await loadPage(candidate, { timeout: Math.min(timeout, 5) });
204
+ if (signal?.aborted) {
205
+ return null;
206
+ }
207
+ const result = await loadPage(candidate, { timeout: Math.min(timeout, 5), signal });
402
208
  if (result.ok && result.content.trim().length > 100 && !looksLikeHtml(result.content)) {
403
209
  return result.content;
404
210
  }
@@ -410,11 +216,18 @@ async function tryMdSuffix(url: string, timeout: number): Promise<string | null>
410
216
  /**
411
217
  * Try to fetch LLM-friendly endpoints
412
218
  */
413
- async function tryLlmEndpoints(origin: string, timeout: number): Promise<string | null> {
219
+ async function tryLlmEndpoints(origin: string, timeout: number, signal?: AbortSignal): Promise<string | null> {
414
220
  const endpoints = [`${origin}/.well-known/llms.txt`, `${origin}/llms.txt`, `${origin}/llms.md`];
415
221
 
222
+ if (signal?.aborted) {
223
+ return null;
224
+ }
225
+
416
226
  for (const endpoint of endpoints) {
417
- const result = await loadPage(endpoint, { timeout: Math.min(timeout, 5) });
227
+ if (signal?.aborted) {
228
+ return null;
229
+ }
230
+ const result = await loadPage(endpoint, { timeout: Math.min(timeout, 5), signal });
418
231
  if (result.ok && result.content.trim().length > 100 && !looksLikeHtml(result.content)) {
419
232
  return result.content;
420
233
  }
@@ -425,10 +238,19 @@ async function tryLlmEndpoints(origin: string, timeout: number): Promise<string
425
238
  /**
426
239
  * Try content negotiation for markdown/plain
427
240
  */
428
- async function tryContentNegotiation(url: string, timeout: number): Promise<{ content: string; type: string } | null> {
241
+ async function tryContentNegotiation(
242
+ url: string,
243
+ timeout: number,
244
+ signal?: AbortSignal,
245
+ ): Promise<{ content: string; type: string } | null> {
246
+ if (signal?.aborted) {
247
+ return null;
248
+ }
249
+
429
250
  const result = await loadPage(url, {
430
251
  timeout,
431
252
  headers: { Accept: "text/markdown, text/plain;q=0.9, text/html;q=0.8" },
253
+ signal,
432
254
  });
433
255
 
434
256
  if (!result.ok) return null;
@@ -586,7 +408,7 @@ async function renderHtmlToText(
586
408
  timeout: number,
587
409
  ): Promise<{ content: string; ok: boolean; method: string }> {
588
410
  const tmpDir = tmpdir();
589
- const tmpFile = path.join(tmpDir, `omp-render-${Date.now()}.html`);
411
+ const tmpFile = path.join(tmpDir, `omp-${nanoid()}.html`);
590
412
 
591
413
  try {
592
414
  await Bun.write(tmpFile, html);
@@ -658,64 +480,6 @@ function formatJson(content: string): string {
658
480
  }
659
481
  }
660
482
 
661
- /**
662
- * Truncate and cleanup output
663
- */
664
- function finalizeOutput(content: string): { content: string; truncated: boolean } {
665
- const cleaned = content.replace(/\n{3,}/g, "\n\n").trim();
666
- const truncated = cleaned.length > MAX_OUTPUT_CHARS;
667
- return {
668
- content: cleaned.slice(0, MAX_OUTPUT_CHARS),
669
- truncated,
670
- };
671
- }
672
-
673
- /**
674
- * Fetch page as binary buffer (for convertible files)
675
- */
676
- async function fetchBinary(
677
- url: string,
678
- timeout: number,
679
- ): Promise<{ buffer: Buffer; contentType: string; contentDisposition?: string; ok: boolean }> {
680
- try {
681
- const controller = new AbortController();
682
- const timeoutId = setTimeout(() => controller.abort(), timeout * 1000);
683
-
684
- const response = await fetch(url, {
685
- signal: controller.signal,
686
- headers: {
687
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0",
688
- },
689
- redirect: "follow",
690
- });
691
-
692
- clearTimeout(timeoutId);
693
-
694
- if (!response.ok) {
695
- return { buffer: Buffer.alloc(0), contentType: "", ok: false };
696
- }
697
-
698
- const contentType = response.headers.get("content-type") ?? "";
699
- const contentDisposition = response.headers.get("content-disposition") ?? undefined;
700
- const contentLength = response.headers.get("content-length");
701
- if (contentLength) {
702
- const size = Number.parseInt(contentLength, 10);
703
- if (Number.isFinite(size) && size > MAX_BYTES) {
704
- return { buffer: Buffer.alloc(0), contentType, contentDisposition, ok: false };
705
- }
706
- }
707
-
708
- const buffer = Buffer.from(await response.arrayBuffer());
709
- if (buffer.length > MAX_BYTES) {
710
- return { buffer: Buffer.alloc(0), contentType, contentDisposition, ok: false };
711
- }
712
-
713
- return { buffer, contentType, contentDisposition, ok: true };
714
- } catch {
715
- return { buffer: Buffer.alloc(0), contentType: "", ok: false };
716
- }
717
- }
718
-
719
483
  // =============================================================================
720
484
  // Unified Special Handler Dispatch
721
485
  // =============================================================================
@@ -723,74 +487,15 @@ async function fetchBinary(
723
487
  /**
724
488
  * Try all special handlers
725
489
  */
726
- async function handleSpecialUrls(url: string, timeout: number): Promise<RenderResult | null> {
727
- // Order matters - more specific first
728
- return (
729
- // Git hosting
730
- (await handleGitHubGist(url, timeout)) ||
731
- (await handleGitHub(url, timeout)) ||
732
- (await handleGitLab(url, timeout)) ||
733
- // Video/Media
734
- (await handleYouTube(url, timeout)) ||
735
- (await handleVimeo(url, timeout)) ||
736
- (await handleSpotify(url, timeout)) ||
737
- (await handleDiscogs(url, timeout)) ||
738
- // Social/News
739
- (await handleTwitter(url, timeout)) ||
740
- (await handleBluesky(url, timeout)) ||
741
- (await handleMastodon(url, timeout)) ||
742
- (await handleHackerNews(url, timeout)) ||
743
- (await handleLobsters(url, timeout)) ||
744
- (await handleReddit(url, timeout)) ||
745
- // Developer content
746
- (await handleStackOverflow(url, timeout)) ||
747
- (await handleDevTo(url, timeout)) ||
748
- (await handleMDN(url, timeout)) ||
749
- (await handleReadTheDocs(url, timeout)) ||
750
- (await handleTldr(url, timeout)) ||
751
- (await handleCheatSh(url, timeout)) ||
752
- // Package registries
753
- (await handleNpm(url, timeout)) ||
754
- (await handleNuGet(url, timeout)) ||
755
- (await handleChocolatey(url, timeout)) ||
756
- (await handleBrew(url, timeout)) ||
757
- (await handlePyPI(url, timeout)) ||
758
- (await handleCratesIo(url, timeout)) ||
759
- (await handleDockerHub(url, timeout)) ||
760
- (await handleGoPkg(url, timeout)) ||
761
- (await handleHex(url, timeout)) ||
762
- (await handlePackagist(url, timeout)) ||
763
- (await handlePubDev(url, timeout)) ||
764
- (await handleMaven(url, timeout)) ||
765
- (await handleArtifactHub(url, timeout)) ||
766
- (await handleRubyGems(url, timeout)) ||
767
- (await handleTerraform(url, timeout)) ||
768
- (await handleAur(url, timeout)) ||
769
- (await handleHackage(url, timeout)) ||
770
- (await handleMetaCPAN(url, timeout)) ||
771
- (await handleRepology(url, timeout)) ||
772
- // ML/AI
773
- (await handleHuggingFace(url, timeout)) ||
774
- // Academic
775
- (await handleArxiv(url, timeout)) ||
776
- (await handleBiorxiv(url, timeout)) ||
777
- (await handleIacr(url, timeout)) ||
778
- (await handleSemanticScholar(url, timeout)) ||
779
- (await handlePubMed(url, timeout)) ||
780
- (await handleRfc(url, timeout)) ||
781
- // Security
782
- (await handleNvd(url, timeout)) ||
783
- (await handleOsv(url, timeout)) ||
784
- // Crypto
785
- (await handleCoinGecko(url, timeout)) ||
786
- // Business
787
- (await handleOpenCorporates(url, timeout)) ||
788
- (await handleSecEdgar(url, timeout)) ||
789
- // Reference
790
- (await handleOpenLibrary(url, timeout)) ||
791
- (await handleWikidata(url, timeout)) ||
792
- (await handleWikipedia(url, timeout))
793
- );
490
+ async function handleSpecialUrls(url: string, timeout: number, signal?: AbortSignal): Promise<RenderResult | null> {
491
+ for (const handler of specialHandlers) {
492
+ if (signal?.aborted) {
493
+ throw new Error("Operation aborted");
494
+ }
495
+ const result = await handler(url, timeout, signal);
496
+ if (result) return result;
497
+ }
498
+ return null;
794
499
  }
795
500
 
796
501
  // =============================================================================
@@ -800,9 +505,17 @@ async function handleSpecialUrls(url: string, timeout: number): Promise<RenderRe
800
505
  /**
801
506
  * Main render function implementing the full pipeline
802
507
  */
803
- async function renderUrl(url: string, timeout: number, raw: boolean = false): Promise<RenderResult> {
508
+ async function renderUrl(
509
+ url: string,
510
+ timeout: number,
511
+ raw: boolean = false,
512
+ signal?: AbortSignal,
513
+ ): Promise<RenderResult> {
804
514
  const notes: string[] = [];
805
515
  const fetchedAt = new Date().toISOString();
516
+ if (signal?.aborted) {
517
+ throw new Error("Operation aborted");
518
+ }
806
519
 
807
520
  // Step 0: Normalize URL (ensure scheme for special handlers)
808
521
  url = normalizeUrl(url);
@@ -810,12 +523,15 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
810
523
 
811
524
  // Step 1: Try special handlers for known sites (unless raw mode)
812
525
  if (!raw) {
813
- const specialResult = await handleSpecialUrls(url, timeout);
526
+ const specialResult = await handleSpecialUrls(url, timeout, signal);
814
527
  if (specialResult) return specialResult;
815
528
  }
816
529
 
817
530
  // Step 2: Fetch page
818
- const response = await loadPage(url, { timeout });
531
+ const response = await loadPage(url, { timeout, signal });
532
+ if (signal?.aborted) {
533
+ throw new Error("Operation aborted");
534
+ }
819
535
  if (!response.ok) {
820
536
  return {
821
537
  url,
@@ -835,26 +551,36 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
835
551
 
836
552
  // Step 3: Handle convertible binary files (PDF, DOCX, etc.)
837
553
  if (isConvertible(mime, extHint)) {
838
- const binary = await fetchBinary(finalUrl, timeout);
554
+ const binary = await fetchBinary(finalUrl, timeout, signal);
839
555
  if (binary.ok) {
840
556
  const ext = getExtensionHint(finalUrl, binary.contentDisposition) || extHint;
841
- const converted = await convertWithMarkitdown(binary.buffer, ext, timeout);
842
- if (converted.ok && converted.content.trim().length > 50) {
843
- notes.push(`Converted with markitdown`);
844
- const output = finalizeOutput(converted.content);
845
- return {
846
- url,
847
- finalUrl,
848
- contentType: mime,
849
- method: "markitdown",
850
- content: output.content,
851
- fetchedAt,
852
- truncated: output.truncated,
853
- notes,
854
- };
557
+ const converted = await convertWithMarkitdown(binary.buffer, ext, timeout, signal);
558
+ if (converted.ok) {
559
+ if (converted.content.trim().length > 50) {
560
+ notes.push("Converted with markitdown");
561
+ const output = finalizeOutput(converted.content);
562
+ return {
563
+ url,
564
+ finalUrl,
565
+ contentType: mime,
566
+ method: "markitdown",
567
+ content: output.content,
568
+ fetchedAt,
569
+ truncated: output.truncated,
570
+ notes,
571
+ };
572
+ }
573
+ notes.push("markitdown conversion produced no usable output");
574
+ } else if (converted.error) {
575
+ notes.push(`markitdown conversion failed: ${converted.error}`);
576
+ } else {
577
+ notes.push("markitdown conversion failed");
855
578
  }
579
+ } else if (binary.error) {
580
+ notes.push(`Binary fetch failed: ${binary.error}`);
581
+ } else {
582
+ notes.push("Binary fetch failed");
856
583
  }
857
- notes.push("markitdown conversion failed");
858
584
  }
859
585
 
860
586
  // Step 4: Handle non-HTML text content
@@ -914,7 +640,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
914
640
  const markdownAlt = alternates.find((alt) => alt.endsWith(".md") || alt.includes("markdown"));
915
641
  if (markdownAlt) {
916
642
  const resolved = markdownAlt.startsWith("http") ? markdownAlt : new URL(markdownAlt, finalUrl).href;
917
- const altResult = await loadPage(resolved, { timeout });
643
+ const altResult = await loadPage(resolved, { timeout, signal });
918
644
  if (altResult.ok && altResult.content.trim().length > 100 && !looksLikeHtml(altResult.content)) {
919
645
  notes.push(`Used markdown alternate: ${resolved}`);
920
646
  const output = finalizeOutput(altResult.content);
@@ -932,7 +658,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
932
658
  }
933
659
 
934
660
  // 5B: Try URL.md suffix (llms.txt convention)
935
- const mdSuffix = await tryMdSuffix(finalUrl, timeout);
661
+ const mdSuffix = await tryMdSuffix(finalUrl, timeout, signal);
936
662
  if (mdSuffix) {
937
663
  notes.push("Found .md suffix version");
938
664
  const output = finalizeOutput(mdSuffix);
@@ -949,7 +675,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
949
675
  }
950
676
 
951
677
  // 5C: LLM-friendly endpoints
952
- const llmContent = await tryLlmEndpoints(origin, timeout);
678
+ const llmContent = await tryLlmEndpoints(origin, timeout, signal);
953
679
  if (llmContent) {
954
680
  notes.push("Found llms.txt");
955
681
  const output = finalizeOutput(llmContent);
@@ -966,7 +692,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
966
692
  }
967
693
 
968
694
  // 5D: Content negotiation
969
- const negotiated = await tryContentNegotiation(url, timeout);
695
+ const negotiated = await tryContentNegotiation(url, timeout, signal);
970
696
  if (negotiated) {
971
697
  notes.push(`Content negotiation returned ${negotiated.type}`);
972
698
  const output = finalizeOutput(negotiated.content);
@@ -986,7 +712,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
986
712
  const feedAlternates = alternates.filter((alt) => !alt.endsWith(".md") && !alt.includes("markdown"));
987
713
  for (const altUrl of feedAlternates.slice(0, 2)) {
988
714
  const resolved = altUrl.startsWith("http") ? altUrl : new URL(altUrl, finalUrl).href;
989
- const altResult = await loadPage(resolved, { timeout });
715
+ const altResult = await loadPage(resolved, { timeout, signal });
990
716
  if (altResult.ok && altResult.content.trim().length > 200) {
991
717
  notes.push(`Used feed alternate: ${resolved}`);
992
718
  const parsed = parseFeedToMarkdown(altResult.content);
@@ -1004,6 +730,10 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
1004
730
  }
1005
731
  }
1006
732
 
733
+ if (signal?.aborted) {
734
+ throw new Error("Operation aborted");
735
+ }
736
+
1007
737
  // Step 6: Render HTML with lynx or html2text
1008
738
  const htmlResult = await renderHtmlToText(rawContent, timeout);
1009
739
  if (!htmlResult.ok) {
@@ -1026,10 +756,10 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
1026
756
  const docLinks = extractDocumentLinks(rawContent, finalUrl);
1027
757
  if (docLinks.length > 0) {
1028
758
  const docUrl = docLinks[0];
1029
- const binary = await fetchBinary(docUrl, timeout);
759
+ const binary = await fetchBinary(docUrl, timeout, signal);
1030
760
  if (binary.ok) {
1031
761
  const ext = getExtensionHint(docUrl, binary.contentDisposition);
1032
- const converted = await convertWithMarkitdown(binary.buffer, ext, timeout);
762
+ const converted = await convertWithMarkitdown(binary.buffer, ext, timeout, signal);
1033
763
  if (converted.ok && converted.content.trim().length > htmlResult.content.length) {
1034
764
  notes.push(`Extracted and converted document: ${docUrl}`);
1035
765
  const output = finalizeOutput(converted.content);
@@ -1044,6 +774,11 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
1044
774
  notes,
1045
775
  };
1046
776
  }
777
+ if (!converted.ok && converted.error) {
778
+ notes.push(`markitdown conversion failed: ${converted.error}`);
779
+ }
780
+ } else if (binary.error) {
781
+ notes.push(`Binary fetch failed: ${binary.error}`);
1047
782
  }
1048
783
  }
1049
784
  notes.push("Page appears to require JavaScript or is mostly navigation");
@@ -1106,11 +841,16 @@ export function createWebFetchTool(_session: ToolSession): AgentTool<typeof webF
1106
841
  execute: async (
1107
842
  _toolCallId: string,
1108
843
  { url, timeout = DEFAULT_TIMEOUT, raw = false }: { url: string; timeout?: number; raw?: boolean },
844
+ signal?: AbortSignal,
1109
845
  ) => {
846
+ if (signal?.aborted) {
847
+ throw new Error("Operation aborted");
848
+ }
849
+
1110
850
  // Clamp timeout
1111
851
  const effectiveTimeout = Math.min(Math.max(timeout, 1), 120);
1112
852
 
1113
- const result = await renderUrl(url, effectiveTimeout, raw);
853
+ const result = await renderUrl(url, effectiveTimeout, raw, signal);
1114
854
 
1115
855
  // Format output
1116
856
  let output = "";
@@ -1147,11 +887,6 @@ export function createWebFetchTool(_session: ToolSession): AgentTool<typeof webF
1147
887
  // TUI Rendering
1148
888
  // =============================================================================
1149
889
 
1150
- import type { Component } from "@oh-my-pi/pi-tui";
1151
- import { Text } from "@oh-my-pi/pi-tui";
1152
- import { type Theme, theme } from "../../modes/interactive/theme/theme";
1153
- import type { RenderResultOptions } from "../custom-tools/types";
1154
-
1155
890
  /** Truncate text to max length with ellipsis */
1156
891
  function truncate(text: string, maxLen: number, ellipsis: string): string {
1157
892
  if (text.length <= maxLen) return text;