thincoder 0.7.8 → 0.8.1
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/README.md +32 -13
- package/bin/thincoder.js +4 -0
- package/bin/thincoder.mjs +27 -346
- package/package.json +2 -2
- package/src/agent/dispatch.mjs +98 -0
- package/src/agent/helpers.mjs +185 -0
- package/src/agent/setup.mjs +117 -0
- package/src/agent-tools/goal.mjs +71 -0
- package/src/agent-tools/plan.mjs +31 -0
- package/src/agent-tools/recent-changes.mjs +23 -0
- package/src/agent-tools/skill.mjs +46 -0
- package/src/agent-tools/subagent.mjs +113 -0
- package/src/agent-tools/task.mjs +67 -0
- package/src/agent-tools/verify.mjs +198 -0
- package/src/agent-tools.mjs +12 -0
- package/src/agent.mjs +90 -1040
- package/src/cli/distill-command.mjs +85 -0
- package/src/cli/make-agent.mjs +85 -0
- package/src/cli/memory-command.mjs +63 -0
- package/src/cli/permission.mjs +41 -0
- package/src/cli/setup-wizard.mjs +70 -0
- package/src/config.mjs +5 -8
- package/src/context.mjs +10 -13
- package/src/distill.mjs +4 -3
- package/src/embedding.mjs +4 -2
- package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
- package/src/mcp/helpers.mjs +37 -0
- package/src/mcp/transport-http.mjs +176 -0
- package/src/mcp/transport-stdio.mjs +84 -0
- package/src/mcp/transport-ws.mjs +87 -0
- package/src/mcp.mjs +4 -428
- package/src/memory/code-index.mjs +211 -0
- package/src/memory/code-sync.mjs +306 -0
- package/src/memory/core.mjs +277 -0
- package/src/memory/docs.mjs +262 -0
- package/src/memory/schema.mjs +426 -0
- package/src/memory.mjs +12 -1403
- package/src/provider/core.mjs +239 -0
- package/src/provider/index.mjs +6 -0
- package/src/provider/rate.mjs +104 -0
- package/src/session.mjs +18 -5
- package/src/tools/bash.mjs +144 -0
- package/src/tools/file.mjs +205 -0
- package/src/tools/git.mjs +166 -0
- package/src/tools/glob.mjs +51 -0
- package/src/tools/grep.mjs +100 -0
- package/src/tools/index.mjs +22 -0
- package/src/tools/ls.mjs +36 -0
- package/src/tools/patch.mjs +226 -0
- package/src/tools/repomap-parse.mjs +168 -0
- package/src/tools/shared.mjs +257 -0
- package/src/tools/system.mjs +336 -0
- package/src/tools/web.mjs +121 -0
- package/src/tools.mjs +2 -1194
- package/src/tui/agent-turn.mjs +254 -0
- package/src/tui/ansi.mjs +32 -0
- package/src/tui/clipboard.mjs +48 -0
- package/src/tui/cmd-auto.mjs +21 -0
- package/src/tui/cmd-clear.mjs +26 -0
- package/src/tui/cmd-config.mjs +72 -0
- package/src/tui/cmd-exit.mjs +5 -0
- package/src/tui/cmd-extract.mjs +5 -0
- package/src/tui/cmd-goal.mjs +47 -0
- package/src/tui/cmd-help.mjs +25 -0
- package/src/tui/cmd-init.mjs +91 -0
- package/src/tui/cmd-mcp.mjs +146 -0
- package/src/tui/cmd-model.mjs +7 -0
- package/src/tui/cmd-new.mjs +18 -0
- package/src/tui/cmd-plan.mjs +21 -0
- package/src/tui/cmd-reindex.mjs +44 -0
- package/src/tui/cmd-restore.mjs +39 -0
- package/src/tui/cmd-session.mjs +42 -0
- package/src/tui/cmd-skills.mjs +17 -0
- package/src/tui/cmd-think.mjs +56 -0
- package/src/tui/config-helpers.mjs +34 -0
- package/src/tui/distill-cmd.mjs +45 -0
- package/src/tui/index.mjs +330 -0
- package/src/tui/interaction.mjs +79 -0
- package/src/tui/key-handler.mjs +267 -0
- package/src/tui/layout.mjs +115 -0
- package/src/tui/pickers.mjs +279 -0
- package/src/tui/render-frame.mjs +304 -0
- package/src/tui/render.mjs +205 -0
- package/src/tui/slash-commands.mjs +138 -0
- package/src/tui/startup.mjs +113 -0
- package/src/tui/wizard.mjs +168 -0
- package/src/tui-render.mjs +4 -0
- package/src/tui.mjs +3 -2566
- package/src/provider.mjs +0 -383
- /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
- /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
- /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
- /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
- /package/src/{main-overlay.md → prompts/main.md} +0 -0
- /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
- /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
- /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
|
+
}
|