thincoder 0.7.7 → 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 (97) hide show
  1. package/README.md +36 -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.md +1 -0
  42. package/src/tools/bash.mjs +144 -0
  43. package/src/tools/file.mjs +205 -0
  44. package/src/tools/git.mjs +166 -0
  45. package/src/tools/glob.mjs +51 -0
  46. package/src/tools/grep.mjs +100 -0
  47. package/src/tools/index.mjs +22 -0
  48. package/src/tools/ls.mjs +36 -0
  49. package/src/tools/patch.mjs +226 -0
  50. package/src/tools/repomap-parse.mjs +168 -0
  51. package/src/tools/shared.mjs +257 -0
  52. package/src/tools/system.mjs +336 -0
  53. package/src/tools/web.mjs +121 -0
  54. package/src/tools.mjs +2 -1188
  55. package/src/tui/agent-turn.mjs +254 -0
  56. package/src/tui/ansi.mjs +32 -0
  57. package/src/tui/clipboard.mjs +48 -0
  58. package/src/tui/cmd-auto.mjs +21 -0
  59. package/src/tui/cmd-clear.mjs +26 -0
  60. package/src/tui/cmd-config.mjs +72 -0
  61. package/src/tui/cmd-exit.mjs +5 -0
  62. package/src/tui/cmd-extract.mjs +5 -0
  63. package/src/tui/cmd-goal.mjs +47 -0
  64. package/src/tui/cmd-help.mjs +25 -0
  65. package/src/tui/cmd-init.mjs +91 -0
  66. package/src/tui/cmd-mcp.mjs +146 -0
  67. package/src/tui/cmd-model.mjs +7 -0
  68. package/src/tui/cmd-new.mjs +18 -0
  69. package/src/tui/cmd-plan.mjs +21 -0
  70. package/src/tui/cmd-reindex.mjs +44 -0
  71. package/src/tui/cmd-restore.mjs +39 -0
  72. package/src/tui/cmd-session.mjs +42 -0
  73. package/src/tui/cmd-skills.mjs +17 -0
  74. package/src/tui/cmd-think.mjs +56 -0
  75. package/src/tui/config-helpers.mjs +34 -0
  76. package/src/tui/distill-cmd.mjs +45 -0
  77. package/src/tui/index.mjs +330 -0
  78. package/src/tui/interaction.mjs +79 -0
  79. package/src/tui/key-handler.mjs +267 -0
  80. package/src/tui/layout.mjs +115 -0
  81. package/src/tui/pickers.mjs +279 -0
  82. package/src/tui/render-frame.mjs +304 -0
  83. package/src/tui/render.mjs +205 -0
  84. package/src/tui/slash-commands.mjs +138 -0
  85. package/src/tui/startup.mjs +113 -0
  86. package/src/tui/wizard.mjs +168 -0
  87. package/src/tui-render.mjs +4 -0
  88. package/src/tui.mjs +3 -2546
  89. package/src/provider.mjs +0 -383
  90. /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
  91. /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
  92. /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
  93. /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
  94. /package/src/{main-overlay.md → prompts/main.md} +0 -0
  95. /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
  96. /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
  97. /package/src/{repomap.mjs → tools/repomap.mjs} +0 -0
