thincoder 0.1.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/LICENSE +21 -0
- package/README.md +162 -0
- package/bin/thincoder.mjs +383 -0
- package/package.json +29 -0
- package/src/agent.mjs +351 -0
- package/src/checkpoint.mjs +135 -0
- package/src/config.mjs +106 -0
- package/src/context.mjs +76 -0
- package/src/distill.mjs +117 -0
- package/src/embedding.mjs +107 -0
- package/src/gitmem.mjs +87 -0
- package/src/markdown.mjs +99 -0
- package/src/memory.mjs +495 -0
- package/src/provider.mjs +153 -0
- package/src/session.mjs +53 -0
- package/src/tools.mjs +513 -0
- package/src/tui.mjs +912 -0
package/src/session.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session.mjs — 会话持久化
|
|
3
|
+
* 每个项目(按 cwd 哈希)保存最近一个会话到 ~/.thincoder/sessions/。
|
|
4
|
+
* 退出时存、启动时恢复;agent.history 本来就是可 JSON 序列化的。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto"
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"
|
|
9
|
+
import { join, dirname } from "node:path"
|
|
10
|
+
import { configDir } from "./config.mjs"
|
|
11
|
+
|
|
12
|
+
export function sessionPath(cwd) {
|
|
13
|
+
const hash = createHash("sha1").update(cwd).digest("hex").slice(0, 12)
|
|
14
|
+
return join(configDir, "sessions", `${hash}.json`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 保存会话(同步:退出清理路径也能用) */
|
|
18
|
+
export function saveSession(agent) {
|
|
19
|
+
const data = {
|
|
20
|
+
version: 1,
|
|
21
|
+
cwd: agent.cwd,
|
|
22
|
+
model: agent.provider.model,
|
|
23
|
+
updatedAt: Date.now(),
|
|
24
|
+
history: agent.history,
|
|
25
|
+
tasks: agent.tasks ?? [],
|
|
26
|
+
}
|
|
27
|
+
const p = sessionPath(agent.cwd)
|
|
28
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
29
|
+
writeFileSync(p, JSON.stringify(data), "utf8")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 恢复会话。没有或损坏返回 null */
|
|
33
|
+
export function loadSession(cwd) {
|
|
34
|
+
try {
|
|
35
|
+
const p = sessionPath(cwd)
|
|
36
|
+
if (!existsSync(p)) return null
|
|
37
|
+
const data = JSON.parse(readFileSync(p, "utf8"))
|
|
38
|
+
if (data?.version !== 1 || !Array.isArray(data.history)) return null
|
|
39
|
+
return data
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 清空会话(/new) */
|
|
46
|
+
export function clearSession(cwd) {
|
|
47
|
+
try {
|
|
48
|
+
const p = sessionPath(cwd)
|
|
49
|
+
if (existsSync(p)) writeFileSync(p, JSON.stringify({ version: 1, cwd, history: [], tasks: [] }), "utf8")
|
|
50
|
+
} catch {
|
|
51
|
+
// 清不掉就算了,下次保存会覆盖
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/tools.mjs
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools.mjs — 内置工具集
|
|
3
|
+
* read / write / edit / bash / glob / grep,零依赖实现。
|
|
4
|
+
* readonly 标记供 agent 调度:只读工具可并行,有副作用工具串行。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from "node:child_process"
|
|
8
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"
|
|
9
|
+
import { dirname, join, resolve } from "node:path"
|
|
10
|
+
|
|
11
|
+
const MAX_READ_LINES = 2000
|
|
12
|
+
const MAX_OUTPUT_CHARS = 50_000
|
|
13
|
+
const BASH_TIMEOUT_MS = 120_000
|
|
14
|
+
const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
|
|
15
|
+
|
|
16
|
+
/** 每个工具:{ name, description, parameters, readonly, execute(args, ctx) }(定义见文件末尾导出) */
|
|
17
|
+
|
|
18
|
+
/** 转成 OpenAI tools 参数格式 */
|
|
19
|
+
export function toOpenAISchema(tool) {
|
|
20
|
+
return {
|
|
21
|
+
type: "function",
|
|
22
|
+
function: {
|
|
23
|
+
name: tool.name,
|
|
24
|
+
description: tool.description,
|
|
25
|
+
parameters: tool.parameters,
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 剥离 ANSI 转义序列(vim/less/颜色码会冲花 TUI 渲染),并把 \r 进度条改写转成换行 */
|
|
31
|
+
function sanitizeOutput(s) {
|
|
32
|
+
return s
|
|
33
|
+
.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
|
|
34
|
+
.replace(/\r\n/g, "\n")
|
|
35
|
+
.replace(/\r/g, "\n")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function truncate(text, max = MAX_OUTPUT_CHARS) {
|
|
39
|
+
if (text.length <= max) return text
|
|
40
|
+
return text.slice(0, max) + `\n... (truncated, ${text.length - max} chars omitted)`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveInCwd(ctx, p) {
|
|
44
|
+
return resolve(ctx.cwd, p)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------- read
|
|
48
|
+
|
|
49
|
+
const readTool = {
|
|
50
|
+
name: "read",
|
|
51
|
+
description:
|
|
52
|
+
"Read a text file. Returns numbered lines. Use offset/limit to page large files.",
|
|
53
|
+
parameters: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
path: { type: "string", description: "File path (relative to cwd or absolute)" },
|
|
57
|
+
offset: { type: "number", description: "1-based line number to start from" },
|
|
58
|
+
limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
|
|
59
|
+
},
|
|
60
|
+
required: ["path"],
|
|
61
|
+
},
|
|
62
|
+
readonly: true,
|
|
63
|
+
async execute(args, ctx) {
|
|
64
|
+
const abs = resolveInCwd(ctx, args.path)
|
|
65
|
+
const content = await readFile(abs, "utf8")
|
|
66
|
+
const lines = content.split("\n")
|
|
67
|
+
const offset = Math.max(1, args.offset ?? 1)
|
|
68
|
+
const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
|
69
|
+
const slice = lines.slice(offset - 1, offset - 1 + limit)
|
|
70
|
+
const numbered = slice.map((l, i) => `${offset + i}\t${l}`).join("\n")
|
|
71
|
+
const suffix = offset - 1 + limit < lines.length ? `\n... (${lines.length} lines total, use offset to continue)` : ""
|
|
72
|
+
return truncate(numbered + suffix)
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------- write
|
|
77
|
+
|
|
78
|
+
const writeTool = {
|
|
79
|
+
name: "write",
|
|
80
|
+
description: "Write content to a file. Creates parent directories; overwrites existing file.",
|
|
81
|
+
parameters: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
path: { type: "string", description: "File path (relative to cwd or absolute)" },
|
|
85
|
+
content: { type: "string", description: "Full content to write" },
|
|
86
|
+
},
|
|
87
|
+
required: ["path", "content"],
|
|
88
|
+
},
|
|
89
|
+
readonly: false,
|
|
90
|
+
async execute(args, ctx) {
|
|
91
|
+
const abs = resolveInCwd(ctx, args.path)
|
|
92
|
+
await mkdir(dirname(abs), { recursive: true })
|
|
93
|
+
await writeFile(abs, args.content, "utf8")
|
|
94
|
+
return `Wrote ${args.content.length} chars to ${abs}`
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------- edit
|
|
99
|
+
|
|
100
|
+
const editTool = {
|
|
101
|
+
name: "edit",
|
|
102
|
+
description:
|
|
103
|
+
"Edit a file by exact string replacement. old_string must match exactly once unless replace_all is set.",
|
|
104
|
+
parameters: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
path: { type: "string", description: "File path" },
|
|
108
|
+
old_string: { type: "string", description: "Exact text to replace" },
|
|
109
|
+
new_string: { type: "string", description: "Replacement text" },
|
|
110
|
+
replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
|
|
111
|
+
},
|
|
112
|
+
required: ["path", "old_string", "new_string"],
|
|
113
|
+
},
|
|
114
|
+
readonly: false,
|
|
115
|
+
async execute(args, ctx) {
|
|
116
|
+
const abs = resolveInCwd(ctx, args.path)
|
|
117
|
+
const content = await readFile(abs, "utf8")
|
|
118
|
+
const occurrences = content.split(args.old_string).length - 1
|
|
119
|
+
if (occurrences === 0) {
|
|
120
|
+
throw new Error(`old_string not found in ${abs}`)
|
|
121
|
+
}
|
|
122
|
+
if (occurrences > 1 && !args.replace_all) {
|
|
123
|
+
throw new Error(`old_string matches ${occurrences} times in ${abs}; provide more context or set replace_all`)
|
|
124
|
+
}
|
|
125
|
+
const updated = args.replace_all
|
|
126
|
+
? content.split(args.old_string).join(args.new_string)
|
|
127
|
+
: content.replace(args.old_string, args.new_string)
|
|
128
|
+
await writeFile(abs, updated, "utf8")
|
|
129
|
+
return `Edited ${abs}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)`
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------- bash
|
|
134
|
+
|
|
135
|
+
const bashTool = {
|
|
136
|
+
name: "bash",
|
|
137
|
+
description:
|
|
138
|
+
"Execute a shell command and return stdout+stderr. Use for running commands, builds, tests.",
|
|
139
|
+
parameters: {
|
|
140
|
+
type: "object",
|
|
141
|
+
properties: {
|
|
142
|
+
command: { type: "string", description: "Shell command to execute" },
|
|
143
|
+
timeout: { type: "number", description: `Timeout in ms (default ${BASH_TIMEOUT_MS})` },
|
|
144
|
+
},
|
|
145
|
+
required: ["command"],
|
|
146
|
+
},
|
|
147
|
+
readonly: false,
|
|
148
|
+
async execute(args, ctx) {
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
const child = spawn(args.command, {
|
|
151
|
+
cwd: ctx.cwd,
|
|
152
|
+
shell: true,
|
|
153
|
+
windowsHide: true,
|
|
154
|
+
// 无 TTY 环境:stdin 置空(vim/less 这类交互程序立刻吃到 EOF 退出,而不是干等),
|
|
155
|
+
// 并通过环境变量缴械编辑器/分页器/花哨输出
|
|
156
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
157
|
+
env: {
|
|
158
|
+
...process.env,
|
|
159
|
+
GIT_EDITOR: "true",
|
|
160
|
+
EDITOR: "true",
|
|
161
|
+
VISUAL: "true",
|
|
162
|
+
GIT_PAGER: "cat",
|
|
163
|
+
PAGER: "cat",
|
|
164
|
+
TERM: "dumb",
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
// 编码嗅探:cmd 自带消息是 GBK,git/node 等程序是 UTF-8,平台判断不了。
|
|
168
|
+
// 策略:纯 ASCII 段两种编码一致,直接透传不判定;遇到高位字节才用
|
|
169
|
+
// fatal UTF-8 试解(容忍尾部 1~3 字节截断),失败则判 GBK;一经判定不再变更。
|
|
170
|
+
let decoder = null
|
|
171
|
+
let pending = Buffer.alloc(0)
|
|
172
|
+
const feed = (d, flush = false) => {
|
|
173
|
+
pending = Buffer.concat([pending, d])
|
|
174
|
+
if (!decoder) {
|
|
175
|
+
const hasHighByte = pending.some((b) => b >= 0x80)
|
|
176
|
+
if (!hasHighByte) {
|
|
177
|
+
// 纯 ASCII:UTF-8/GBK 完全一致,透传即可(无需判定)
|
|
178
|
+
const s = pending.toString("ascii")
|
|
179
|
+
pending = Buffer.alloc(0)
|
|
180
|
+
return s
|
|
181
|
+
}
|
|
182
|
+
for (let trim = 0; trim <= 3 && !decoder; trim++) {
|
|
183
|
+
try {
|
|
184
|
+
new TextDecoder("utf-8", { fatal: true }).decode(pending.subarray(0, pending.length - trim))
|
|
185
|
+
decoder = new TextDecoder("utf-8")
|
|
186
|
+
} catch {
|
|
187
|
+
// 继续尝试
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!decoder) decoder = new TextDecoder("gbk")
|
|
191
|
+
}
|
|
192
|
+
const s = decoder.decode(pending, { stream: !flush })
|
|
193
|
+
pending = Buffer.alloc(0)
|
|
194
|
+
return s
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let out = ""
|
|
198
|
+
let truncatedNote = ""
|
|
199
|
+
const onData = (d) => {
|
|
200
|
+
const s = sanitizeOutput(feed(d))
|
|
201
|
+
// 输出实时透传给 UI;本地缓冲超 2MB 后停止累积(防内存爆炸)
|
|
202
|
+
if (s) ctx.onOutput?.(s)
|
|
203
|
+
if (out.length < 2_000_000) {
|
|
204
|
+
out += s
|
|
205
|
+
} else if (!truncatedNote) {
|
|
206
|
+
truncatedNote = "\n... (output exceeded 2MB, remainder discarded)"
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
child.stdout.on("data", onData)
|
|
210
|
+
child.stderr.on("data", onData)
|
|
211
|
+
|
|
212
|
+
const timer = setTimeout(() => child.kill(), args.timeout ?? BASH_TIMEOUT_MS)
|
|
213
|
+
child.on("error", (error) => {
|
|
214
|
+
clearTimeout(timer)
|
|
215
|
+
resolve(truncate(`Command failed: ${error.message}\n${out}`))
|
|
216
|
+
})
|
|
217
|
+
child.on("close", (code, signal) => {
|
|
218
|
+
clearTimeout(timer)
|
|
219
|
+
out += sanitizeOutput(feed(Buffer.alloc(0), true)) // 最终判定 + 冲刷解码器尾部
|
|
220
|
+
const suffix = signal
|
|
221
|
+
? "\n(killed: timeout)"
|
|
222
|
+
: code !== 0
|
|
223
|
+
? `\n(exit code ${code})`
|
|
224
|
+
: ""
|
|
225
|
+
resolve(truncate((out.trim() || "(no output)") + suffix + truncatedNote))
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------- glob
|
|
232
|
+
|
|
233
|
+
const globTool = {
|
|
234
|
+
name: "glob",
|
|
235
|
+
description: "Find files by glob pattern (e.g. 'src/**/*.mjs'). Returns matching paths.",
|
|
236
|
+
parameters: {
|
|
237
|
+
type: "object",
|
|
238
|
+
properties: {
|
|
239
|
+
pattern: { type: "string", description: "Glob pattern" },
|
|
240
|
+
path: { type: "string", description: "Directory to search in (default cwd)" },
|
|
241
|
+
},
|
|
242
|
+
required: ["pattern"],
|
|
243
|
+
},
|
|
244
|
+
readonly: true,
|
|
245
|
+
async execute(args, ctx) {
|
|
246
|
+
const base = resolve(ctx.cwd, args.path ?? ".")
|
|
247
|
+
const regex = globToRegex(args.pattern)
|
|
248
|
+
const results = []
|
|
249
|
+
for await (const relPath of walkFiles(base)) {
|
|
250
|
+
if (regex.test(relPath)) {
|
|
251
|
+
results.push(relPath)
|
|
252
|
+
if (results.length >= 1000) break
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (results.length === 0) return "(no matches)"
|
|
256
|
+
return truncate(results.sort().join("\n"))
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 递归遍历文件,产出相对路径(跳过 IGNORED_DIRS) */
|
|
261
|
+
async function* walkFiles(dir, rel = "") {
|
|
262
|
+
let entries
|
|
263
|
+
try {
|
|
264
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
265
|
+
} catch {
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
for (const e of entries) {
|
|
269
|
+
if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
|
|
270
|
+
const relPath = rel ? `${rel}/${e.name}` : e.name
|
|
271
|
+
if (e.isDirectory()) {
|
|
272
|
+
yield* walkFiles(join(dir, e.name), relPath)
|
|
273
|
+
} else {
|
|
274
|
+
yield relPath
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** glob 转正则:**\/ 匹配零或多级目录,** 跨目录,* 段内,? 段内单字符 */
|
|
280
|
+
function globToRegex(pattern) {
|
|
281
|
+
const DS = "\u0001" // **/ 的占位符(零或多级目录)
|
|
282
|
+
const DP = "\u0002" // ** 的占位符
|
|
283
|
+
const escaped = pattern
|
|
284
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
285
|
+
.replace(/\*\*\//g, DS)
|
|
286
|
+
.replace(/\*\*/g, DP)
|
|
287
|
+
.replace(/\*/g, "[^/]*")
|
|
288
|
+
.replace(/\?/g, "[^/]")
|
|
289
|
+
.replaceAll(DS, "(?:.*/)?")
|
|
290
|
+
.replaceAll(DP, ".*")
|
|
291
|
+
return new RegExp("^" + escaped + "$")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------- grep
|
|
295
|
+
|
|
296
|
+
const grepTool = {
|
|
297
|
+
name: "grep",
|
|
298
|
+
description: "Search file contents with a regex. Returns matching lines as path:line: content.",
|
|
299
|
+
parameters: {
|
|
300
|
+
type: "object",
|
|
301
|
+
properties: {
|
|
302
|
+
pattern: { type: "string", description: "Regular expression" },
|
|
303
|
+
path: { type: "string", description: "Directory or file to search (default cwd)" },
|
|
304
|
+
glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
|
|
305
|
+
},
|
|
306
|
+
required: ["pattern"],
|
|
307
|
+
},
|
|
308
|
+
readonly: true,
|
|
309
|
+
async execute(args, ctx) {
|
|
310
|
+
const base = resolve(ctx.cwd, args.path ?? ".")
|
|
311
|
+
const regex = new RegExp(args.pattern)
|
|
312
|
+
const fileFilter = args.glob ? globToRegex(args.glob) : null
|
|
313
|
+
const matches = []
|
|
314
|
+
|
|
315
|
+
async function search(file) {
|
|
316
|
+
let content
|
|
317
|
+
try {
|
|
318
|
+
content = await readFile(file, "utf8")
|
|
319
|
+
} catch {
|
|
320
|
+
return // 二进制/不可读文件跳过
|
|
321
|
+
}
|
|
322
|
+
const lines = content.split("\n")
|
|
323
|
+
for (let i = 0; i < lines.length; i++) {
|
|
324
|
+
if (regex.test(lines[i])) {
|
|
325
|
+
matches.push(`${file}:${i + 1}: ${lines[i]}`)
|
|
326
|
+
if (matches.length >= 200) return
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function walk(target) {
|
|
332
|
+
if (matches.length >= 200) return
|
|
333
|
+
const s = await stat(target)
|
|
334
|
+
if (!s.isDirectory()) {
|
|
335
|
+
if (!fileFilter || fileFilter.test(target.split(/[\\/]/).pop())) await search(target)
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
let entries
|
|
339
|
+
try {
|
|
340
|
+
entries = await readdir(target, { withFileTypes: true })
|
|
341
|
+
} catch {
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
for (const e of entries) {
|
|
345
|
+
if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
|
|
346
|
+
await walk(join(target, e.name))
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
await walk(base)
|
|
351
|
+
if (matches.length === 0) return "(no matches)"
|
|
352
|
+
return truncate(matches.join("\n"))
|
|
353
|
+
},
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------- websearch
|
|
357
|
+
|
|
358
|
+
const websearchTool = {
|
|
359
|
+
name: "websearch",
|
|
360
|
+
description:
|
|
361
|
+
"Search the web (Bing). Returns result titles, URLs, and snippets. Use for looking up current information, docs, error messages.",
|
|
362
|
+
parameters: {
|
|
363
|
+
type: "object",
|
|
364
|
+
properties: {
|
|
365
|
+
query: { type: "string", description: "Search query" },
|
|
366
|
+
limit: { type: "number", description: "Max results (default 8)" },
|
|
367
|
+
},
|
|
368
|
+
required: ["query"],
|
|
369
|
+
},
|
|
370
|
+
readonly: true,
|
|
371
|
+
async execute(args) {
|
|
372
|
+
const limit = args.limit ?? 8
|
|
373
|
+
const url = `https://www.bing.com/search?q=${encodeURIComponent(args.query)}`
|
|
374
|
+
let html
|
|
375
|
+
try {
|
|
376
|
+
const response = await fetch(url, {
|
|
377
|
+
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
|
|
378
|
+
signal: AbortSignal.timeout(15_000),
|
|
379
|
+
})
|
|
380
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
381
|
+
html = await response.text()
|
|
382
|
+
} catch (error) {
|
|
383
|
+
throw new Error(`websearch request failed: ${error.cause?.code ?? error.message}`)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 结果块 <li class="b_algo">:<h2><a href>标题</a></h2> + <p>摘要</p>
|
|
387
|
+
const blocks = html.split('<li class="b_algo"').slice(1)
|
|
388
|
+
const results = []
|
|
389
|
+
for (const block of blocks) {
|
|
390
|
+
const link = block.match(/<h2[^>]*><a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/)
|
|
391
|
+
if (!link) continue
|
|
392
|
+
const snippet = block.match(/<p[^>]*>([\s\S]*?)<\/p>/)
|
|
393
|
+
results.push({
|
|
394
|
+
href: link[1],
|
|
395
|
+
title: stripTags(link[2]),
|
|
396
|
+
snippet: snippet ? stripTags(snippet[1]) : "",
|
|
397
|
+
})
|
|
398
|
+
if (results.length >= limit) break
|
|
399
|
+
}
|
|
400
|
+
if (results.length === 0) return "(no results)"
|
|
401
|
+
return truncate(
|
|
402
|
+
results.map((r, i) => `${i + 1}. ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"),
|
|
403
|
+
)
|
|
404
|
+
},
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function stripTags(html) {
|
|
408
|
+
return html
|
|
409
|
+
.replace(/<[^>]+>/g, "")
|
|
410
|
+
.replace(/�*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
411
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
412
|
+
.replace(/ /g, " ")
|
|
413
|
+
.replace(/&/g, "&")
|
|
414
|
+
.replace(/</g, "<")
|
|
415
|
+
.replace(/>/g, ">")
|
|
416
|
+
.replace(/"/g, '"')
|
|
417
|
+
.replace(/\s+/g, " ")
|
|
418
|
+
.trim()
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------- ls
|
|
422
|
+
|
|
423
|
+
const lsTool = {
|
|
424
|
+
name: "ls",
|
|
425
|
+
description:
|
|
426
|
+
"List directory contents with type, size, and modification time. Directories listed first. Use to see what a directory contains (glob only matches files).",
|
|
427
|
+
parameters: {
|
|
428
|
+
type: "object",
|
|
429
|
+
properties: {
|
|
430
|
+
path: { type: "string", description: "Directory path (default cwd)" },
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
readonly: true,
|
|
434
|
+
async execute(args, ctx) {
|
|
435
|
+
const abs = resolve(ctx.cwd, args.path ?? ".")
|
|
436
|
+
const entries = await readdir(abs, { withFileTypes: true })
|
|
437
|
+
const rows = await Promise.all(
|
|
438
|
+
entries.slice(0, 500).map(async (e) => {
|
|
439
|
+
const s = await stat(join(abs, e.name)).catch(() => null)
|
|
440
|
+
const isDir = e.isDirectory()
|
|
441
|
+
return {
|
|
442
|
+
dir: isDir,
|
|
443
|
+
name: e.name + (isDir ? "/" : ""),
|
|
444
|
+
size: s?.size ?? 0,
|
|
445
|
+
mtime: s ? s.mtime.toISOString().slice(0, 16).replace("T", " ") : "?",
|
|
446
|
+
}
|
|
447
|
+
}),
|
|
448
|
+
)
|
|
449
|
+
rows.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
|
|
450
|
+
if (rows.length === 0) return "(empty directory)"
|
|
451
|
+
const out = rows.map((r) => `${r.dir ? "d" : "-"} ${r.name.padEnd(40)} ${String(r.size).padStart(10)} ${r.mtime}`)
|
|
452
|
+
return truncate(out.join("\n"))
|
|
453
|
+
},
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------- fetch
|
|
457
|
+
|
|
458
|
+
const fetchTool = {
|
|
459
|
+
name: "fetch",
|
|
460
|
+
description:
|
|
461
|
+
"Fetch a URL and return its content as text (HTML pages are stripped to readable text). Use after websearch to read full documents.",
|
|
462
|
+
parameters: {
|
|
463
|
+
type: "object",
|
|
464
|
+
properties: {
|
|
465
|
+
url: { type: "string", description: "http/https URL" },
|
|
466
|
+
},
|
|
467
|
+
required: ["url"],
|
|
468
|
+
},
|
|
469
|
+
readonly: true,
|
|
470
|
+
async execute(args) {
|
|
471
|
+
if (!/^https?:\/\//.test(args.url)) throw new Error("url must start with http:// or https://")
|
|
472
|
+
let response
|
|
473
|
+
try {
|
|
474
|
+
response = await fetch(args.url, {
|
|
475
|
+
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
|
|
476
|
+
redirect: "follow",
|
|
477
|
+
signal: AbortSignal.timeout(20_000),
|
|
478
|
+
})
|
|
479
|
+
} catch (error) {
|
|
480
|
+
throw new Error(`fetch failed: ${error.cause?.code ?? error.message}`)
|
|
481
|
+
}
|
|
482
|
+
if (!response.ok) throw new Error(`fetch failed: HTTP ${response.status}`)
|
|
483
|
+
|
|
484
|
+
const contentType = response.headers.get("content-type") ?? ""
|
|
485
|
+
const body = await response.text()
|
|
486
|
+
if (!contentType.includes("text/html")) return truncate(body)
|
|
487
|
+
return truncate(htmlToText(body))
|
|
488
|
+
},
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** HTML → 粗文本:去脚本样式、块级标签换行、剥标签、解码实体、压缩空行 */
|
|
492
|
+
function htmlToText(html) {
|
|
493
|
+
return html
|
|
494
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
495
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
496
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, "")
|
|
497
|
+
.replace(/<\/(p|div|li|ul|ol|h[1-6]|tr|table|section|article|header|footer|blockquote|pre)>/gi, "\n")
|
|
498
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
499
|
+
.replace(/<li[^>]*>/gi, "- ")
|
|
500
|
+
.replace(/<[^>]+>/g, "")
|
|
501
|
+
.replace(/�*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
502
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
503
|
+
.replace(/ | /g, " ")
|
|
504
|
+
.replace(/&/g, "&")
|
|
505
|
+
.replace(/</g, "<")
|
|
506
|
+
.replace(/>/g, ">")
|
|
507
|
+
.replace(/"/g, '"')
|
|
508
|
+
.replace(/[ \t]+/g, " ")
|
|
509
|
+
.replace(/\n\s*\n\s*\n+/g, "\n\n")
|
|
510
|
+
.trim()
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export const builtinTools = [readTool, writeTool, editTool, bashTool, globTool, grepTool, websearchTool, lsTool, fetchTool]
|