thincoder 0.7.8 → 0.8.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 (96) hide show
  1. package/README.md +32 -13
  2. package/bin/thincoder.mjs +27 -346
  3. package/package.json +1 -1
  4. package/src/agent/dispatch.mjs +98 -0
  5. package/src/agent/helpers.mjs +185 -0
  6. package/src/agent/setup.mjs +117 -0
  7. package/src/agent-tools/goal.mjs +71 -0
  8. package/src/agent-tools/plan.mjs +31 -0
  9. package/src/agent-tools/recent-changes.mjs +23 -0
  10. package/src/agent-tools/skill.mjs +46 -0
  11. package/src/agent-tools/subagent.mjs +113 -0
  12. package/src/agent-tools/task.mjs +67 -0
  13. package/src/agent-tools/verify.mjs +198 -0
  14. package/src/agent-tools.mjs +12 -0
  15. package/src/agent.mjs +90 -1040
  16. package/src/cli/distill-command.mjs +85 -0
  17. package/src/cli/make-agent.mjs +85 -0
  18. package/src/cli/memory-command.mjs +63 -0
  19. package/src/cli/permission.mjs +41 -0
  20. package/src/cli/setup-wizard.mjs +70 -0
  21. package/src/config.mjs +5 -8
  22. package/src/context.mjs +10 -13
  23. package/src/distill.mjs +4 -3
  24. package/src/embedding.mjs +4 -2
  25. package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
  26. package/src/mcp/helpers.mjs +37 -0
  27. package/src/mcp/transport-http.mjs +176 -0
  28. package/src/mcp/transport-stdio.mjs +84 -0
  29. package/src/mcp/transport-ws.mjs +87 -0
  30. package/src/mcp.mjs +4 -428
  31. package/src/memory/code-index.mjs +211 -0
  32. package/src/memory/code-sync.mjs +306 -0
  33. package/src/memory/core.mjs +277 -0
  34. package/src/memory/docs.mjs +262 -0
  35. package/src/memory/schema.mjs +426 -0
  36. package/src/memory.mjs +12 -1403
  37. package/src/provider/core.mjs +239 -0
  38. package/src/provider/index.mjs +6 -0
  39. package/src/provider/rate.mjs +104 -0
  40. package/src/session.mjs +18 -5
  41. package/src/tools/bash.mjs +144 -0
  42. package/src/tools/file.mjs +205 -0
  43. package/src/tools/git.mjs +166 -0
  44. package/src/tools/glob.mjs +51 -0
  45. package/src/tools/grep.mjs +100 -0
  46. package/src/tools/index.mjs +22 -0
  47. package/src/tools/ls.mjs +36 -0
  48. package/src/tools/patch.mjs +226 -0
  49. package/src/tools/repomap-parse.mjs +168 -0
  50. package/src/tools/shared.mjs +257 -0
  51. package/src/tools/system.mjs +336 -0
  52. package/src/tools/web.mjs +121 -0
  53. package/src/tools.mjs +2 -1194
  54. package/src/tui/agent-turn.mjs +254 -0
  55. package/src/tui/ansi.mjs +32 -0
  56. package/src/tui/clipboard.mjs +48 -0
  57. package/src/tui/cmd-auto.mjs +21 -0
  58. package/src/tui/cmd-clear.mjs +26 -0
  59. package/src/tui/cmd-config.mjs +72 -0
  60. package/src/tui/cmd-exit.mjs +5 -0
  61. package/src/tui/cmd-extract.mjs +5 -0
  62. package/src/tui/cmd-goal.mjs +47 -0
  63. package/src/tui/cmd-help.mjs +25 -0
  64. package/src/tui/cmd-init.mjs +91 -0
  65. package/src/tui/cmd-mcp.mjs +146 -0
  66. package/src/tui/cmd-model.mjs +7 -0
  67. package/src/tui/cmd-new.mjs +18 -0
  68. package/src/tui/cmd-plan.mjs +21 -0
  69. package/src/tui/cmd-reindex.mjs +44 -0
  70. package/src/tui/cmd-restore.mjs +39 -0
  71. package/src/tui/cmd-session.mjs +42 -0
  72. package/src/tui/cmd-skills.mjs +17 -0
  73. package/src/tui/cmd-think.mjs +56 -0
  74. package/src/tui/config-helpers.mjs +34 -0
  75. package/src/tui/distill-cmd.mjs +45 -0
  76. package/src/tui/index.mjs +330 -0
  77. package/src/tui/interaction.mjs +79 -0
  78. package/src/tui/key-handler.mjs +267 -0
  79. package/src/tui/layout.mjs +115 -0
  80. package/src/tui/pickers.mjs +279 -0
  81. package/src/tui/render-frame.mjs +304 -0
  82. package/src/tui/render.mjs +205 -0
  83. package/src/tui/slash-commands.mjs +138 -0
  84. package/src/tui/startup.mjs +113 -0
  85. package/src/tui/wizard.mjs +168 -0
  86. package/src/tui-render.mjs +4 -0
  87. package/src/tui.mjs +3 -2566
  88. package/src/provider.mjs +0 -383
  89. /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
  90. /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
  91. /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
  92. /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
  93. /package/src/{main-overlay.md → prompts/main.md} +0 -0
  94. /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
  95. /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
  96. /package/src/{repomap.mjs → tools/repomap.mjs} +0 -0