@@ -0,0 +1,205 @@
1
+ import {
2
+ DESC,
3
+ truncate,
4
+ MAX_READ_LINES,
5
+ gitDiffOne,
6
+ autoSyntaxCheck,
7
+ resolveInCwd
8
+ } from "./shared.mjs";
9
+ import { mkdir } from "node:fs/promises";
10
+ import { readFile } from "node:fs/promises";
11
+ import { stat } from "node:fs/promises";
12
+ import { writeFile } from "node:fs/promises";
13
+ import { unlink } from "node:fs/promises";
14
+ import { join, relative, dirname } from "node:path";
15
+
16
+ export const readTool = {
17
+ name: "read",
18
+ description: DESC("read"),
19
+ parameters: {
20
+ type: "object",
21
+ properties: {
22
+ path: { type: "string", description: "File path (relative to cwd or absolute)" },
23
+ offset: { type: "number", description: "1-based line number to start from" },
24
+ limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
25
+ },
26
+ required: ["path"],
27
+ },
28
+ readonly: true,
29
+ async execute(args, ctx) {
30
+ const abs = resolveInCwd(ctx, args.path)
31
+ // 大文件保护:先查大小,超 10MB 拒绝整文件读入(offset/limit 只影响返回切片,不影响缓冲)
32
+ const st = await stat(abs).catch(() => null)
33
+ if (st && st.size > 10_000_000) throw new Error(`File too large (${Math.round(st.size / 1_000_000)}MB > 10MB limit). Use bash with head/tail or grep for targeted extraction.`)
34
+ const content = await readFile(abs, "utf8")
35
+ const lines = content.split("\n")
36
+ const offset = Math.max(1, args.offset ?? 1)
37
+ const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
38
+ const slice = lines.slice(offset - 1, offset - 1 + limit)
39
+ const numbered = slice.map((l, i) => `${offset + i}\t${l}`).join("\n")
40
+ const suffix = offset - 1 + limit < lines.length ? `\n... (${lines.length} lines total, use offset to continue)` : ""
41
+ return truncate(numbered + suffix)
42
+ },
43
+ }
44
+
45
+ // ---------------------------------------------------------------- read_image
46
+
47
+ const IMAGE_EXTENSIONS = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp", svg: "image/svg+xml" }
48
+
49
+ export const readImageTool = {
50
+ name: "read_image",
51
+ description: DESC("read_image"),
52
+ parameters: {
53
+ type: "object",
54
+ properties: {
55
+ path: { type: "string", description: "Path to image file (relative to cwd or absolute). Supports png, jpg, gif, webp, bmp, svg." },
56
+ },
57
+ required: ["path"],
58
+ },
59
+ readonly: true,
60
+ /** 返回 JSON:{ text, images },供 agent 层转为多模态 user 消息 */
61
+ async execute(args, ctx) {
62
+ const abs = resolveInCwd(ctx, args.path)
63
+ const ext = abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()
64
+ const mime = IMAGE_EXTENSIONS[ext]
65
+ if (!mime) throw new Error(`Unsupported image format: .${ext}. Supported: ${Object.keys(IMAGE_EXTENSIONS).join(", ")}`)
66
+ // 先查大小再读入——防超大图片撑爆内存(20MB base64 ≈ 15MB 原始)
67
+ const imgStat = await stat(abs).catch(() => null)
68
+ if (imgStat && imgStat.size > 15_000_000) throw new Error(`Image too large: ${Math.round(imgStat.size / 1_000_000)}MB (max 15MB)`)
69
+ const buf = await readFile(abs) // raw buffer, no encoding
70
+ const b64 = buf.toString("base64")
71
+ const bytes = buf.length
72
+ const result = JSON.stringify({
73
+ text: `[read_image: ${args.path} (${mime}, ${bytes} bytes)]`,
74
+ images: [{ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } }],
75
+ })
76
+ // 粘贴产生的临时文件用完即删,不留垃圾
77
+ const basename = abs.includes("/") ? abs.slice(abs.lastIndexOf("/") + 1) : abs.slice(abs.lastIndexOf("\\") + 1)
78
+ if (basename.startsWith(".thincoder-paste-")) {
79
+ try { await unlink(abs) } catch { /* 删不掉就算了 */ }
80
+ }
81
+ return result
82
+ },
83
+ }
84
+
85
+ // ---------------------------------------------------------------- write
86
+
87
+ export const writeTool = {
88
+ name: "write",
89
+ description: DESC("write"),
90
+ parameters: {
91
+ type: "object",
92
+ properties: {
93
+ path: { type: "string", description: "File path (relative to cwd or absolute)" },
94
+ content: { type: "string", description: "Full content to write" },
95
+ },
96
+ required: ["path", "content"],
97
+ },
98
+ readonly: false,
99
+ async execute(args, ctx) {
100
+ const abs = resolveInCwd(ctx, args.path)
101
+ await mkdir(dirname(abs), { recursive: true })
102
+ const st = await stat(abs).catch(() => null)
103
+ if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
104
+ await writeFile(abs, args.content, "utf8")
105
+ const diff = gitDiffOne(ctx.cwd, abs)
106
+ return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
107
+ },
108
+ }
109
+
110
+ // ---------------------------------------------------------------- edit
111
+
112
+ export const editTool = {
113
+ name: "edit",
114
+ description: DESC("edit"),
115
+ parameters: {
116
+ type: "object",
117
+ properties: {
118
+ path: { type: "string", description: "File path" },
119
+ old_string: { type: "string", description: "Exact text to replace" },
120
+ new_string: { type: "string", description: "Replacement text" },
121
+ replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
122
+ },
123
+ required: ["path", "old_string", "new_string"],
124
+ },
125
+ readonly: false,
126
+ async execute(args, ctx) {
127
+ const abs = resolveInCwd(ctx, args.path)
128
+ if (!args.old_string) {
129
+ throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
130
+ }
131
+ const content = await readFile(abs, "utf8")
132
+ const occurrences = content.split(args.old_string).length - 1
133
+ if (occurrences === 0) {
134
+ // 给出线索帮模型定位:首行预览 + 常见原因
135
+ const preview = args.old_string.slice(0, 100).split("\n")[0]
136
+ throw new Error(
137
+ `old_string not found in ${args.path}\n` +
138
+ ` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
139
+ ` hints: whitespace mismatch? file already changed? try reading the file first`
140
+ )
141
+ }
142
+ if (occurrences > 1 && !args.replace_all) {
143
+ throw new Error(`old_string matches ${occurrences} times in ${args.path}; provide more context or set replace_all`)
144
+ }
145
+ const updated = args.replace_all
146
+ ? content.split(args.old_string).join(args.new_string)
147
+ // 函数式替换:避免 new_string 里的 $ 替换模式(匹配串/前后文引用)被展开
148
+ : content.replace(args.old_string, () => args.new_string)
149
+ await writeFile(abs, updated, "utf8")
150
+ const diff = gitDiffOne(ctx.cwd, abs)
151
+ return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
152
+ },
153
+ }
154
+
155
+ // ---------------------------------------------------------------- insert_after
156
+
157
+ export const insertAfterTool = {
158
+ name: "insert_after",
159
+ description: DESC("insert_after"),
160
+ parameters: {
161
+ type: "object",
162
+ properties: {
163
+ path: { type: "string", description: "File path" },
164
+ after_line: { type: "number", description: "Line number to insert after (1-based). Takes priority over after_regex." },
165
+ after_regex: { type: "string", description: "JavaScript regex to find the line to insert after (must match exactly one line)" },
166
+ content: { type: "string", description: "Text to insert (with leading newline if you need a blank line)" },
167
+ },
168
+ required: ["path", "content"],
169
+ },
170
+ readonly: false,
171
+ async execute(args, ctx) {
172
+ const abs = resolveInCwd(ctx, args.path)
173
+ const text = await readFile(abs, "utf8")
174
+ const lines = text.split("\n")
175
+
176
+ let targetLine
177
+ if (args.after_line != null) {
178
+ targetLine = args.after_line
179
+ if (!Number.isInteger(targetLine)) {
180
+ throw new Error(`after_line must be an integer, got: ${args.after_line}`)
181
+ }
182
+ if (targetLine < 1 || targetLine > lines.length) {
183
+ throw new Error(`after_line ${targetLine} out of range (file has ${lines.length} lines)`)
184
+ }
185
+ } else if (args.after_regex) {
186
+ const regex = new RegExp(args.after_regex)
187
+ const matches = []
188
+ for (let i = 0; i < lines.length; i++) {
189
+ if (regex.test(lines[i])) matches.push(i + 1)
190
+ }
191
+ if (matches.length === 0) throw new Error(`after_regex /${args.after_regex}/ matched no lines in ${args.path}`)
192
+ if (matches.length > 1) throw new Error(`after_regex /${args.after_regex}/ matched ${matches.length} lines (${matches.slice(0, 5).join(", ")}${matches.length > 5 ? "…" : ""}); use a more specific pattern or after_line instead`)
193
+ targetLine = matches[0]
194
+ } else {
195
+ throw new Error("Either after_line or after_regex is required")
196
+ }
197
+
198
+ lines.splice(targetLine, 0, args.content)
199
+ const updated = lines.join("\n")
200
+ await writeFile(abs, updated, "utf8")
201
+ const diff = gitDiffOne(ctx.cwd, abs)
202
+ return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
203
+ },
204
+ }
205
+
@@ -0,0 +1,166 @@
1
+ import {
2
+ DESC,
3
+ truncate,
4
+ runGit
5
+ } from "./shared.mjs";
6
+ import { execFileSync } from "node:child_process";
7
+ import { join } from "node:path";
8
+
9
+ export const gitDiffTool = {
10
+ name: "git_diff",
11
+ description: DESC("git_diff"),
12
+ parameters: {
13
+ type: "object",
14
+ properties: {
15
+ staged: { type: "boolean", description: "Show staged changes (default false)" },
16
+ path: { type: "string", description: "File or directory to diff (default all)" },
17
+ ref: { type: "string", description: "Compare against this ref (default HEAD)" },
18
+ },
19
+ },
20
+ readonly: true,
21
+ execute(args, ctx) {
22
+ const ref = args.ref ?? "HEAD"
23
+ // ref 由模型提供且位于 "--" 之前:校验字符集,防 "--output=..." 之类被 git 当成选项
24
+ if (!/^[A-Za-z0-9._\/~^][A-Za-z0-9._\/~^-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
25
+ const flags = args.staged ? ["--staged"] : []
26
+ const paths = args.path ? [args.path] : []
27
+ const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
28
+ return truncate(out || "(no changes)")
29
+ },
30
+ }
31
+
32
+ // ---------------------------------------------------------------- git_status
33
+
34
+ export const gitStatusTool = {
35
+ name: "git_status",
36
+ description: DESC("git_status"),
37
+ parameters: {
38
+ type: "object",
39
+ properties: {},
40
+ },
41
+ readonly: true,
42
+ execute(_args, ctx) {
43
+ const porcelain = runGit(ctx.cwd, ["status", "--porcelain"])
44
+ if (!porcelain) return "(clean — no changes)"
45
+
46
+ const staged = []
47
+ const unstaged = []
48
+ const untracked = []
49
+ const conflicts = []
50
+ for (const line of porcelain.split("\n")) {
51
+ if (!line) continue
52
+ // porcelain: XY path — 2 状态字符 + 空格 + 文件路径(部分环境只 1 空格)
53
+ // 去掉可能的 CR(execFileSync 在某些 Windows git 下会残留 \r 在行末但不在换行符中)
54
+ const clean = line.replace(/\r/g, "")
55
+ // 尝试匹配 "XY path" 或 "XY path"(可变间距)
56
+ const m = clean.match(/^(..?)\s+(.+)$/)
57
+ if (!m) continue
58
+ const [, status, rawFile] = m
59
+ // 重命名条目 porcelain 输出为 "R old -> new",拆开明确展示而非当成一个字面文件名
60
+ const file = status.includes("R") && rawFile.includes(" -> ") ? rawFile.replace(" -> ", " → ") : rawFile
61
+ const idx = status[0] ?? " "
62
+ const wt = status[1] ?? " "
63
+ if (idx === "U" || wt === "U" || (idx === "A" && wt === "A")) {
64
+ conflicts.push(file)
65
+ } else if (idx === "?" && wt === "?") {
66
+ untracked.push(file)
67
+ } else {
68
+ if (idx !== " " && idx !== "?") staged.push(idx + " " + file)
69
+ if (wt !== " " && wt !== "?") unstaged.push(wt + " " + file)
70
+ }
71
+ }
72
+ const parts = []
73
+ if (staged.length) parts.push("Staged (" + staged.length + "):\n" + staged.join("\n"))
74
+ if (unstaged.length) parts.push("Unstaged (" + unstaged.length + "):\n" + unstaged.join("\n"))
75
+ if (untracked.length) parts.push("Untracked (" + untracked.length + "):\n" + untracked.join("\n"))
76
+ if (conflicts.length) parts.push("Conflicts (" + conflicts.length + "):\n" + conflicts.join("\n"))
77
+ return truncate(parts.join("\n\n"))
78
+ },
79
+ }
80
+
81
+ // ---------------------------------------------------------------- git_log
82
+
83
+ export const gitLogTool = {
84
+ name: "git_log",
85
+ description: DESC("git_log"),
86
+ parameters: {
87
+ type: "object",
88
+ properties: {
89
+ count: { type: "number", description: "Number of commits (default 10)" },
90
+ path: { type: "string", description: "File or directory (default all)" },
91
+ oneline: { type: "boolean", description: "One-line-per-commit format (default false)" },
92
+ },
93
+ },
94
+ readonly: true,
95
+ execute(args, ctx) {
96
+ const n = Math.min(Math.max(1, args.count ?? 10), 200)
97
+ const isOneline = args.oneline
98
+ const cmdArgs = isOneline
99
+ ? ["log", "-" + n, "--oneline"]
100
+ : ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
101
+ if (args.path) cmdArgs.push("--", args.path)
102
+ const out = runGit(ctx.cwd, cmdArgs)
103
+ return truncate(out || "(no commits)")
104
+ },
105
+ }
106
+
107
+ // ---------------------------------------------------------------- question
108
+
109
+ export const questionTool = {
110
+ name: "question",
111
+ description: DESC("question"),
112
+ parameters: {
113
+ type: "object",
114
+ properties: {
115
+ question: { type: "string", description: "The question to ask the user" },
116
+ options: {
117
+ type: "array",
118
+ items: { type: "string" },
119
+ description: "Single-choice options for the user to pick from (optional)",
120
+ },
121
+ },
122
+ required: ["question"],
123
+ },
124
+ readonly: true,
125
+ async execute(args, ctx) {
126
+ if (!ctx.onQuestion) throw new Error("question tool not supported in this context (no UI to ask)")
127
+ return ctx.onQuestion(args.question, args.options ?? [])
128
+ },
129
+ }
130
+
131
+ /** 执行 git 命令;非 git 仓库 / git 不可用时返回空字符串 */
132
+
133
+ // ---------------------------------------------------------------- checkpoint
134
+
135
+ export const checkpointTool = {
136
+ name: "checkpoint",
137
+ description: DESC("checkpoint"),
138
+ parameters: {
139
+ type: "object",
140
+ properties: {
141
+ action: { type: "string", enum: ["list", "create", "rewind"], description: "list snapshots / create one now / restore a snapshot by id" },
142
+ id: { type: "string", description: "Snapshot id (required for rewind)" },
143
+ },
144
+ required: ["action"],
145
+ },
146
+ readonly: false,
147
+ async execute(args, ctx) {
148
+ const { createCheckpoint, listCheckpoints, rewind, isGitRepo } = await import("../git/checkpoint.mjs")
149
+ if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
150
+ if (args.action === "create") {
151
+ const cp = await createCheckpoint(ctx.cwd)
152
+ return `Checkpoint ${cp.id} created (${cp.files} file(s) captured)`
153
+ }
154
+ if (args.action === "rewind") {
155
+ if (!args.id) throw new Error("id is required for rewind — use action=list to see snapshot ids")
156
+ const s = await rewind(ctx.cwd, args.id)
157
+ return `Rewound to checkpoint ${args.id}: patch ${s.patchApplied ? "applied" : "(empty)"}, ${s.restored} untracked file(s) restored, ${s.deleted} file(s) deleted.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
158
+ }
159
+ if (args.action === "list") {
160
+ const cps = await listCheckpoints(ctx.cwd)
161
+ if (cps.length === 0) return "(no checkpoints yet — one is auto-created before each user task)"
162
+ return cps.map((c) => `${c.id} ${new Date(c.time).toISOString()} ${c.untracked} untracked file(s)`).join("\n")
163
+ }
164
+ throw new Error(`Unknown action: ${args.action}`)
165
+ },
166
+ }
@@ -0,0 +1,51 @@
1
+ import { DESC, truncate, resolveInCwd, globToRegex, IGNORED_DIRS } from "./shared.mjs"
2
+ import { readdir } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+
5
+ export const globTool = {
6
+ name: "glob",
7
+ description: DESC("glob"),
8
+ parameters: {
9
+ type: "object",
10
+ properties: {
11
+ pattern: { type: "string", description: "Glob pattern" },
12
+ path: { type: "string", description: "Directory to search in (default cwd)" },
13
+ },
14
+ required: ["pattern"],
15
+ },
16
+ readonly: true,
17
+ async execute(args, ctx) {
18
+ const base = resolveInCwd(ctx, args.path ?? ".")
19
+ const regex = globToRegex(args.pattern)
20
+ const results = []
21
+ for await (const relPath of walkFiles(base)) {
22
+ if (regex.test(relPath)) {
23
+ results.push(relPath)
24
+ if (results.length >= 1000) break
25
+ }
26
+ }
27
+ if (results.length === 0) return "(no matches)"
28
+ return truncate(results.sort().join("\n"))
29
+ },
30
+ }
31
+
32
+ // ---------------------------------------------------------- 内部实现
33
+
34
+ /** 递归遍历文件,产出相对路径(跳过 IGNORED_DIRS) */
35
+ async function* walkFiles(dir, rel = "") {
36
+ let entries
37
+ try {
38
+ entries = await readdir(dir, { withFileTypes: true })
39
+ } catch {
40
+ return
41
+ }
42
+ for (const e of entries) {
43
+ if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
44
+ const relPath = rel ? `${rel}/${e.name}` : e.name
45
+ if (e.isDirectory()) {
46
+ yield* walkFiles(join(dir, e.name), relPath)
47
+ } else {
48
+ yield relPath
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,100 @@
1
+ import { DESC, truncate, resolveInCwd, globToRegex, IGNORED_DIRS } from "./shared.mjs"
2
+ import { readFile, readdir } from "node:fs/promises"
3
+ import { stat, lstat } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+
6
+ export const grepTool = {
7
+ name: "grep",
8
+ description: DESC("grep"),
9
+ parameters: {
10
+ type: "object",
11
+ properties: {
12
+ pattern: { type: "string", description: "Regular expression" },
13
+ path: { type: "string", description: "Directory or file to search (default cwd)" },
14
+ glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
15
+ before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
16
+ after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
17
+ },
18
+ required: ["pattern"],
19
+ },
20
+ readonly: true,
21
+ async execute(args, ctx) {
22
+ const base = resolveInCwd(ctx, args.path ?? ".")
23
+ const regex = new RegExp(args.pattern)
24
+ const fileFilter = args.glob ? globToRegex(args.glob) : null
25
+ const before = Math.max(0, Math.floor(args.before ?? 0))
26
+ const after = Math.max(0, Math.floor(args.after ?? 0))
27
+ const wantCtx = before > 0 || after > 0
28
+ const hits = [] // { file, line(1-based), text }
29
+ const fileLines = new Map() // file -> string[](仅 wantCtx 时缓存)
30
+
31
+ async function search(file) {
32
+ let content
33
+ try {
34
+ // 大文件保护:超 10MB 跳过,防 OOM
35
+ const fst = await stat(file)
36
+ if (fst.size > 10_000_000) return
37
+ content = await readFile(file, "utf8")
38
+ } catch {
39
+ return // 不可读文件跳过;二进制会被按 utf8 读入并照常搜索(可能产生乱码匹配)
40
+ }
41
+ const lines = content.split("\n")
42
+ if (wantCtx) fileLines.set(file, lines)
43
+ for (let i = 0; i < lines.length; i++) {
44
+ if (regex.test(lines[i])) {
45
+ hits.push({ file, line: i + 1, text: lines[i] })
46
+ if (hits.length >= 200) return
47
+ }
48
+ }
49
+ }
50
+
51
+ async function walk(target) {
52
+ if (hits.length >= 200) return
53
+ // 用 lstat 不跟随符号链接——防 ./evil → /etc 时 grep 读遍 /etc
54
+ let s
55
+ try { s = await lstat(target) } catch { return }
56
+ if (!s.isDirectory()) {
57
+ if (!fileFilter || fileFilter.test(target.split(/[\\/]/).pop())) await search(target)
58
+ return
59
+ }
60
+ let entries
61
+ try {
62
+ entries = await readdir(target, { withFileTypes: true })
63
+ } catch {
64
+ return
65
+ }
66
+ for (const e of entries) {
67
+ if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
68
+ await walk(join(target, e.name))
69
+ }
70
+ }
71
+
72
+ await walk(base)
73
+ if (hits.length === 0) return "(no matches)"
74
+
75
+ // 无上下文:保持原 path:line: content 格式
76
+ if (!wantCtx) {
77
+ return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
78
+ }
79
+
80
+ // 带上下文:匹配行用 ':',上下文行用 '-'(同 ripgrep);同文件相邻区间去重合并
81
+ const fileMatched = new Map() // file -> Set<line>
82
+ for (const h of hits) {
83
+ if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
84
+ fileMatched.get(h.file).add(h.line)
85
+ }
86
+ const out = []
87
+ for (const [file, matchedLines] of fileMatched) {
88
+ const lines = fileLines.get(file) ?? []
89
+ const lineSet = new Set()
90
+ for (const ml of matchedLines) {
91
+ for (let l = Math.max(1, ml - before); l <= Math.min(lines.length, ml + after); l++) lineSet.add(l)
92
+ }
93
+ for (const l of [...lineSet].sort((a, b) => a - b)) {
94
+ const sep = matchedLines.has(l) ? ":" : "-"
95
+ out.push(`${file}${sep}${l}${sep} ${lines[l - 1]}`)
96
+ }
97
+ }
98
+ return truncate(out.join("\n"))
99
+ },
100
+ }
@@ -0,0 +1,22 @@
1
+ // tools/index.mjs — 后端兼容重导出
2
+ export { toOpenAISchema } from "./shared.mjs";
3
+
4
+ import { readTool, writeTool, editTool, insertAfterTool, readImageTool } from "./file.mjs";
5
+ import { applyPatchTool, syntaxCheckTool, deleteTool } from "./patch.mjs";
6
+ import { bashTool, globTool, grepTool, lsTool } from "./system.mjs";
7
+ import { websearchTool, fetchTool } from "./web.mjs";
8
+ import { gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool } from "./git.mjs";
9
+
10
+ export const builtinTools = [
11
+ readTool, writeTool, editTool, insertAfterTool, applyPatchTool,
12
+ syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
13
+ websearchTool, lsTool, fetchTool, deleteTool,
14
+ gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
15
+ ];
16
+
17
+ export {
18
+ readTool, writeTool, editTool, insertAfterTool, applyPatchTool,
19
+ syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
20
+ websearchTool, lsTool, fetchTool, deleteTool,
21
+ gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
22
+ };
@@ -0,0 +1,36 @@
1
+ import { DESC, truncate, resolveInCwd } from "./shared.mjs"
2
+ import { readdir } from "node:fs/promises"
3
+ import { stat } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+
6
+ export const lsTool = {
7
+ name: "ls",
8
+ description: DESC("ls"),
9
+ parameters: {
10
+ type: "object",
11
+ properties: {
12
+ path: { type: "string", description: "Directory path (default cwd)" },
13
+ },
14
+ },
15
+ readonly: true,
16
+ async execute(args, ctx) {
17
+ const abs = resolveInCwd(ctx, args.path ?? ".")
18
+ const entries = await readdir(abs, { withFileTypes: true })
19
+ const rows = await Promise.all(
20
+ entries.slice(0, 500).map(async (e) => {
21
+ const s = await stat(join(abs, e.name)).catch(() => null)
22
+ const isDir = e.isDirectory()
23
+ return {
24
+ dir: isDir,
25
+ name: e.name + (isDir ? "/" : ""),
26
+ size: s?.size ?? 0,
27
+ mtime: s ? s.mtime.toISOString().slice(0, 16).replace("T", " ") : "?",
28
+ }
29
+ }),
30
+ )
31
+ rows.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
32
+ if (rows.length === 0) return "(empty directory)"
33
+ const out = rows.map((r) => `${r.dir ? "d" : "-"} ${r.name.padEnd(40)} ${String(r.size).padStart(10)} ${r.mtime}`)
34
+ return truncate(out.join("\n"))
35
+ },
36
+ }