novacode 0.2.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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Git tools for executing safe repository operations programmatically.
3
+ */
4
+ import type { Tool, ToolResult } from "../types.ts"
5
+ import { textPart } from "../util.ts"
6
+
7
+ export function gitTool(cwd: string): Tool {
8
+ return {
9
+ def: {
10
+ name: "git",
11
+ description:
12
+ "Execute safe, non-interactive git commands (status, diff, log, add, commit) in the repository.",
13
+ parameters: {
14
+ type: "object",
15
+ properties: {
16
+ action: {
17
+ type: "string",
18
+ enum: ["status", "diff", "log", "add", "commit"],
19
+ description: "The git action to execute",
20
+ },
21
+ args: {
22
+ type: "array",
23
+ description: "Optional additional arguments or file paths for the git action",
24
+ items: { type: "string" },
25
+ },
26
+ },
27
+ required: ["action"],
28
+ },
29
+ },
30
+ async execute(args, signal): Promise<ToolResult> {
31
+ const action = args.action as string
32
+ const extraArgs = (args.args as string[]) || []
33
+
34
+ const allowed = new Set(["status", "diff", "log", "add", "commit"])
35
+ if (!allowed.has(action)) {
36
+ return {
37
+ content: [textPart(`Error: Git action '${action}' is not supported.`)],
38
+ isError: true,
39
+ }
40
+ }
41
+
42
+ try {
43
+ const cmd = ["git", action, ...extraArgs]
44
+ const proc = Bun.spawn(cmd, {
45
+ cwd,
46
+ stdout: "pipe",
47
+ stderr: "pipe",
48
+ env: { ...process.env, PAGER: "cat" },
49
+ })
50
+
51
+ const onAbort = () => proc.kill()
52
+ signal?.addEventListener("abort", onAbort, { once: true })
53
+
54
+ const exitCode = await proc.exited
55
+ signal?.removeEventListener("abort", onAbort)
56
+
57
+ const stdout = await new Response(proc.stdout).text()
58
+ const stderr = await new Response(proc.stderr).text()
59
+
60
+ // Prevent context window blowout by truncating very large outputs
61
+ const MAX = 50_000
62
+ let out = ""
63
+ if (stdout) out += stdout.slice(0, MAX)
64
+ if (stderr) {
65
+ if (out) out += "\n"
66
+ out += stderr.slice(0, MAX - out.length)
67
+ }
68
+ if (out.length >= MAX) out += "\n…truncated"
69
+
70
+ return {
71
+ content: [textPart(out || "(no output)")],
72
+ isError: exitCode !== 0,
73
+ }
74
+ } catch (e) {
75
+ return {
76
+ content: [textPart(`Error running git: ${(e as Error).message}`)],
77
+ isError: true,
78
+ }
79
+ }
80
+ },
81
+ }
82
+ }
@@ -0,0 +1,33 @@
1
+ import type { Tool } from "../types.ts"
2
+ import { editTool, readTool, writeTool } from "./fs.ts"
3
+ import { gitTool } from "./git.ts"
4
+ import { globTool, grepTool, lsTool, treeTool } from "./search.ts"
5
+ import { bashTool } from "./shell.ts"
6
+ import { webFetchTool, webSearchTool } from "./web.ts"
7
+
8
+ export function getAllTools(cwd: string): Tool[] {
9
+ return [
10
+ readTool(cwd),
11
+ writeTool(cwd),
12
+ editTool(cwd),
13
+ bashTool(cwd),
14
+ globTool(cwd),
15
+ grepTool(cwd),
16
+ lsTool(cwd),
17
+ treeTool(cwd),
18
+ gitTool(cwd),
19
+ webSearchTool(),
20
+ webFetchTool(),
21
+ ]
22
+ }
23
+
24
+ export function getDefaultTools(cwd: string): Tool[] {
25
+ return [
26
+ readTool(cwd),
27
+ writeTool(cwd),
28
+ editTool(cwd),
29
+ bashTool(cwd),
30
+ webSearchTool(),
31
+ webFetchTool(),
32
+ ]
33
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Search tools for finding files and content.
3
+ * Uses 'rg' (ripgrep) if available, falling back to a pure JS implementation.
4
+ */
5
+ import { readdir } from "node:fs/promises"
6
+ import { relative, resolve } from "node:path"
7
+ import { glob } from "glob"
8
+ import type { Tool, ToolResult } from "../types.ts"
9
+
10
+ import { textPart } from "../util.ts"
11
+
12
+ /**
13
+ * Tool for finding files by glob pattern.
14
+ */
15
+ export function globTool(cwd: string): Tool {
16
+ return {
17
+ def: {
18
+ name: "glob",
19
+ description: "Find files by glob pattern (e.g. **/*.ts, src/**/*.test.ts).",
20
+ parameters: {
21
+ type: "object",
22
+ properties: {
23
+ pattern: { type: "string", description: "Glob pattern (e.g. **/*.ts)" },
24
+ path: { type: "string", description: "Directory to search in (default .)" },
25
+ nocase: { type: "boolean", description: "Case-insensitive search (default false)" },
26
+ },
27
+ required: ["pattern"],
28
+ },
29
+ },
30
+ async execute(args): Promise<ToolResult> {
31
+ try {
32
+ const rawPath = (args.path as string) || "."
33
+ const dir = resolve(cwd, rawPath)
34
+ if (dir !== cwd && !dir.startsWith(`${cwd}/`)) {
35
+ throw new Error(`Path outside project: ${rawPath}`)
36
+ }
37
+
38
+ const pattern = args.pattern as string
39
+ const nocase = !!args.nocase
40
+ const files = await glob(pattern, { cwd: dir, nocase })
41
+ const sliced = files.slice(0, 500)
42
+ const relSearchPath = relative(cwd, dir)
43
+ const prefix = relSearchPath ? `${relSearchPath}/` : ""
44
+ const relFiles = sliced.map((f) => prefix + f)
45
+ const out = relFiles.length > 0 ? relFiles.join("\n") : "No files found"
46
+ return { content: [textPart(out)], isError: false }
47
+ } catch (e) {
48
+ return {
49
+ content: [textPart(`Error: ${(e as Error).message}`)],
50
+ isError: true,
51
+ }
52
+ }
53
+ },
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Tool for searching file contents using regex.
59
+ */
60
+ export function grepTool(cwd: string): Tool {
61
+ return {
62
+ def: {
63
+ name: "grep",
64
+ description:
65
+ "Search file contents with a regex pattern. Returns matching lines with file paths and line numbers.",
66
+ parameters: {
67
+ type: "object",
68
+ properties: {
69
+ pattern: { type: "string", description: "Regex pattern to search for" },
70
+ path: { type: "string", description: "Directory or file to search in (default .)" },
71
+ glob: { type: "string", description: "File filter glob (e.g. *.ts)" },
72
+ },
73
+ required: ["pattern"],
74
+ },
75
+ },
76
+ async execute(args, signal): Promise<ToolResult> {
77
+ try {
78
+ const rawPath = (args.path as string) || "."
79
+ const dir = resolve(cwd, rawPath)
80
+ if (dir !== cwd && !dir.startsWith(`${cwd}/`)) {
81
+ throw new Error(`Path outside project: ${rawPath}`)
82
+ }
83
+
84
+ const pattern = args.pattern as string
85
+ const globFilter = args.glob as string | undefined
86
+ const relSearchPath = relative(cwd, dir) || "."
87
+
88
+ // rg is 10-100x faster than our JS fallback, but isn't always installed
89
+ try {
90
+ const cmd = ["rg", "--line-number", "--max-count", "200"]
91
+ if (globFilter) cmd.push(`--glob=${globFilter}`)
92
+ cmd.push("--", pattern, relSearchPath)
93
+
94
+ const proc = Bun.spawn(cmd, {
95
+ cwd,
96
+ stdout: "pipe",
97
+ stderr: "pipe",
98
+ })
99
+ signal?.addEventListener("abort", () => proc.kill(), { once: true })
100
+ const exitCode = await proc.exited
101
+ signal?.removeEventListener("abort", () => proc.kill())
102
+
103
+ if (exitCode === 0) {
104
+ const out = await new Response(proc.stdout).text()
105
+ const lines = out.split("\n").slice(0, 200).join("\n")
106
+ return { content: [textPart(lines || "No matches")], isError: false }
107
+ }
108
+ } catch {
109
+ // rg not available, fall through
110
+ }
111
+
112
+ // Pure JS fallback when rg is not available
113
+ const files = await glob(globFilter || "**/*", { cwd: dir })
114
+ const prefix = relSearchPath === "." ? "" : `${relSearchPath}/`
115
+ const re = new RegExp(pattern, "i")
116
+ const matches: string[] = []
117
+ for (const file of files.slice(0, 500)) {
118
+ if (signal?.aborted) break
119
+ try {
120
+ const content = await Bun.file(resolve(dir, file)).text()
121
+ const lines = content.split("\n")
122
+ for (let i = 0; i < lines.length && matches.length < 200; i++) {
123
+ const line = lines[i]
124
+ if (line && re.test(line)) matches.push(`${prefix}${file}:${i + 1}:${line}`)
125
+ }
126
+ } catch {
127
+ // Skip binary/unreadable files silently
128
+ }
129
+ }
130
+ return {
131
+ content: [textPart(matches.join("\n") || "No matches")],
132
+ isError: false,
133
+ }
134
+ } catch (e) {
135
+ return {
136
+ content: [textPart(`Error: ${(e as Error).message}`)],
137
+ isError: true,
138
+ }
139
+ }
140
+ },
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Tool for listing directory entries.
146
+ */
147
+ export function lsTool(cwd: string): Tool {
148
+ return {
149
+ def: {
150
+ name: "ls",
151
+ description: "List directory contents.",
152
+ parameters: {
153
+ type: "object",
154
+ properties: {
155
+ path: { type: "string", description: "Directory to list (default .)" },
156
+ },
157
+ required: [],
158
+ },
159
+ },
160
+ async execute(args): Promise<ToolResult> {
161
+ try {
162
+ const dir = resolve(cwd, (args.path as string) || ".")
163
+ const entries = await readdir(dir, { withFileTypes: true })
164
+ const lines = entries.map((e) => {
165
+ const suffix = e.isDirectory() ? "/" : e.isSymbolicLink() ? "@" : ""
166
+ return `${e.name}${suffix}`
167
+ })
168
+ return { content: [textPart(lines.join("\n") || "(empty)")], isError: false }
169
+ } catch (e) {
170
+ return {
171
+ content: [textPart(`Error: ${(e as Error).message}`)],
172
+ isError: true,
173
+ }
174
+ }
175
+ },
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Tool for visualizing a truncated directory tree.
181
+ */
182
+ export function treeTool(cwd: string): Tool {
183
+ return {
184
+ def: {
185
+ name: "tree",
186
+ description:
187
+ "Print a visual directory tree structure, ignoring common ignored folders like node_modules and .git.",
188
+ parameters: {
189
+ type: "object",
190
+ properties: {
191
+ path: { type: "string", description: "Directory to start tree from (default .)" },
192
+ depth: { type: "number", description: "Maximum depth to traverse (default 3)" },
193
+ },
194
+ required: [],
195
+ },
196
+ },
197
+ async execute(args): Promise<ToolResult> {
198
+ try {
199
+ const startDir = resolve(cwd, (args.path as string) || ".")
200
+ const maxDepth = Number(args.depth ?? 3) || 3
201
+
202
+ const ignoreList = new Set([
203
+ ".git",
204
+ "node_modules",
205
+ "dist",
206
+ "build",
207
+ ".svelte-kit",
208
+ ".next",
209
+ "out",
210
+ ".scannerwork",
211
+ "coverage",
212
+ ])
213
+
214
+ async function walk(dir: string, currentDepth: number, prefix: string): Promise<string> {
215
+ if (currentDepth > maxDepth) return ""
216
+ let result = ""
217
+
218
+ const entries = await readdir(dir, { withFileTypes: true })
219
+ const sorted = entries
220
+ .filter((e) => !ignoreList.has(e.name))
221
+ .sort((a, b) => {
222
+ if (a.isDirectory() && !b.isDirectory()) return -1
223
+ if (!a.isDirectory() && b.isDirectory()) return 1
224
+ return a.name.localeCompare(b.name)
225
+ })
226
+
227
+ for (let i = 0; i < sorted.length; i++) {
228
+ const entry = sorted[i]!
229
+ const isLast = i === sorted.length - 1
230
+ const connector = isLast ? "└── " : "├── "
231
+ const childPrefix = prefix + (isLast ? " " : "│ ")
232
+
233
+ result += `${prefix}${connector}${entry.name}${entry.isDirectory() ? "/" : ""}\n`
234
+
235
+ if (entry.isDirectory()) {
236
+ result += await walk(resolve(dir, entry.name), currentDepth + 1, childPrefix)
237
+ }
238
+ }
239
+ return result
240
+ }
241
+
242
+ const treeText = await walk(startDir, 1, "")
243
+ return { content: [textPart(treeText || "(empty)")], isError: false }
244
+ } catch (e) {
245
+ return {
246
+ content: [textPart(`Error: ${(e as Error).message}`)],
247
+ isError: true,
248
+ }
249
+ }
250
+ },
251
+ }
252
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Tool for executing shell commands within the project root.
3
+ * Supports timeouts and output truncation to protect the context window.
4
+ */
5
+ import type { Tool, ToolResult } from "../types.ts"
6
+
7
+ import { textPart } from "../util.ts"
8
+
9
+ export function bashTool(cwd: string): Tool {
10
+ return {
11
+ def: {
12
+ name: "bash",
13
+ description:
14
+ "Execute a shell command. Returns stdout and stderr. Timeout after N seconds (default 120).",
15
+ parameters: {
16
+ type: "object",
17
+ properties: {
18
+ command: { type: "string", description: "Shell command to run" },
19
+ timeout: { type: "number", description: "Timeout in seconds (default 120)" },
20
+ },
21
+ required: ["command"],
22
+ },
23
+ },
24
+ async execute(args, signal): Promise<ToolResult> {
25
+ const command = args.command as string
26
+ const timeoutMs = (Number(args.timeout) || 120) * 1000
27
+
28
+ try {
29
+ const proc = Bun.spawn(["sh", "-c", command], {
30
+ cwd,
31
+ stdout: "pipe",
32
+ stderr: "pipe",
33
+ })
34
+
35
+ // Start reading pipes immediately so they don't block the process
36
+ const stdoutPromise = new Response(proc.stdout).text()
37
+ const stderrPromise = new Response(proc.stderr).text()
38
+
39
+ // Track whether we killed it vs normal exit, so the output reflects the cause
40
+ let killed = false
41
+ const timer = setTimeout(() => {
42
+ killed = true
43
+ proc.kill(9) // SIGKILL to be more aggressive against orphans
44
+ }, timeoutMs)
45
+
46
+ const onAbort = () => {
47
+ killed = true
48
+ proc.kill(9)
49
+ }
50
+ signal?.addEventListener("abort", onAbort, { once: true })
51
+
52
+ const exitCode = await proc.exited
53
+ clearTimeout(timer)
54
+ signal?.removeEventListener("abort", onAbort)
55
+
56
+ // After exitCode, pipes should close. We give them a tiny grace period
57
+ // to avoid hanging on orphans.
58
+ const stdout = await Promise.race([
59
+ stdoutPromise,
60
+ new Promise<string>((r) => setTimeout(() => r(""), 500)),
61
+ ])
62
+ const stderr = await Promise.race([
63
+ stderrPromise,
64
+ new Promise<string>((r) => setTimeout(() => r(""), 500)),
65
+ ])
66
+
67
+ // Prevent context-window blowout from noisy commands
68
+ const MAX = 50_000
69
+ let out = ""
70
+ if (stdout) out += stdout.slice(0, MAX)
71
+ if (stderr) {
72
+ if (out) out += "\n"
73
+ out += stderr.slice(0, MAX - out.length)
74
+ }
75
+ if (out.length >= MAX) out += `\n…truncated`
76
+
77
+ if (killed) out += `\n[timeout after ${timeoutMs / 1000}s]`
78
+ out += `\n[exit ${exitCode}]`
79
+
80
+ return { content: [textPart(out)], isError: exitCode !== 0 || killed }
81
+ } catch (e) {
82
+ return {
83
+ content: [textPart(`Error: ${(e as Error).message}`)],
84
+ isError: true,
85
+ }
86
+ }
87
+ },
88
+ }
89
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Web tools for searching and fetching internet content.
3
+ * Uses DuckDuckGo HTML for search (no API key needed) and Bun's built-in
4
+ * fetch for reading URLs.
5
+ */
6
+ import type { Tool, ToolResult } from "../types.ts"
7
+ import { textPart } from "../util.ts"
8
+
9
+ const MAX_CONTENT = 50_000
10
+
11
+ // Minimal HTML → plaintext: strips tags, decodes entities, collapses whitespace
12
+ function htmlToText(html: string): string {
13
+ let text = html
14
+ // Remove HTML comments
15
+ .replace(/<!--[\s\S]*?-->/g, "")
16
+ // Remove script and style blocks entirely
17
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
18
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
19
+ // Keep link hrefs visible (supports single, double, or no quotes)
20
+ .replace(/<a[^>]*href=["']?([^"'>\s]*)["']?[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)")
21
+ // Block-level tags → newlines
22
+ .replace(/<\/?(p|div|br|h[1-6]|li|tr|blockquote|pre|hr)[^>]*>/gi, "\n")
23
+ // Remove remaining tags
24
+ .replace(/<[^>]+>/g, "")
25
+ // Decode common HTML entities (both named and numeric, single/double quotes)
26
+ .replace(/&amp;/g, "&")
27
+ .replace(/&lt;/g, "<")
28
+ .replace(/&gt;/g, ">")
29
+ .replace(/&quot;/g, '"')
30
+ .replace(/&#34;/g, '"')
31
+ .replace(/&#x22;/g, '"')
32
+ .replace(/&#39;/g, "'")
33
+ .replace(/&#x27;/g, "'")
34
+ .replace(/&apos;/g, "'")
35
+ .replace(/&ldquo;/g, '"')
36
+ .replace(/&rdquo;/g, '"')
37
+ .replace(/&lsquo;/g, "'")
38
+ .replace(/&rsquo;/g, "'")
39
+ .replace(/&nbsp;/g, " ")
40
+ // Collapse whitespace but keep paragraph breaks
41
+ .replace(/[ \t]+/g, " ")
42
+ .replace(/\n{3,}/g, "\n\n")
43
+ .trim()
44
+
45
+ if (text.length > MAX_CONTENT) {
46
+ text = `${text.slice(0, MAX_CONTENT)}\n…truncated`
47
+ }
48
+ return text
49
+ }
50
+
51
+ export function webSearchTool(): Tool {
52
+ return {
53
+ def: {
54
+ name: "web_search",
55
+ description:
56
+ "Search the web using DuckDuckGo. Returns up to 10 results with titles, URLs, and snippets. Use this when you need information from the internet.",
57
+ parameters: {
58
+ type: "object",
59
+ properties: {
60
+ query: { type: "string", description: "Search query" },
61
+ },
62
+ required: ["query"],
63
+ },
64
+ },
65
+ async execute(args, signal): Promise<ToolResult> {
66
+ const query = args.query as string
67
+ if (!query.trim()) {
68
+ return { content: [textPart("Error: empty search query")], isError: true }
69
+ }
70
+
71
+ try {
72
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`
73
+ const resp = await fetch(url, {
74
+ signal: signal ?? undefined,
75
+ headers: {
76
+ "User-Agent":
77
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
78
+ },
79
+ })
80
+
81
+ if (!resp.ok) {
82
+ return {
83
+ content: [textPart(`Search failed: HTTP ${resp.status}`)],
84
+ isError: true,
85
+ }
86
+ }
87
+
88
+ const html = await resp.text()
89
+
90
+ // Split HTML into blocks by result__body to isolate each search result safely
91
+ const blocks: string[] = []
92
+ const containerRegex = /<div[^>]*class="[^"]*result__body[^"]*"[^>]*>/gi
93
+ const indices: number[] = []
94
+ for (const match of html.matchAll(containerRegex)) {
95
+ if (match.index !== undefined) {
96
+ indices.push(match.index)
97
+ }
98
+ }
99
+
100
+ if (indices.length > 0) {
101
+ for (let i = 0; i < indices.length; i++) {
102
+ const start = indices[i]!
103
+ const end = indices[i + 1] ?? html.length
104
+ blocks.push(html.slice(start, end))
105
+ }
106
+ }
107
+
108
+ const results: string[] = []
109
+ const titleRegex = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/i
110
+ const snippetRegex = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/i
111
+
112
+ for (const block of blocks) {
113
+ if (results.length >= 10) break
114
+
115
+ const titleMatch = titleRegex.exec(block)
116
+ if (!titleMatch) continue
117
+
118
+ const rawUrl = titleMatch[1]!
119
+ const title = htmlToText(titleMatch[2]!)
120
+
121
+ const snippetMatch = snippetRegex.exec(block)
122
+ const snippet = snippetMatch ? htmlToText(snippetMatch[1]!) : ""
123
+
124
+ // DuckDuckGo wraps URLs through a redirect; extract the actual URL
125
+ let cleanUrl = rawUrl
126
+ try {
127
+ // Prepend protocol/host if DuckDuckGo returns a relative path or protocol-relative URL
128
+ const urlToParse = rawUrl.startsWith("//")
129
+ ? `https:${rawUrl}`
130
+ : rawUrl.startsWith("/")
131
+ ? `https://duckduckgo.com${rawUrl}`
132
+ : rawUrl
133
+ const param = new URL(urlToParse).searchParams.get("uddg")
134
+ if (param) cleanUrl = param
135
+ } catch {
136
+ // Not a redirect URL, use as-is
137
+ }
138
+
139
+ results.push(`## ${title}\n${cleanUrl}\n${snippet}`)
140
+ }
141
+
142
+ if (results.length === 0) {
143
+ return { content: [textPart("No results found.")], isError: false }
144
+ }
145
+
146
+ return {
147
+ content: [textPart(results.join("\n\n"))],
148
+ isError: false,
149
+ }
150
+ } catch (e) {
151
+ const msg = (e as Error).message
152
+ if (msg.includes("abort")) {
153
+ return { content: [textPart("Search aborted.")], isError: true }
154
+ }
155
+ return {
156
+ content: [textPart(`Search error: ${msg}`)],
157
+ isError: true,
158
+ }
159
+ }
160
+ },
161
+ }
162
+ }
163
+
164
+ export function webFetchTool(): Tool {
165
+ return {
166
+ def: {
167
+ name: "web_fetch",
168
+ description:
169
+ "Fetch and read the content of a web page. Returns the page text with HTML tags stripped. Useful for reading documentation, articles, or API references.",
170
+ parameters: {
171
+ type: "object",
172
+ properties: {
173
+ url: { type: "string", description: "URL to fetch" },
174
+ },
175
+ required: ["url"],
176
+ },
177
+ },
178
+ async execute(args, signal): Promise<ToolResult> {
179
+ const url = args.url as string
180
+ if (!url.trim()) {
181
+ return { content: [textPart("Error: empty URL")], isError: true }
182
+ }
183
+
184
+ try {
185
+ new URL(url)
186
+ } catch {
187
+ return { content: [textPart(`Error: invalid URL: ${url}`)], isError: true }
188
+ }
189
+
190
+ try {
191
+ const resp = await fetch(url, {
192
+ signal: signal ?? undefined,
193
+ headers: {
194
+ "User-Agent":
195
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
196
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
197
+ },
198
+ redirect: "follow",
199
+ })
200
+
201
+ if (!resp.ok) {
202
+ return {
203
+ content: [textPart(`Fetch failed: HTTP ${resp.status} ${resp.statusText}`)],
204
+ isError: true,
205
+ }
206
+ }
207
+
208
+ const contentType = (resp.headers.get("content-type") ?? "").toLowerCase()
209
+ const body = await resp.text()
210
+
211
+ // Sniff HTML: check content-type or if body has HTML markup signature
212
+ const isHtml =
213
+ contentType.includes("text/html") ||
214
+ contentType.includes("application/xhtml+xml") ||
215
+ body.trim().toLowerCase().startsWith("<!doctype html") ||
216
+ body.trim().toLowerCase().startsWith("<html")
217
+
218
+ if (isHtml) {
219
+ const text = htmlToText(body)
220
+ return { content: [textPart(text)], isError: false }
221
+ }
222
+
223
+ // For plain text, JSON, etc. return as-is (truncated if needed)
224
+ const truncated =
225
+ body.length > MAX_CONTENT ? `${body.slice(0, MAX_CONTENT)}\n…truncated` : body
226
+ return { content: [textPart(truncated)], isError: false }
227
+ } catch (e) {
228
+ const msg = (e as Error).message
229
+ if (msg.includes("abort")) {
230
+ return { content: [textPart("Fetch aborted.")], isError: true }
231
+ }
232
+ return {
233
+ content: [textPart(`Fetch error: ${msg}`)],
234
+ isError: true,
235
+ }
236
+ }
237
+ },
238
+ }
239
+ }