@@ -0,0 +1,336 @@
1
+ import {
2
+ DESC,
3
+ sanitizeOutput,
4
+ truncate,
5
+ makeDecoder,
6
+ BASH_TIMEOUT_MS,
7
+ IGNORED_DIRS,
8
+ resolveInCwd,
9
+ shellSegments,
10
+ isDestructiveGitSegment,
11
+ isDestructiveCommand,
12
+ hasFileRedirection,
13
+ insideGitRepo,
14
+ globToRegex
15
+ } from "./shared.mjs";
16
+ import { spawn } from "node:child_process";
17
+ import { execFileSync } from "node:child_process";
18
+ import { readFile } from "node:fs/promises";
19
+ import { readdir } from "node:fs/promises";
20
+ import { stat, lstat } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+
23
+ /** 子进程环境变量白名单:只透传安全变量,隔离 API key 等敏感信息 */
24
+ const SAFE_ENV_KEYS = new Set([
25
+ "PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
26
+ "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "SHELL",
27
+ "ComSpec", "PATHEXT", "SystemRoot", "windir",
28
+ "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "OS",
29
+ "PYTHONIOENCODING", "GIT_EDITOR", "EDITOR", "VISUAL",
30
+ "GIT_PAGER", "PAGER", "TERM",
31
+ ])
32
+
33
+ export const bashTool = {
34
+ name: "bash",
35
+ description: DESC("bash"),
36
+ parameters: {
37
+ type: "object",
38
+ properties: {
39
+ command: { type: "string", description: "Shell command to execute" },
40
+ timeout: { type: "number", description: `Timeout in ms (default ${BASH_TIMEOUT_MS})` },
41
+ },
42
+ required: ["command"],
43
+ },
44
+ readonly: false,
45
+ async execute(args, ctx) {
46
+ // 安全预检:禁止 shell 重定向(> >> <)——应改用 write/edit/insert_after 工具
47
+ if (hasFileRedirection(args.command)) {
48
+ throw new Error("File redirection via bash is not allowed — use the write/edit/insert_after tools instead")
49
+ }
50
+ // 安全预检:破坏性非 git 命令(rm -rf / DROP TABLE 等)直接拒绝
51
+ if (shellSegments(args.command).some(isDestructiveCommand)) {
52
+ throw new Error("Destructive command blocked — use specific tools or confirm with the user first")
53
+ }
54
+ // 安全预检:销毁性 git 操作先检查未提交改动,有则拒绝——防一键清掉几小时工作
55
+ if (shellSegments(args.command).some(isDestructiveGitSegment)) {
56
+ if (!insideGitRepo(ctx.cwd)) {
57
+ throw new Error(`Refusing destructive git command: not a git repository: ${ctx.cwd}`)
58
+ }
59
+ const status = execFileSync("git", ["status", "--porcelain"], {
60
+ cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
61
+ }).trim()
62
+ if (status) {
63
+ throw new Error(
64
+ `Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n` +
65
+ `(If uncommitted work was already lost, the checkpoint tool can restore the auto-snapshot: action=list, then action=rewind.)\n\n${status}`
66
+ )
67
+ }
68
+ }
69
+
70
+ return new Promise((resolve) => {
71
+ // detached: 让子进程成为进程组组长,超时/中断时才能整树杀掉(POSIX 用负 pid 组杀,
72
+ // Windows 用 taskkill /T)——只 kill 壳进程会把孙进程(如 npm test)留在后台继续跑
73
+ const killTree = () => {
74
+ if (process.platform === "win32") {
75
+ try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
76
+ } else {
77
+ try { process.kill(-child.pid, "SIGKILL") } catch {}
78
+ try { child.kill("SIGKILL") } catch {} // 组杀失败时兜底杀本体
79
+ }
80
+ }
81
+ // Windows 中文系统默认代码页是 GBK (CP936),cmd.exe 重定向写文件时用 ANSI 代码页,
82
+ // chcp 65001 也改不了重定向的编码。bash 工具写含 CJK 的文件会产生 GBK——
83
+ // 提示词层已禁止用 bash 写文件(用 write/edit 工具替代),这里设 PYTHONIOENCODING
84
+ // 覆盖 Python 脚本的 stdout 编码(Python 是唯一可能正确响应环境变量的子进程)
85
+ const winCmd = process.platform === "win32"
86
+ const child = spawn(args.command, {
87
+ cwd: ctx.cwd,
88
+ shell: true,
89
+ windowsHide: true,
90
+ detached: process.platform !== "win32",
91
+ stdio: ["ignore", "pipe", "pipe"],
92
+ env: {
93
+ ...Object.fromEntries(
94
+ Object.entries(process.env).filter(([k]) => SAFE_ENV_KEYS.has(k))
95
+ ),
96
+ GIT_EDITOR: "true",
97
+ EDITOR: "true",
98
+ VISUAL: "true",
99
+ GIT_PAGER: "cat",
100
+ PAGER: "cat",
101
+ TERM: "dumb",
102
+ ...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
103
+ },
104
+ })
105
+ // stdout / stderr 各自独立解码(同进程通常同编码,但分开收集更干净,
106
+ // 且允许模型按 stderr 快速定位错误)
107
+ const outDecoder = makeDecoder()
108
+ const errDecoder = makeDecoder()
109
+ let outBuf = ""
110
+ let errBuf = ""
111
+ let truncatedNote = ""
112
+
113
+ const onStdout = (d) => {
114
+ const s = sanitizeOutput(outDecoder(d))
115
+ if (s) {
116
+ ctx.onOutput?.(s)
117
+ if (outBuf.length < 2_000_000) outBuf += s
118
+ else if (!truncatedNote) truncatedNote = "\n[... output exceeded 2MB, remainder discarded]"
119
+ }
120
+ }
121
+ const onStderr = (d) => {
122
+ const s = sanitizeOutput(errDecoder(d)) // 始终解码,防 pending 无限累积
123
+ if (errBuf.length < 2_000_000) errBuf += s
124
+ }
125
+
126
+ child.stdout.on("data", onStdout)
127
+ child.stderr.on("data", onStderr)
128
+
129
+ const timer = setTimeout(killTree, args.timeout ?? BASH_TIMEOUT_MS)
130
+ if (ctx.signal) {
131
+ ctx.signal.addEventListener("abort", killTree, { once: true })
132
+ }
133
+ child.on("error", (error) => {
134
+ clearTimeout(timer)
135
+ resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
136
+ })
137
+ child.on("close", (code, signal) => {
138
+ clearTimeout(timer)
139
+ // 冲刷解码器尾部
140
+ outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
141
+ errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
142
+ const status = signal
143
+ ? `killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"}`
144
+ : `exit code ${code}`
145
+ const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
146
+ if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
147
+ parts.push(`(${status})`)
148
+ resolve(truncate(parts.join("\n\n") + truncatedNote))
149
+ })
150
+ })
151
+ },
152
+ }
153
+
154
+ // ---------------------------------------------------------------- glob
155
+
156
+ export const globTool = {
157
+ name: "glob",
158
+ description: DESC("glob"),
159
+ parameters: {
160
+ type: "object",
161
+ properties: {
162
+ pattern: { type: "string", description: "Glob pattern" },
163
+ path: { type: "string", description: "Directory to search in (default cwd)" },
164
+ },
165
+ required: ["pattern"],
166
+ },
167
+ readonly: true,
168
+ async execute(args, ctx) {
169
+ const base = resolveInCwd(ctx, args.path ?? ".")
170
+ const regex = globToRegex(args.pattern)
171
+ const results = []
172
+ for await (const relPath of walkFiles(base)) {
173
+ if (regex.test(relPath)) {
174
+ results.push(relPath)
175
+ if (results.length >= 1000) break
176
+ }
177
+ }
178
+ if (results.length === 0) return "(no matches)"
179
+ return truncate(results.sort().join("\n"))
180
+ },
181
+ }
182
+
183
+ /** 递归遍历文件,产出相对路径(跳过 IGNORED_DIRS) */
184
+ async function* walkFiles(dir, rel = "") {
185
+ let entries
186
+ try {
187
+ entries = await readdir(dir, { withFileTypes: true })
188
+ } catch {
189
+ return
190
+ }
191
+ for (const e of entries) {
192
+ if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
193
+ const relPath = rel ? `${rel}/${e.name}` : e.name
194
+ if (e.isDirectory()) {
195
+ yield* walkFiles(join(dir, e.name), relPath)
196
+ } else {
197
+ yield relPath
198
+ }
199
+ }
200
+ }
201
+
202
+ /** glob 转正则:**\/ 匹配零或多级目录,** 跨目录,* 段内,? 段内单字符 */
203
+
204
+ // ---------------------------------------------------------------- grep
205
+
206
+ export const grepTool = {
207
+ name: "grep",
208
+ description: DESC("grep"),
209
+ parameters: {
210
+ type: "object",
211
+ properties: {
212
+ pattern: { type: "string", description: "Regular expression" },
213
+ path: { type: "string", description: "Directory or file to search (default cwd)" },
214
+ glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
215
+ before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
216
+ after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
217
+ },
218
+ required: ["pattern"],
219
+ },
220
+ readonly: true,
221
+ async execute(args, ctx) {
222
+ const base = resolveInCwd(ctx, args.path ?? ".")
223
+ const regex = new RegExp(args.pattern)
224
+ const fileFilter = args.glob ? globToRegex(args.glob) : null
225
+ const before = Math.max(0, Math.floor(args.before ?? 0))
226
+ const after = Math.max(0, Math.floor(args.after ?? 0))
227
+ const wantCtx = before > 0 || after > 0
228
+ const hits = [] // { file, line(1-based), text }
229
+ const fileLines = new Map() // file -> string[](仅 wantCtx 时缓存)
230
+
231
+ async function search(file) {
232
+ let content
233
+ try {
234
+ // 大文件保护:超 10MB 跳过,防 OOM
235
+ const fst = await stat(file)
236
+ if (fst.size > 10_000_000) return
237
+ content = await readFile(file, "utf8")
238
+ } catch {
239
+ return // 不可读文件跳过;二进制会被按 utf8 读入并照常搜索(可能产生乱码匹配)
240
+ }
241
+ const lines = content.split("\n")
242
+ if (wantCtx) fileLines.set(file, lines)
243
+ for (let i = 0; i < lines.length; i++) {
244
+ if (regex.test(lines[i])) {
245
+ hits.push({ file, line: i + 1, text: lines[i] })
246
+ if (hits.length >= 200) return
247
+ }
248
+ }
249
+ }
250
+
251
+ async function walk(target) {
252
+ if (hits.length >= 200) return
253
+ // 用 lstat 不跟随符号链接——防 ./evil → /etc 时 grep 读遍 /etc
254
+ let s
255
+ try { s = await lstat(target) } catch { return }
256
+ if (!s.isDirectory()) {
257
+ if (!fileFilter || fileFilter.test(target.split(/[\\/]/).pop())) await search(target)
258
+ return
259
+ }
260
+ let entries
261
+ try {
262
+ entries = await readdir(target, { withFileTypes: true })
263
+ } catch {
264
+ return
265
+ }
266
+ for (const e of entries) {
267
+ if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
268
+ await walk(join(target, e.name))
269
+ }
270
+ }
271
+
272
+ await walk(base)
273
+ if (hits.length === 0) return "(no matches)"
274
+
275
+ // 无上下文:保持原 path:line: content 格式
276
+ if (!wantCtx) {
277
+ return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
278
+ }
279
+
280
+ // 带上下文:匹配行用 ':',上下文行用 '-'(同 ripgrep);同文件相邻区间去重合并
281
+ const fileMatched = new Map() // file -> Set<line>
282
+ for (const h of hits) {
283
+ if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
284
+ fileMatched.get(h.file).add(h.line)
285
+ }
286
+ const out = []
287
+ for (const [file, matchedLines] of fileMatched) {
288
+ const lines = fileLines.get(file) ?? []
289
+ const lineSet = new Set()
290
+ for (const ml of matchedLines) {
291
+ for (let l = Math.max(1, ml - before); l <= Math.min(lines.length, ml + after); l++) lineSet.add(l)
292
+ }
293
+ for (const l of [...lineSet].sort((a, b) => a - b)) {
294
+ const sep = matchedLines.has(l) ? ":" : "-"
295
+ out.push(`${file}${sep}${l}${sep} ${lines[l - 1]}`)
296
+ }
297
+ }
298
+ return truncate(out.join("\n"))
299
+ },
300
+ }
301
+
302
+ // ---------------------------------------------------------------- websearch
303
+
304
+ export const lsTool = {
305
+ name: "ls",
306
+ description: DESC("ls"),
307
+ parameters: {
308
+ type: "object",
309
+ properties: {
310
+ path: { type: "string", description: "Directory path (default cwd)" },
311
+ },
312
+ },
313
+ readonly: true,
314
+ async execute(args, ctx) {
315
+ const abs = resolveInCwd(ctx, args.path ?? ".")
316
+ const entries = await readdir(abs, { withFileTypes: true })
317
+ const rows = await Promise.all(
318
+ entries.slice(0, 500).map(async (e) => {
319
+ const s = await stat(join(abs, e.name)).catch(() => null)
320
+ const isDir = e.isDirectory()
321
+ return {
322
+ dir: isDir,
323
+ name: e.name + (isDir ? "/" : ""),
324
+ size: s?.size ?? 0,
325
+ mtime: s ? s.mtime.toISOString().slice(0, 16).replace("T", " ") : "?",
326
+ }
327
+ }),
328
+ )
329
+ rows.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
330
+ if (rows.length === 0) return "(empty directory)"
331
+ const out = rows.map((r) => `${r.dir ? "d" : "-"} ${r.name.padEnd(40)} ${String(r.size).padStart(10)} ${r.mtime}`)
332
+ return truncate(out.join("\n"))
333
+ },
334
+ }
335
+
336
+ // ---------------------------------------------------------------- fetch
@@ -0,0 +1,121 @@
1
+ import {
2
+ DESC,
3
+ truncate,
4
+ readBodyText,
5
+ stripTags,
6
+ htmlToText
7
+ } from "./shared.mjs";
8
+ import { join } from "node:path";
9
+
10
+ export const websearchTool = {
11
+ name: "websearch",
12
+ description: DESC("websearch"),
13
+ parameters: {
14
+ type: "object",
15
+ properties: {
16
+ query: { type: "string", description: "Search query" },
17
+ limit: { type: "number", description: "Max results (default 8)" },
18
+ },
19
+ required: ["query"],
20
+ },
21
+ readonly: true,
22
+ async execute(args, ctx) {
23
+ const limit = args.limit ?? 8
24
+ const url = `https://www.bing.com/search?q=${encodeURIComponent(args.query)}`
25
+ let html
26
+ try {
27
+ const response = await fetch(url, {
28
+ headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
29
+ signal: ctx?.signal
30
+ ? AbortSignal.any([ctx.signal, AbortSignal.timeout(15_000)])
31
+ : AbortSignal.timeout(15_000),
32
+ })
33
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
34
+ html = await readBodyText(response)
35
+ } catch (error) {
36
+ throw new Error(`websearch request failed: ${error.cause?.code ?? error.message}`)
37
+ }
38
+
39
+ // 结果块 <li class="b_algo">:<h2><a href>标题</a></h2> + <p>摘要</p>
40
+ const blocks = html.split('<li class="b_algo"').slice(1)
41
+ const results = []
42
+ for (const block of blocks) {
43
+ const link = block.match(/<h2[^>]*><a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/)
44
+ if (!link) continue
45
+ const snippet = block.match(/<p[^>]*>([\s\S]*?)<\/p>/)
46
+ results.push({
47
+ href: link[1],
48
+ title: stripTags(link[2]),
49
+ snippet: snippet ? stripTags(snippet[1]) : "",
50
+ })
51
+ if (results.length >= limit) break
52
+ }
53
+ if (results.length === 0) return "(no results)"
54
+ return truncate(
55
+ results.map((r, i) => `${i + 1}. ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"),
56
+ )
57
+ },
58
+ }
59
+
60
+
61
+ // ---------------------------------------------------------------- ls
62
+
63
+ /** SSRF 防护:拒绝内网私有段/metadata 端点(localhost 放行——用户本机,测试也依赖) */
64
+ function isPrivateUrl(urlStr) {
65
+ let u
66
+ try { u = new URL(urlStr) } catch { return true }
67
+ const host = u.hostname.toLowerCase()
68
+ // localhost / 127.x 放行(用户本机开发服务器、测试 mock server)
69
+ if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".localhost")) return false
70
+ if (host === "127.0.0.1" || host.startsWith("127.")) return false
71
+ // 云 metadata 端点
72
+ if (host === "169.254.169.254" || host === "metadata.google.internal") return true
73
+ // IPv4 私有段
74
+ const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
75
+ if (m) {
76
+ const [a, b] = [Number(m[1]), Number(m[2])]
77
+ if (a === 10) return true
78
+ if (a === 172 && b >= 16 && b <= 31) return true
79
+ if (a === 192 && b === 168) return true
80
+ if (a === 169 && b === 254) return true
81
+ if (a === 0) return true
82
+ }
83
+ // IPv6 环回 / 链路本地 / 唯一本地地址
84
+ if (host === "::1" || host === "fe80::1" || host.startsWith("fc") || host.startsWith("fd")) return true
85
+ return false
86
+ }
87
+
88
+ export const fetchTool = {
89
+ name: "fetch",
90
+ description: DESC("fetch"),
91
+ parameters: {
92
+ type: "object",
93
+ properties: {
94
+ url: { type: "string", description: "http/https URL" },
95
+ },
96
+ required: ["url"],
97
+ },
98
+ readonly: true,
99
+ async execute(args, ctx) {
100
+ if (!/^https?:\/\//.test(args.url)) throw new Error("url must start with http:// or https://")
101
+ if (isPrivateUrl(args.url)) throw new Error("fetch blocked: internal/private/metadata addresses are not allowed")
102
+ let response
103
+ try {
104
+ response = await fetch(args.url, {
105
+ headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
106
+ redirect: "follow",
107
+ signal: ctx?.signal
108
+ ? AbortSignal.any([ctx.signal, AbortSignal.timeout(20_000)])
109
+ : AbortSignal.timeout(20_000),
110
+ })
111
+ } catch (error) {
112
+ throw new Error(`fetch failed: ${error.cause?.code ?? error.message}`)
113
+ }
114
+ if (!response.ok) throw new Error(`fetch failed: HTTP ${response.status}`)
115
+
116
+ const contentType = response.headers.get("content-type") ?? ""
117
+ const body = await readBodyText(response)
118
+ if (!contentType.includes("text/html")) return truncate(body)
119
+ return truncate(htmlToText(body))
120
+ },
121
+ }