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.
@@ -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(/&#0*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
411
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
412
+ .replace(/&ensp;/g, " ")
413
+ .replace(/&amp;/g, "&")
414
+ .replace(/&lt;/g, "<")
415
+ .replace(/&gt;/g, ">")
416
+ .replace(/&quot;/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(/&#0*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
502
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
503
+ .replace(/&nbsp;|&ensp;/g, " ")
504
+ .replace(/&amp;/g, "&")
505
+ .replace(/&lt;/g, "<")
506
+ .replace(/&gt;/g, ">")
507
+ .replace(/&quot;/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]