@oh-my-pi/pi-coding-agent 13.17.6 → 13.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.18.0] - 2026-04-02
6
+ ### Breaking Changes
7
+
8
+ - Removed standalone `fetch` tool; URL fetching is now integrated into the `read` tool
9
+
10
+ ### Added
11
+
12
+ - Added URL reading capability to `read` tool with support for web pages, GitHub issues, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, technical blogs, RSS/Atom feeds, and JSON endpoints
13
+ - Added `offset` and `limit` parameter support for paginating cached URL fetch results
14
+ - Added URL caching mechanism to avoid redundant network requests when reading the same URL multiple times
15
+
16
+ ### Changed
17
+
18
+ - Renamed `fetch.enabled` setting to `Read URLs` with updated description to reflect integration with read tool
19
+ - Updated `read` tool to accept `timeout` and `raw` parameters for URL handling
20
+ - Updated `read` tool to support `file://` URLs for local file paths
21
+ - Removed `fetch` tool from agent tool lists (explore, librarian, oracle, plan, reviewer agents)
22
+
23
+ ### Fixed
24
+
25
+ - Fixed `read` tool to properly handle `file://` URL scheme by converting to filesystem paths
26
+
5
27
  ## [13.17.5] - 2026-04-01
6
28
  ### Added
7
29
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.17.6",
4
+ "version": "13.18.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -42,12 +42,12 @@
42
42
  "dependencies": {
43
43
  "@agentclientprotocol/sdk": "0.16.1",
44
44
  "@mozilla/readability": "^0.6",
45
- "@oh-my-pi/omp-stats": "13.17.6",
46
- "@oh-my-pi/pi-agent-core": "13.17.6",
47
- "@oh-my-pi/pi-ai": "13.17.6",
48
- "@oh-my-pi/pi-natives": "13.17.6",
49
- "@oh-my-pi/pi-tui": "13.17.6",
50
- "@oh-my-pi/pi-utils": "13.17.6",
45
+ "@oh-my-pi/omp-stats": "13.18.0",
46
+ "@oh-my-pi/pi-agent-core": "13.18.0",
47
+ "@oh-my-pi/pi-ai": "13.18.0",
48
+ "@oh-my-pi/pi-natives": "13.18.0",
49
+ "@oh-my-pi/pi-tui": "13.18.0",
50
+ "@oh-my-pi/pi-utils": "13.18.0",
51
51
  "@sinclair/typebox": "^0.34",
52
52
  "@xterm/headless": "^6.0",
53
53
  "ajv": "^8.18",
package/src/cli/args.ts CHANGED
@@ -262,7 +262,6 @@ ${chalk.bold("Available Tools (default-enabled unless noted):")}
262
262
  browser - Browser automation (Puppeteer)
263
263
  task - Launch sub-agents for parallel tasks
264
264
  todo_write - Manage todo/task lists
265
- fetch - Fetch and process URLs
266
265
  web_search - Search the web
267
266
  ask - Ask user questions (interactive mode only)
268
267
 
@@ -1207,7 +1207,7 @@ export const SETTINGS_SCHEMA = {
1207
1207
  "fetch.enabled": {
1208
1208
  type: "boolean",
1209
1209
  default: true,
1210
- ui: { tab: "tools", label: "Fetch", description: "Enable the fetch tool for URL fetching" },
1210
+ ui: { tab: "tools", label: "Read URLs", description: "Allow the read tool to fetch and process URLs" },
1211
1211
  },
1212
1212
 
1213
1213
  "github.enabled": {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Types for the internal URL routing system.
3
3
  *
4
- * Internal URLs (agent://, artifact://, memory://, skill://, rule://, mcp://, pi://, local://) are resolved by tools like fetch and read,
4
+ * Internal URLs (agent://, artifact://, memory://, skill://, rule://, mcp://, pi://, local://) are resolved by tools like read,
5
5
  * providing access to agent outputs and server resources without exposing filesystem paths.
6
6
  */
7
7
 
@@ -106,7 +106,6 @@ export function mapToolKind(toolName: string): ToolKind {
106
106
  case "find":
107
107
  case "ast_grep":
108
108
  return "search";
109
- case "fetch":
110
109
  case "web_search":
111
110
  return "fetch";
112
111
  case "todo_write":
@@ -2,7 +2,7 @@
2
2
  name: designer
3
3
  description: UI/UX specialist for design implementation, review, visual refinement
4
4
  spawns: explore
5
- model: google-gemini-cli/gemini-3-pro, gemini-3-pro, gemini-3, pi/default
5
+ model: google-gemini-cli/gemini-3.1-pro, google-gemini-cli/gemini-3-pro, gemini-3.1-pro, gemini-3-1-pro, gemini-3-pro, gemini-3, pi/default
6
6
  ---
7
7
 
8
8
  You are an expert UI/UX designer implementing and reviewing UI designs.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: explore
3
3
  description: Fast read-only codebase scout returning compressed context for handoff
4
- tools: read, grep, find, fetch, web_search
4
+ tools: read, grep, find, web_search
5
5
  model: pi/smol
6
6
  thinking-level: med
7
7
  output:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: librarian
3
3
  description: Researches external libraries and APIs by reading source code. Returns definitive, source-verified answers.
4
- tools: read, grep, find, bash, lsp, web_search, fetch, ast_grep
4
+ tools: read, grep, find, bash, lsp, web_search, ast_grep
5
5
  model: pi/smol
6
6
  thinking-level: minimal
7
7
  output:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: oracle
3
3
  description: Deep reasoning advisor for debugging dead ends, architecture decisions, and second opinions. Read-only.
4
- tools: read, grep, find, bash, lsp, fetch, web_search, ast_grep
4
+ tools: read, grep, find, bash, lsp, web_search, ast_grep
5
5
  spawns: explore
6
6
  model: pi/slow
7
7
  thinking-level: high
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: plan
3
3
  description: Software architect for complex multi-file architectural decisions. NOT for simple tasks, single-file changes, or tasks completable in <5 tool calls.
4
- tools: read, grep, find, bash, lsp, fetch, web_search, ast_grep
4
+ tools: read, grep, find, bash, lsp, web_search, ast_grep
5
5
  spawns: explore
6
6
  model: pi/plan, pi/slow
7
7
  thinking-level: high
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: reviewer
3
3
  description: "Code review specialist for quality/security analysis"
4
- tools: read, grep, find, bash, lsp, fetch, web_search, ast_grep, report_finding
4
+ tools: read, grep, find, bash, lsp, web_search, ast_grep, report_finding
5
5
  spawns: explore
6
6
  model: pi/slow
7
7
  thinking-level: high
@@ -1,31 +1,40 @@
1
- Reads files from local filesystem, supported archives, or internal URLs.
1
+ Reads the content at the specified path or URL.
2
2
 
3
3
  <instruction>
4
- - Reads up to {{DEFAULT_LIMIT}} lines default
4
+ The `read` tool is a multi-purpose tool that can be used to inspect all kinds of files and URLs.
5
+ - You **MUST** parallelize reads when exploring related files
6
+
7
+ # Filesystem
8
+ - Reads up to {{DEFAULT_LIMIT}} lines by default
5
9
  - Use `offset` and `limit` for large files; max {{DEFAULT_MAX_LINES}} lines per call
6
10
  {{#if IS_HASHLINE_MODE}}
7
- - Filesystem output is CID prefixed: `LINE#ID:content`
11
+ - If reading from FS, result will be prefixed with anchors: `41#ZZ:def alpha():`
8
12
  {{else}}
9
- {{#if IS_LINE_NUMBER_MODE}}
10
- - Filesystem output is line-number-prefixed
11
- {{/if}}
13
+ {{#if IS_LINE_NUMBER_MODE}}
14
+ - If reading from FS, result will be prefixed with line numbers: `41:def alpha():`
15
+ {{/if}}
12
16
  {{/if}}
13
- - Supports images (PNG, JPG) and PDFs
14
- - For directories, returns formatted listing with modification times
15
- - Supports `.tar`, `.tar.gz`, `.tgz`, and `.zip` archives
17
+
18
+ # Inspection
19
+ When used with a PDF, Word, PowerPoint, Excel, RTF, EPUB, or Jupyter notebook file, the tool will return the extracted text.
20
+ It can also be used to inspect images.
21
+
22
+ # Directories & Archives
23
+ When used against a directory, or an archive root, the tool will return a list of directory entries within.
24
+ - Formats: `.tar`, `.tar.gz`, `.tgz`, and `.zip`.
16
25
  - Use `archive.ext:path/inside/archive` to read or list archive contents
17
- - Parallelize reads when exploring related files
18
- </instruction>
19
26
 
20
- <output>
21
- - Returns file content as text; images return visual content; PDFs return extracted text; archive roots behave like directories
22
- - Missing files: returns closest filename matches for correction
23
- </output>
27
+ # URLs
28
+ - Extract information from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, technical blogs, RSS/Atom feeds, JSON endpoints
29
+ - `raw: true` for untouched HTML or debugging
30
+ - `timeout` to override the default request timeout
31
+ </instruction>
24
32
 
25
33
  <critical>
26
34
  - You **MUST** use `read` instead of bash for ALL file reading: `cat`, `head`, `tail`, `less`, `more` are FORBIDDEN.
27
- - You **MUST** use `read(path="dir/")` instead of `ls dir/` for directory listings.
28
- - You **MUST** use `read(path="archive.zip:path/to/file")` instead of shelling out to `tar` or `unzip` for supported archive reads.
29
- - You **MUST** always include the `path` parameter NEVER call `read` with empty arguments `{}`.
35
+ - You **MUST** use `read` instead of `ls` for directory listings.
36
+ - You **MUST** use `read` instead of shelling out to `tar` or `unzip` for supported archive reads.
37
+ - You **MUST** always include the `path` parameter, NEVER call `read` with empty arguments `{}`.
30
38
  - When reading specific line ranges, use `offset` and `limit`: `read(path="file", offset=50, limit=100)` not `cat -n file | sed`.
39
+ - You **MAY** use `offset` and `limit` with URL reads; the tool will paginate the cached fetched output.
31
40
  </critical>
@@ -75,7 +75,7 @@ export class ArtifactManager {
75
75
  /**
76
76
  * Allocate a new artifact path and ID without writing content.
77
77
  *
78
- * @param toolType Tool name for file extension (e.g., "bash", "fetch")
78
+ * @param toolType Tool name for file extension (e.g., "bash", "read")
79
79
  */
80
80
  async allocatePath(toolType: string): Promise<{ id: string; path: string }> {
81
81
  await this.#ensureDir();
@@ -88,7 +88,7 @@ export class ArtifactManager {
88
88
  * Save content as an artifact and return the artifact ID.
89
89
  *
90
90
  * @param content Full content to save
91
- * @param toolType Tool name for file extension (e.g., "bash", "fetch")
91
+ * @param toolType Tool name for file extension (e.g., "bash", "read")
92
92
  * @returns Artifact ID (numeric string)
93
93
  */
94
94
  async save(content: string, toolType: string): Promise<string> {
package/src/task/index.ts CHANGED
@@ -496,7 +496,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
496
496
  }
497
497
 
498
498
  const planModeState = this.session.getPlanModeState?.();
499
- const planModeTools = ["read", "grep", "find", "ls", "lsp", "fetch", "web_search"];
499
+ const planModeTools = ["read", "grep", "find", "ls", "lsp", "web_search"];
500
500
  const effectiveAgent: typeof agent = planModeState?.enabled
501
501
  ? {
502
502
  ...agent,
@@ -1,16 +1,15 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
- import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
+ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
3
4
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
4
5
  import { htmlToMarkdown } from "@oh-my-pi/pi-natives";
5
6
  import { type Component, Text } from "@oh-my-pi/pi-tui";
6
7
  import { ptree, truncate } from "@oh-my-pi/pi-utils";
7
- import { type Static, Type } from "@sinclair/typebox";
8
8
  import { parseHTML } from "linkedom";
9
- import { renderPromptTemplate } from "../config/prompt-templates";
10
9
  import type { Settings } from "../config/settings";
11
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
11
  import { type Theme, theme } from "../modes/theme/theme";
13
- import fetchDescription from "../prompts/tools/fetch.md" with { type: "text" };
12
+ import type { ToolSession } from "../sdk";
14
13
  import { DEFAULT_MAX_BYTES, truncateHead } from "../session/streaming-output";
15
14
  import { renderStatusLine } from "../tui";
16
15
  import { CachedOutputBlock } from "../tui/output-block";
@@ -21,7 +20,6 @@ import { specialHandlers } from "../web/scrapers";
21
20
  import type { RenderResult } from "../web/scrapers/types";
22
21
  import { finalizeOutput, loadPage, MAX_OUTPUT_CHARS } from "../web/scrapers/types";
23
22
  import { convertWithMarkit, fetchBinary } from "../web/scrapers/utils";
24
- import type { ToolSession } from ".";
25
23
  import { applyListLimit } from "./list-limit";
26
24
  import { formatStyledArtifactReference, type OutputMeta } from "./output-meta";
27
25
  import { formatExpandHint, getDomain } from "./render-utils";
@@ -135,6 +133,10 @@ function normalizeUrl(url: string): string {
135
133
  return url;
136
134
  }
137
135
 
136
+ export function isReadableUrlPath(value: string): boolean {
137
+ return /^https?:\/\//i.test(value) || /^www\./i.test(value);
138
+ }
139
+
138
140
  /**
139
141
  * Normalize MIME type (lowercase, strip charset/params)
140
142
  */
@@ -1082,13 +1084,8 @@ async function renderUrl(
1082
1084
  // Tool Definition
1083
1085
  // =============================================================================
1084
1086
 
1085
- const fetchSchema = Type.Object({
1086
- url: Type.String({ description: "URL to fetch" }),
1087
- timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 20)" })),
1088
- raw: Type.Optional(Type.Boolean({ description: "Return raw HTML without transforms" })),
1089
- });
1090
-
1091
- export interface FetchToolDetails {
1087
+ export interface ReadUrlToolDetails {
1088
+ kind: "url";
1092
1089
  url: string;
1093
1090
  finalUrl: string;
1094
1091
  contentType: string;
@@ -1098,96 +1095,180 @@ export interface FetchToolDetails {
1098
1095
  meta?: OutputMeta;
1099
1096
  }
1100
1097
 
1101
- export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails> {
1102
- readonly name = "fetch";
1103
- readonly label = "Fetch";
1104
- readonly description: string;
1105
- readonly parameters = fetchSchema;
1106
- readonly strict = true;
1098
+ interface ReadUrlCacheEntry {
1099
+ artifactId?: string;
1100
+ details: ReadUrlToolDetails;
1101
+ image?: FetchImagePayload;
1102
+ output: string;
1103
+ }
1107
1104
 
1108
- constructor(private readonly session: ToolSession) {
1109
- this.description = renderPromptTemplate(fetchDescription);
1110
- }
1105
+ const readUrlCache = new Map<string, ReadUrlCacheEntry>();
1111
1106
 
1112
- async execute(
1113
- _toolCallId: string,
1114
- params: Static<typeof fetchSchema>,
1115
- signal?: AbortSignal,
1116
- _onUpdate?: AgentToolUpdateCallback<FetchToolDetails>,
1117
- _context?: AgentToolContext,
1118
- ): Promise<AgentToolResult<FetchToolDetails>> {
1119
- const { url, timeout: rawTimeout = 20, raw = false } = params;
1107
+ function getReadUrlCacheKey(session: ToolSession, requestedUrl: string, raw: boolean): string {
1108
+ const scope = session.getSessionFile() ?? session.cwd;
1109
+ return `${scope}::${raw ? "raw" : "rendered"}::${normalizeUrl(requestedUrl)}`;
1110
+ }
1120
1111
 
1121
- // Clamp to valid range (seconds)
1122
- const effectiveTimeout = clampTimeout("fetch", rawTimeout);
1112
+ async function readArtifactOutput(session: ToolSession, artifactId: string): Promise<string | null> {
1113
+ const artifactsDir = session.getArtifactsDir?.();
1114
+ if (!artifactsDir) return null;
1123
1115
 
1124
- if (signal?.aborted) {
1125
- throw new ToolAbortError();
1116
+ try {
1117
+ const files = await fs.readdir(artifactsDir);
1118
+ const match = files.find(file => file.startsWith(`${artifactId}.`));
1119
+ if (!match) return null;
1120
+ return await Bun.file(path.join(artifactsDir, match)).text();
1121
+ } catch {
1122
+ return null;
1123
+ }
1124
+ }
1125
+
1126
+ async function materializeReadUrlCacheEntry(
1127
+ session: ToolSession,
1128
+ entry: ReadUrlCacheEntry,
1129
+ ): Promise<ReadUrlCacheEntry | null> {
1130
+ if (entry.artifactId) {
1131
+ const artifactOutput = await readArtifactOutput(session, entry.artifactId);
1132
+ if (artifactOutput !== null) {
1133
+ return { ...entry, output: artifactOutput };
1126
1134
  }
1135
+ }
1127
1136
 
1128
- const result = await renderUrl(url, effectiveTimeout, raw, this.session.settings, signal);
1129
- const truncation = truncateHead(result.content, {
1130
- maxBytes: DEFAULT_MAX_BYTES,
1131
- maxLines: FETCH_DEFAULT_MAX_LINES,
1132
- });
1133
- const needsArtifact = truncation.truncated;
1134
- let artifactId: string | undefined;
1135
-
1136
- const buildOutput = (content: string): string => {
1137
- let output = "";
1138
- output += `URL: ${result.finalUrl}\n`;
1139
- output += `Content-Type: ${result.contentType}\n`;
1140
- output += `Method: ${result.method}\n`;
1141
- if (result.notes.length > 0) {
1142
- output += `Notes: ${result.notes.join("; ")}\n`;
1143
- }
1144
- output += `\n---\n\n`;
1145
- output += content;
1146
- return output;
1147
- };
1137
+ return entry.output.length > 0 ? entry : null;
1138
+ }
1148
1139
 
1149
- if (needsArtifact) {
1150
- const { path: artifactPath, id } = (await this.session.allocateOutputArtifact?.("fetch")) ?? {};
1151
- if (artifactPath) {
1152
- await Bun.write(artifactPath, buildOutput(result.content));
1153
- artifactId = id;
1154
- }
1155
- }
1140
+ async function persistReadUrlArtifact(session: ToolSession, output: string): Promise<string | undefined> {
1141
+ const { path: artifactPath, id } = (await session.allocateOutputArtifact?.("read")) ?? {};
1142
+ if (!artifactPath) return undefined;
1143
+ await Bun.write(artifactPath, output);
1144
+ return id;
1145
+ }
1156
1146
 
1157
- const output = buildOutput(needsArtifact ? truncation.content : result.content);
1147
+ async function ensureReadUrlCacheArtifact(session: ToolSession, entry: ReadUrlCacheEntry): Promise<ReadUrlCacheEntry> {
1148
+ if (entry.artifactId) return entry;
1149
+ const artifactId = await persistReadUrlArtifact(session, entry.output);
1150
+ return artifactId ? { ...entry, artifactId } : entry;
1151
+ }
1152
+
1153
+ function cacheReadUrlEntry(session: ToolSession, requestedUrl: string, raw: boolean, entry: ReadUrlCacheEntry): void {
1154
+ readUrlCache.set(getReadUrlCacheKey(session, requestedUrl, raw), entry);
1155
+ readUrlCache.set(getReadUrlCacheKey(session, entry.details.finalUrl, raw), entry);
1156
+ }
1158
1157
 
1159
- const details: FetchToolDetails = {
1158
+ async function buildReadUrlCacheEntry(
1159
+ session: ToolSession,
1160
+ params: { path: string; timeout?: number; raw?: boolean },
1161
+ signal?: AbortSignal,
1162
+ options?: { ensureArtifact?: boolean },
1163
+ ): Promise<ReadUrlCacheEntry> {
1164
+ const { path: url, timeout: rawTimeout = 20, raw = false } = params;
1165
+
1166
+ const effectiveTimeout = clampTimeout("fetch", rawTimeout);
1167
+
1168
+ if (signal?.aborted) {
1169
+ throw new ToolAbortError();
1170
+ }
1171
+
1172
+ const result = await renderUrl(url, effectiveTimeout, raw, session.settings, signal);
1173
+ const output = buildUrlReadOutput(result, result.content);
1174
+ const artifactId = options?.ensureArtifact ? await persistReadUrlArtifact(session, output) : undefined;
1175
+
1176
+ return {
1177
+ artifactId,
1178
+ details: {
1179
+ kind: "url",
1160
1180
  url: result.url,
1161
1181
  finalUrl: result.finalUrl,
1162
1182
  contentType: result.contentType,
1163
1183
  method: result.method,
1164
- truncated: Boolean(result.truncated || needsArtifact),
1184
+ truncated: Boolean(result.truncated),
1165
1185
  notes: result.notes,
1166
- };
1186
+ },
1187
+ image: result.image,
1188
+ output,
1189
+ };
1190
+ }
1167
1191
 
1168
- const contentBlocks: Array<TextContent | ImageContent> = [{ type: "text", text: output }];
1169
- if (result.image) {
1170
- contentBlocks.push({ type: "image", data: result.image.data, mimeType: result.image.mimeType });
1192
+ export async function loadReadUrlCacheEntry(
1193
+ session: ToolSession,
1194
+ params: { path: string; timeout?: number; raw?: boolean },
1195
+ signal?: AbortSignal,
1196
+ options?: { ensureArtifact?: boolean; preferCached?: boolean },
1197
+ ): Promise<ReadUrlCacheEntry> {
1198
+ const raw = params.raw ?? false;
1199
+ const cached = readUrlCache.get(getReadUrlCacheKey(session, params.path, raw));
1200
+ if (options?.preferCached && cached) {
1201
+ const prepared = options.ensureArtifact ? await ensureReadUrlCacheArtifact(session, cached) : cached;
1202
+ const materialized = await materializeReadUrlCacheEntry(session, prepared);
1203
+ if (materialized) {
1204
+ cacheReadUrlEntry(session, params.path, raw, materialized);
1205
+ return materialized;
1171
1206
  }
1207
+ }
1172
1208
 
1173
- const resultBuilder = toolResult(details).content(contentBlocks).sourceUrl(result.finalUrl);
1174
- if (needsArtifact) {
1175
- resultBuilder.truncation(truncation, { direction: "head", artifactId });
1176
- } else if (result.truncated) {
1177
- const outputLines = result.content.split("\n").length;
1178
- const outputBytes = Buffer.byteLength(result.content, "utf-8");
1179
- const totalBytes = Math.max(outputBytes + 1, MAX_OUTPUT_CHARS + 1);
1180
- const totalLines = outputLines + 1;
1181
- resultBuilder.truncationFromText(result.content, {
1182
- direction: "tail",
1183
- totalLines,
1184
- totalBytes,
1185
- maxBytes: MAX_OUTPUT_CHARS,
1186
- });
1187
- }
1209
+ const fresh = await buildReadUrlCacheEntry(session, params, signal, {
1210
+ ensureArtifact: options?.ensureArtifact,
1211
+ });
1212
+ cacheReadUrlEntry(session, params.path, raw, fresh);
1213
+ return fresh;
1214
+ }
1188
1215
 
1189
- return resultBuilder.done();
1216
+ function buildUrlReadOutput(result: FetchRenderResult, content: string): string {
1217
+ let output = "";
1218
+ output += `URL: ${result.finalUrl}\n`;
1219
+ output += `Content-Type: ${result.contentType}\n`;
1220
+ output += `Method: ${result.method}\n`;
1221
+ if (result.notes.length > 0) {
1222
+ output += `Notes: ${result.notes.join("; ")}\n`;
1190
1223
  }
1224
+ output += `\n---\n\n`;
1225
+ output += content;
1226
+ return output;
1227
+ }
1228
+
1229
+ export async function executeReadUrl(
1230
+ session: ToolSession,
1231
+ params: { path: string; timeout?: number; raw?: boolean },
1232
+ signal?: AbortSignal,
1233
+ ): Promise<AgentToolResult<ReadUrlToolDetails>> {
1234
+ let cacheEntry = await loadReadUrlCacheEntry(session, params, signal, { preferCached: true });
1235
+ const truncation = truncateHead(cacheEntry.output, {
1236
+ maxBytes: DEFAULT_MAX_BYTES,
1237
+ maxLines: FETCH_DEFAULT_MAX_LINES,
1238
+ });
1239
+ const needsArtifact = truncation.truncated;
1240
+ if (needsArtifact && !cacheEntry.artifactId) {
1241
+ cacheEntry = await ensureReadUrlCacheArtifact(session, cacheEntry);
1242
+ cacheReadUrlEntry(session, params.path, params.raw ?? false, cacheEntry);
1243
+ }
1244
+ const output = needsArtifact ? truncation.content : cacheEntry.output;
1245
+ const details: ReadUrlToolDetails = {
1246
+ ...cacheEntry.details,
1247
+ truncated: Boolean(cacheEntry.details.truncated || needsArtifact),
1248
+ };
1249
+
1250
+ const contentBlocks: Array<TextContent | ImageContent> = [{ type: "text", text: output }];
1251
+ if (cacheEntry.image) {
1252
+ contentBlocks.push({ type: "image", data: cacheEntry.image.data, mimeType: cacheEntry.image.mimeType });
1253
+ }
1254
+
1255
+ const resultBuilder = toolResult(details).content(contentBlocks).sourceUrl(details.finalUrl);
1256
+ if (needsArtifact) {
1257
+ resultBuilder.truncation(truncation, { direction: "head", artifactId: cacheEntry.artifactId });
1258
+ } else if (cacheEntry.details.truncated) {
1259
+ const outputLines = cacheEntry.output.split("\n").length;
1260
+ const outputBytes = Buffer.byteLength(cacheEntry.output, "utf-8");
1261
+ const totalBytes = Math.max(outputBytes + 1, MAX_OUTPUT_CHARS + 1);
1262
+ const totalLines = outputLines + 1;
1263
+ resultBuilder.truncationFromText(cacheEntry.output, {
1264
+ direction: "tail",
1265
+ totalLines,
1266
+ totalBytes,
1267
+ maxBytes: MAX_OUTPUT_CHARS,
1268
+ });
1269
+ }
1270
+
1271
+ return resultBuilder.done();
1191
1272
  }
1192
1273
 
1193
1274
  // =============================================================================
@@ -1199,26 +1280,26 @@ function countNonEmptyLines(text: string): number {
1199
1280
  return text.split("\n").filter(l => l.trim()).length;
1200
1281
  }
1201
1282
 
1202
- /** Render fetch call (URL preview) */
1203
- export function renderFetchCall(
1204
- args: { url?: string; timeout?: number; raw?: boolean },
1283
+ /** Render URL read call (URL preview) */
1284
+ export function renderReadUrlCall(
1285
+ args: { path?: string; url?: string; timeout?: number; raw?: boolean },
1205
1286
  _options: RenderResultOptions,
1206
1287
  uiTheme: Theme = theme,
1207
1288
  ): Component {
1208
- const url = args.url ?? "";
1289
+ const url = args.path ?? args.url ?? "";
1209
1290
  const domain = getDomain(url);
1210
1291
  const path = truncate(url.replace(/^https?:\/\/[^/]+/, ""), 50, "\u2026");
1211
1292
  const description = `${domain}${path ? ` ${path}` : ""}`.trim();
1212
1293
  const meta: string[] = [];
1213
1294
  if (args.raw) meta.push("raw");
1214
1295
  if (args.timeout !== undefined) meta.push(`timeout:${args.timeout}s`);
1215
- const text = renderStatusLine({ icon: "pending", title: "Fetch", description, meta }, uiTheme);
1296
+ const text = renderStatusLine({ icon: "pending", title: "Read", description, meta }, uiTheme);
1216
1297
  return new Text(text, 0, 0);
1217
1298
  }
1218
1299
 
1219
- /** Render fetch result with tree-based layout */
1220
- export function renderFetchResult(
1221
- result: { content: Array<{ type: string; text?: string }>; details?: FetchToolDetails },
1300
+ /** Render URL read result with tree-based layout */
1301
+ export function renderReadUrlResult(
1302
+ result: { content: Array<{ type: string; text?: string }>; details?: ReadUrlToolDetails },
1222
1303
  options: RenderResultOptions,
1223
1304
  uiTheme: Theme = theme,
1224
1305
  ): Component {
@@ -1238,7 +1319,7 @@ export function renderFetchResult(
1238
1319
  const header = renderStatusLine(
1239
1320
  {
1240
1321
  icon: truncated ? "warning" : "success",
1241
- title: "Fetch",
1322
+ title: "Read",
1242
1323
  description: `${domain}${path ? ` ${path}` : ""}`,
1243
1324
  },
1244
1325
  uiTheme,
@@ -1316,9 +1397,3 @@ export function renderFetchResult(
1316
1397
  },
1317
1398
  };
1318
1399
  }
1319
-
1320
- export const fetchToolRenderer = {
1321
- renderCall: renderFetchCall,
1322
- renderResult: renderFetchResult,
1323
- mergeCallAndResult: true,
1324
- };
@@ -26,7 +26,6 @@ import { CalculatorTool } from "./calculator";
26
26
  import { CancelJobTool } from "./cancel-job";
27
27
  import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
28
28
  import { ExitPlanModeTool } from "./exit-plan-mode";
29
- import { FetchTool } from "./fetch";
30
29
  import { FindTool } from "./find";
31
30
  import {
32
31
  GhIssueViewTool,
@@ -73,7 +72,6 @@ export * from "./calculator";
73
72
  export * from "./cancel-job";
74
73
  export * from "./checkpoint";
75
74
  export * from "./exit-plan-mode";
76
- export * from "./fetch";
77
75
  export * from "./find";
78
76
  export * from "./gemini-image";
79
77
  export * from "./gh";
@@ -219,7 +217,6 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
219
217
  cancel_job: CancelJobTool.createIf,
220
218
  await: AwaitTool.createIf,
221
219
  todo_write: s => new TodoWriteTool(s),
222
- fetch: s => new FetchTool(s),
223
220
  web_search: s => new SearchTool(s),
224
221
  search_tool_bm25: SearchToolBm25Tool.createIf,
225
222
  write: s => new WriteTool(s),
@@ -358,7 +355,6 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
358
355
  if (name === "render_mermaid") return session.settings.get("renderMermaid.enabled");
359
356
  if (name === "notebook") return session.settings.get("notebook.enabled");
360
357
  if (name === "inspect_image") return session.settings.get("inspect_image.enabled");
361
- if (name === "fetch") return session.settings.get("fetch.enabled");
362
358
  if (name === "web_search") return session.settings.get("web_search.enabled");
363
359
  if (name === "search_tool_bm25") return session.settings.get("mcp.discoveryMode");
364
360
  if (name === "lsp") return session.settings.get("lsp.enabled");
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import * as url from "node:url";
4
5
 
5
6
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
6
7
  const NARROW_NO_BREAK_SPACE = "\u202F";
@@ -82,6 +83,16 @@ function normalizeAtPrefix(filePath: string): string {
82
83
  return filePath;
83
84
  }
84
85
 
86
+ function stripFileUrl(filePath: string): string {
87
+ if (!filePath.toLowerCase().startsWith("file://")) return filePath;
88
+
89
+ try {
90
+ return url.fileURLToPath(filePath);
91
+ } catch {
92
+ return filePath;
93
+ }
94
+ }
95
+
85
96
  export function expandTilde(filePath: string, home?: string): string {
86
97
  const h = home ?? os.homedir();
87
98
  if (filePath === "~") return h;
@@ -95,7 +106,7 @@ export function expandTilde(filePath: string, home?: string): string {
95
106
  }
96
107
 
97
108
  export function expandPath(filePath: string): string {
98
- const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath));
109
+ const normalized = stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(filePath)));
99
110
  return expandTilde(normalized);
100
111
  }
101
112
 
package/src/tools/read.ts CHANGED
@@ -35,9 +35,17 @@ import {
35
35
  import { convertFileWithMarkit } from "../utils/markit";
36
36
  import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
37
37
  import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader";
38
+ import {
39
+ executeReadUrl,
40
+ isReadableUrlPath,
41
+ loadReadUrlCacheEntry,
42
+ type ReadUrlToolDetails,
43
+ renderReadUrlCall,
44
+ renderReadUrlResult,
45
+ } from "./fetch";
38
46
  import { applyListLimit } from "./list-limit";
39
47
  import { formatFullOutputReference, formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
40
- import { resolveReadPath } from "./path-utils";
48
+ import { expandPath, resolveReadPath } from "./path-utils";
41
49
  import { formatAge, formatBytes, shortenPath, wrapBrackets } from "./render-utils";
42
50
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
43
51
  import { toolResult } from "./tool-result";
@@ -345,18 +353,26 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
345
353
  }
346
354
 
347
355
  const readSchema = Type.Object({
348
- path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
349
- offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
350
- limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
356
+ path: Type.String({ description: "Path or URL to read" }),
357
+ offset: Type.Optional(Type.Number({ description: "Line number to start from (1-indexed)" })),
358
+ limit: Type.Optional(Type.Number({ description: "Maximum number of lines" })),
359
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 20)" })),
360
+ raw: Type.Optional(Type.Boolean({ description: "If set, returns raw content without transformations" })),
351
361
  });
352
362
 
353
363
  export type ReadToolInput = Static<typeof readSchema>;
354
364
 
355
365
  export interface ReadToolDetails {
366
+ kind?: "file" | "url";
356
367
  truncation?: TruncationResult;
357
368
  isDirectory?: boolean;
358
369
  resolvedPath?: string;
359
370
  suffixResolution?: { from: string; to: string };
371
+ url?: string;
372
+ finalUrl?: string;
373
+ contentType?: string;
374
+ method?: string;
375
+ notes?: string[];
360
376
  meta?: OutputMeta;
361
377
  }
362
378
 
@@ -451,6 +467,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
451
467
  options: {
452
468
  details?: ReadToolDetails;
453
469
  sourcePath?: string;
470
+ sourceUrl?: string;
454
471
  sourceInternal?: string;
455
472
  entityLabel: string;
456
473
  },
@@ -466,6 +483,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
466
483
  if (options.sourcePath) {
467
484
  resultBuilder.sourcePath(options.sourcePath);
468
485
  }
486
+ if (options.sourceUrl) {
487
+ resultBuilder.sourceUrl(options.sourceUrl);
488
+ }
469
489
  if (options.sourceInternal) {
470
490
  resultBuilder.sourceInternal(options.sourceInternal);
471
491
  }
@@ -651,9 +671,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
651
671
  _onUpdate?: AgentToolUpdateCallback<ReadToolDetails>,
652
672
  _toolContext?: AgentToolContext,
653
673
  ): Promise<AgentToolResult<ReadToolDetails>> {
654
- const { path: readPath, offset, limit } = params;
655
-
674
+ let { path: readPath, offset, limit, timeout, raw } = params;
656
675
  const displayMode = resolveFileDisplayMode(this.session);
676
+ if (readPath.startsWith("file://")) {
677
+ readPath = expandPath(readPath);
678
+ }
657
679
 
658
680
  // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://)
659
681
  const internalRouter = this.session.internalRouter;
@@ -661,6 +683,24 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
661
683
  return this.#handleInternalUrl(readPath, offset, limit);
662
684
  }
663
685
 
686
+ if (isReadableUrlPath(readPath)) {
687
+ if (!this.session.settings.get("fetch.enabled")) {
688
+ throw new ToolError("URL reads are disabled by settings.");
689
+ }
690
+ if (offset !== undefined || limit !== undefined) {
691
+ const cached = await loadReadUrlCacheEntry(this.session, { path: readPath, timeout, raw }, signal, {
692
+ ensureArtifact: true,
693
+ preferCached: true,
694
+ });
695
+ return this.#buildInMemoryTextResult(cached.output, offset, limit, {
696
+ details: { ...cached.details },
697
+ sourceUrl: cached.details.finalUrl,
698
+ entityLabel: "URL output",
699
+ });
700
+ }
701
+ return executeReadUrl(this.session, { path: readPath, timeout, raw }, signal);
702
+ }
703
+
664
704
  const archivePath = await this.#resolveArchiveReadPath(readPath, signal);
665
705
  if (archivePath) {
666
706
  return this.#readArchive(readPath, offset, limit, archivePath, signal);
@@ -1128,10 +1168,16 @@ interface ReadRenderArgs {
1128
1168
  file_path?: string;
1129
1169
  offset?: number;
1130
1170
  limit?: number;
1171
+ timeout?: number;
1172
+ raw?: boolean;
1131
1173
  }
1132
1174
 
1133
1175
  export const readToolRenderer = {
1134
1176
  renderCall(args: ReadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
1177
+ if (isReadableUrlPath(args.file_path || args.path || "")) {
1178
+ return renderReadUrlCall(args, _options, uiTheme);
1179
+ }
1180
+
1135
1181
  const rawPath = args.file_path || args.path || "";
1136
1182
  const filePath = shortenPath(rawPath);
1137
1183
  const offset = args.offset;
@@ -1154,6 +1200,15 @@ export const readToolRenderer = {
1154
1200
  uiTheme: Theme,
1155
1201
  args?: ReadRenderArgs,
1156
1202
  ): Component {
1203
+ const urlDetails = result.details as ReadUrlToolDetails | undefined;
1204
+ if (urlDetails?.kind === "url" || isReadableUrlPath(args?.file_path || args?.path || "")) {
1205
+ return renderReadUrlResult(
1206
+ result as { content: Array<{ type: string; text?: string }>; details?: ReadUrlToolDetails },
1207
+ _options,
1208
+ uiTheme,
1209
+ );
1210
+ }
1211
+
1157
1212
  const details = result.details;
1158
1213
  const contentText = result.content?.find(c => c.type === "text")?.text ?? "";
1159
1214
  const imageContent = result.content?.find(c => c.type === "image");
@@ -15,7 +15,6 @@ import { astEditToolRenderer } from "./ast-edit";
15
15
  import { astGrepToolRenderer } from "./ast-grep";
16
16
  import { bashToolRenderer } from "./bash";
17
17
  import { calculatorToolRenderer } from "./calculator";
18
- import { fetchToolRenderer } from "./fetch";
19
18
  import { findToolRenderer } from "./find";
20
19
  import { ghRunWatchToolRenderer } from "./gh-renderer";
21
20
  import { grepToolRenderer } from "./grep";
@@ -61,7 +60,6 @@ export const toolRenderers: Record<string, ToolRenderer> = {
61
60
  ssh: sshToolRenderer as ToolRenderer,
62
61
  task: taskToolRenderer as ToolRenderer,
63
62
  todo_write: todoWriteToolRenderer as ToolRenderer,
64
- fetch: fetchToolRenderer as ToolRenderer,
65
63
  gh_run_watch: ghRunWatchToolRenderer as ToolRenderer,
66
64
  web_search: webSearchToolRenderer as ToolRenderer,
67
65
  write: writeToolRenderer as ToolRenderer,
@@ -1,11 +0,0 @@
1
- Retrieves content from a URL and returns it in a clean, readable format.
2
-
3
- <instruction>
4
- - Extract information from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, technical blogs, RSS/Atom feeds, JSON endpoints
5
- - Read PDF or DOCX files hosted at a URL
6
- - Use `raw: true` for untouched HTML or debugging
7
- </instruction>
8
-
9
- <output>
10
- Returns processed, readable content. HTML transformed to remove boilerplate. PDF/DOCX converted to text. JSON returned formatted. With `raw: true`, returns untransformed HTML.
11
- </output>