minimal-agent 0.2.0 → 0.3.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 (107) hide show
  1. package/README.md +50 -72
  2. package/package.json +18 -13
  3. package/plugins/ralph-wiggum/plugin.js +205 -0
  4. package/plugins/ralph-wiggum/src/goalState.js +260 -0
  5. package/plugins/ralph-wiggum/src/{sentinels.ts → sentinels.js} +4 -7
  6. package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
  7. package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
  8. package/plugins/workflow-runner/{plugin.ts → plugin.js} +20 -26
  9. package/plugins/workflow-runner/src/expressions.js +369 -0
  10. package/plugins/workflow-runner/src/index.js +174 -0
  11. package/plugins/workflow-runner/src/loader.js +183 -0
  12. package/plugins/workflow-runner/src/runner.js +290 -0
  13. package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
  14. package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
  15. package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
  16. package/plugins/workflow-runner/src/stepExecutors/{tool.ts → tool.js} +19 -25
  17. package/plugins/workflow-runner/src/types.js +59 -0
  18. package/plugins/workflow-runner/src/{workflowState.ts → workflowState.js} +21 -40
  19. package/src/bootstrap/cwdArg.js +22 -0
  20. package/src/bootstrap/workingDir.js +31 -0
  21. package/src/cli/configWizard.js +272 -0
  22. package/src/cli/print.js +192 -0
  23. package/src/config/configFile.js +78 -0
  24. package/src/config.js +118 -0
  25. package/src/context/compact.js +357 -0
  26. package/src/context/microCompactLite.js +151 -0
  27. package/src/context/persistContext.js +109 -0
  28. package/src/context/reactiveCompact.js +121 -0
  29. package/src/context/sessionPath.js +58 -0
  30. package/src/context/snipCompact.js +112 -0
  31. package/src/context/tokenCounter.js +66 -0
  32. package/src/llm/client.js +182 -0
  33. package/src/loop.js +230 -0
  34. package/src/main.js +116 -0
  35. package/src/plugin-sdk.js +24 -0
  36. package/src/plugins/commandRouter.js +169 -0
  37. package/src/plugins/hookEngine.js +258 -0
  38. package/src/plugins/pluginApi.js +23 -0
  39. package/src/plugins/pluginLoader.js +71 -0
  40. package/src/plugins/pluginRunner.js +65 -0
  41. package/src/plugins/transcript.js +171 -0
  42. package/src/prompts/projectInstructions.js +48 -0
  43. package/src/prompts/skillList.js +126 -0
  44. package/src/prompts/system.js +155 -0
  45. package/src/session/runTurn.js +41 -0
  46. package/src/session/sessionState.js +19 -0
  47. package/src/tools/bash/bash.js +352 -0
  48. package/src/tools/bash/semantics.js +85 -0
  49. package/src/tools/bash/warnings.js +98 -0
  50. package/src/tools/edit/edit.js +253 -0
  51. package/src/tools/edit/multi-edit.js +155 -0
  52. package/src/tools/glob/glob.js +97 -0
  53. package/src/tools/grep/grep.js +185 -0
  54. package/src/tools/grep/rgPath.js +173 -0
  55. package/src/tools/index.js +94 -0
  56. package/src/tools/read/read.js +209 -0
  57. package/src/tools/shared/fileState.js +61 -0
  58. package/src/tools/shared/fileUtils.js +281 -0
  59. package/src/tools/shared/schemas.js +16 -0
  60. package/src/tools/types.js +21 -0
  61. package/src/tools/webbrowser/browser.js +55 -0
  62. package/src/tools/webbrowser/webbrowser.js +194 -0
  63. package/src/tools/webfetch/preapproved.js +267 -0
  64. package/src/tools/webfetch/webfetch.js +317 -0
  65. package/src/tools/websearch/websearch.js +161 -0
  66. package/src/tools/write/write.js +125 -0
  67. package/src/types/turndown.d.ts +23 -0
  68. package/src/types.js +16 -0
  69. package/src/ui/App.js +37 -0
  70. package/src/ui/InputBox.js +240 -0
  71. package/src/ui/MessageList.js +28 -0
  72. package/src/ui/Root.js +70 -0
  73. package/src/ui/StatusLine.js +41 -0
  74. package/src/ui/ToolStatus.js +11 -0
  75. package/src/ui/hooks/useChat.js +234 -0
  76. package/src/ui/hooks/usePasteHandler.js +137 -0
  77. package/src/ui/hooks/useTextBuffer.js +55 -0
  78. package/src/ui/hooks/useTokenUsage.js +30 -0
  79. package/src/ui/textBuffer.js +217 -0
  80. package/src/utils/packageRoot.js +37 -0
  81. package/src/utils/resourcePaths.js +49 -0
  82. package/src/utils/zodToJson.js +29 -0
  83. package/dist/main.js +0 -5315
  84. package/plugins/ralph-wiggum/plugin.ts +0 -275
  85. package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
  86. package/plugins/ralph-wiggum/src/goalState.ts +0 -310
  87. package/plugins/ralph-wiggum/src/stopHookRunner.ts +0 -136
  88. package/plugins/ralph-wiggum/src/verificationGate.ts +0 -252
  89. package/plugins/ralph-wiggum/test/goalState.test.ts +0 -410
  90. package/plugins/ralph-wiggum/test/verificationGate.test.ts +0 -122
  91. package/plugins/workflow-runner/src/expressions.ts +0 -371
  92. package/plugins/workflow-runner/src/index.ts +0 -194
  93. package/plugins/workflow-runner/src/loader.ts +0 -193
  94. package/plugins/workflow-runner/src/runner.ts +0 -313
  95. package/plugins/workflow-runner/src/stepExecutors/assert.ts +0 -30
  96. package/plugins/workflow-runner/src/stepExecutors/llm.ts +0 -54
  97. package/plugins/workflow-runner/src/stepExecutors/skill.ts +0 -115
  98. package/plugins/workflow-runner/src/types.ts +0 -183
  99. package/plugins/workflow-runner/test/cli.e2e.test.ts +0 -114
  100. package/plugins/workflow-runner/test/e2e.test.ts +0 -268
  101. package/plugins/workflow-runner/test/expressions.test.ts +0 -140
  102. package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +0 -27
  103. package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +0 -49
  104. package/plugins/workflow-runner/test/graceful.test.ts +0 -139
  105. package/plugins/workflow-runner/test/loader.test.ts +0 -216
  106. package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +0 -230
  107. package/plugins/workflow-runner/test/runner.test.ts +0 -511
@@ -0,0 +1,253 @@
1
+ /**
2
+ * ============================================================
3
+ * src/tools/edit.ts —— Edit 工具(精确字符串替换)
4
+ * ------------------------------------------------------------
5
+ * 对应 kakadeai 主仓库的 FileEditTool。
6
+ * prompt 文本和 Zod schema 与 kakadeai 一致。
7
+ *
8
+ * v0.2 增强:
9
+ * - 路径安全校验(null byte / UNC 路径)
10
+ * - 文件大小上限(1GB)
11
+ * - old_string 找不到时提供模糊匹配提示(最常见模型错误)
12
+ * ============================================================
13
+ */
14
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
15
+ import { existsSync } from 'node:fs';
16
+ import { dirname } from 'node:path';
17
+ import { z } from 'zod';
18
+ import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
19
+ import { toToolParameters } from '../../utils/zodToJson.js';
20
+ import { resolveToolPath, findActualString, preserveQuoteStyle, detectLineEndingsForString, applyLineEnding, countOccurrences, splitReplaceAll, } from '../shared/fileUtils.js';
21
+ import { filePathField } from '../shared/schemas.js';
22
+ import { assertFresh, recordRead } from '../shared/fileState.js';
23
+ /** 编辑文件大小上限:1GB。防止模型试图 Edit 超大文件撑爆内存。 */
24
+ const MAX_EDIT_FILE_SIZE_BYTES = 1024 * 1024 * 1024;
25
+ // ---------------- 1. Zod 输入 schema ----------------
26
+ const inputSchema = z.object({
27
+ file_path: filePathField('编辑'),
28
+ old_string: z
29
+ .string()
30
+ .describe('要替换的原文本(必须在文件中唯一,除非 replace_all=true);为空字符串且文件不存在时创建新文件'),
31
+ new_string: z.string().describe('替换为的新文本(与 old_string 必须不同)'),
32
+ replace_all: z
33
+ .boolean()
34
+ .optional()
35
+ .describe('是否替换所有出现位置(默认 false)'),
36
+ });
37
+ // ---------------- 2. JSON Schema(由 Zod 自动派生) ----------------
38
+ const parameters = toToolParameters(inputSchema);
39
+ // ---------------- 3. Description(搬自 kakadeai/tools/FileEditTool/prompt.ts) ----------------
40
+ const description = `Performs exact string replacements in files.
41
+
42
+ Usage:
43
+ - You MUST use your \`Read\` tool to read the current content of the file BEFORE calling Edit.
44
+ Memory is unreliable — if you think you "remember" how the code looks, you are probably wrong.
45
+ Re-read the relevant sections (function, module, or area you plan to change) every time.
46
+ - To create a new file, pass an empty \`old_string\` and the desired contents as \`new_string\`. When creating a new file (path does not yet exist), you may call Edit directly — no prior Read is needed.
47
+ - If the file already exists and you pass an empty \`old_string\`, the edit is rejected with a clear error. To replace the entire content of an existing file, use the Write tool instead.
48
+ - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
49
+ - The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
50
+ - Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
51
+ - Preserve exact indentation (tabs/spaces).`;
52
+ // ---------------- 4. call() ----------------
53
+ async function call(input) {
54
+ const { old_string, new_string } = input;
55
+ const replaceAll = input.replace_all ?? false;
56
+ // 5a. 路径规范化 + 安全校验(含 null byte / UNC / 设备文件拦截 / ~ 展开)
57
+ const pathResult = resolveToolPath(input.file_path);
58
+ if (!pathResult.ok)
59
+ return pathResult;
60
+ const filePath = pathResult.resolvedPath;
61
+ // 5a-bis. Pre-read guard:文件已存在则必须先 Read(创建新文件场景下 assertFresh 返回 ok:true)
62
+ const freshness = assertFresh(filePath);
63
+ if (!freshness.ok) {
64
+ return { ok: false, error: freshness.error };
65
+ }
66
+ if (old_string === new_string) {
67
+ return { ok: false, error: 'old_string 与 new_string 完全相同,没有可改的内容。' };
68
+ }
69
+ // 5b. 创建新文件路径:old_string === '' 且文件不存在
70
+ if (old_string === '' && !existsSync(filePath)) {
71
+ try {
72
+ await mkdir(dirname(filePath), { recursive: true });
73
+ await writeFile(filePath, new_string, 'utf8');
74
+ recordRead(filePath);
75
+ return {
76
+ ok: true,
77
+ content: `已创建新文件:${filePath}(${new_string.length} 字符)`,
78
+ };
79
+ }
80
+ catch (e) {
81
+ return { ok: false, error: `创建文件失败:${e.message}` };
82
+ }
83
+ }
84
+ // 5c. 既有文件:读 → 改 → 写
85
+ if (!existsSync(filePath)) {
86
+ return {
87
+ ok: false,
88
+ error: `文件不存在:${filePath}\n(要创建新文件,请把 old_string 设为空字符串)`,
89
+ };
90
+ }
91
+ let original;
92
+ try {
93
+ original = await readFile(filePath, 'utf8');
94
+ }
95
+ catch (e) {
96
+ return { ok: false, error: `读取失败:${e.message}` };
97
+ }
98
+ // 5d-1. 记录原文件行尾符风格(写入时保持一致)
99
+ const originalLineEnding = detectLineEndingsForString(original);
100
+ // 5d. 文件大小上限检查
101
+ const fileSize = Buffer.byteLength(original, 'utf8');
102
+ if (fileSize > MAX_EDIT_FILE_SIZE_BYTES) {
103
+ return {
104
+ ok: false,
105
+ error: `文件过大(${fileSize} 字节 > ${MAX_EDIT_FILE_SIZE_BYTES} 字节 ≈ 1GB)。Edit 不适合操作超大文件,请用 Write 工具。`,
106
+ };
107
+ }
108
+ // 唯性检查
109
+ if (old_string === '') {
110
+ return {
111
+ ok: false,
112
+ error: 'old_string 为空但文件已存在 —— 这通常是错误的。要替换全文请用 Write 工具。',
113
+ };
114
+ }
115
+ // 5e. 引号风格归一化:处理模型输出的直引号 vs 文件中的弯引号差异
116
+ let searchTarget = old_string;
117
+ let processedNewString = new_string;
118
+ const actualOld = findActualString(original, old_string);
119
+ if (actualOld !== null && actualOld !== old_string) {
120
+ searchTarget = actualOld;
121
+ processedNewString = preserveQuoteStyle(old_string, actualOld, new_string);
122
+ }
123
+ // splitCount = 出现次数
124
+ const occurrences = countOccurrences(original, searchTarget);
125
+ if (occurrences === 0) {
126
+ // 模糊匹配提示:找找有没有"接近"的内容
127
+ const hint = findFuzzyMatchHint(original, searchTarget);
128
+ const extraMsg = hint ? `\n\n💡 提示:${hint}` : '';
129
+ return {
130
+ ok: false,
131
+ error: `在 ${filePath} 中找不到 old_string。请先用 Read 工具核对内容(注意空格/缩进/换行)。${extraMsg}`,
132
+ };
133
+ }
134
+ if (occurrences > 1 && !replaceAll) {
135
+ return {
136
+ ok: false,
137
+ error: `old_string 在文件中出现了 ${occurrences} 次,不唯一。\n请扩大 old_string 包含更多上下文,或显式传 replace_all=true。`,
138
+ };
139
+ }
140
+ const replaced = replaceAll
141
+ ? splitReplaceAll(original, searchTarget, processedNewString)
142
+ : original.replace(searchTarget, processedNewString); // 只替换第一处(已确认 occurrences === 1)
143
+ // 5f. 保持原文件行尾符风格
144
+ const normalizedReplaced = applyLineEnding(replaced, originalLineEnding);
145
+ try {
146
+ await writeFile(filePath, normalizedReplaced, 'utf8');
147
+ recordRead(filePath);
148
+ }
149
+ catch (e) {
150
+ return { ok: false, error: `写入失败:${e.message}` };
151
+ }
152
+ // 输出:报告改了几处 + 行变化
153
+ const linesBefore = original.split('\n').length;
154
+ const linesAfter = replaced.split('\n').length;
155
+ return {
156
+ ok: true,
157
+ content: `已编辑 ${filePath}\n` +
158
+ `替换次数:${replaceAll ? occurrences : 1}\n` +
159
+ `行数:${linesBefore} → ${linesAfter}`,
160
+ };
161
+ }
162
+ // ---------------- 5. 模糊匹配提示 ----------------
163
+ /**
164
+ * 当 old_string 精确匹配失败时,尝试在文件内容中找到相似行。
165
+ * 常见场景:模型传入的 old_string 缩进差一个 tab、末尾多一个空格等。
166
+ *
167
+ * 策略:
168
+ * 1. 按 \n 分割成行
169
+ * 2. 对每行计算与 old_string 的字符重叠率(最长公共子序列长度 / max(len))
170
+ * 3. 返回重叠率最高的前 N 行作为提示
171
+ */
172
+ function findFuzzyMatchHint(fileContent, target) {
173
+ const MIN_OVERLAP_RATIO = 0.5; // 至少 50% 字符重叠才认为"接近"
174
+ const MAX_HINTS = 3; // 最多提示 3 个候选
175
+ const targetTrimmed = target.trim();
176
+ if (targetTrimmed.length < 5)
177
+ return null; // 太短的 target 不做模糊匹配
178
+ const lines = fileContent.split('\n');
179
+ const candidates = [];
180
+ for (let i = 0; i < lines.length; i++) {
181
+ const line = lines[i];
182
+ const trimmedLine = line.trim();
183
+ // 跳过空行和太短的行
184
+ if (trimmedLine.length < 5)
185
+ continue;
186
+ const ratio = lcsRatio(targetTrimmed, trimmedLine);
187
+ if (ratio >= MIN_OVERLAP_RATIO) {
188
+ candidates.push({ lineNum: i + 1, text: trimmedLine, ratio });
189
+ }
190
+ }
191
+ // 也检查跨行情况:如果 target 包含换行,尝试匹配连续多行
192
+ if (target.includes('\n') && candidates.length < MAX_HINTS) {
193
+ const targetLines = target.split('\n').filter((l) => l.trim().length > 0);
194
+ if (targetLines.length >= 2) {
195
+ for (let start = 0; start <= lines.length - targetLines.length; start++) {
196
+ const window = lines.slice(start, start + targetLines.length).map((l) => l.trim()).join('\n');
197
+ const ratio = lcsRatio(target.trim(), window);
198
+ if (ratio >= MIN_OVERLAP_RATIO) {
199
+ candidates.push({ lineNum: start + 1, text: window.slice(0, 80) + (window.length > 80 ? '...' : ''), ratio });
200
+ }
201
+ }
202
+ }
203
+ }
204
+ if (candidates.length === 0)
205
+ return null;
206
+ // 按重叠率降序排列,取前 N 个
207
+ candidates.sort((a, b) => b.ratio - a.ratio);
208
+ const top = candidates.slice(0, MAX_HINTS);
209
+ const hints = top.map((c) => `第 ${c.lineNum} 行附近有相似内容(相似度 ${(c.ratio * 100).toFixed(0)}%):\n "${c.text.slice(0, 100)}${c.text.length > 100 ? '…' : ''}"`);
210
+ return `可能你想要修改以下位置之一?\n ${hints.join('\n ')}`;
211
+ }
212
+ /**
213
+ * 计算两个字符串的最长公共子序列(LCS)长度比。
214
+ * 使用动态规划,时间复杂度 O(m*n),但对短字符串足够快。
215
+ * 返回值范围 [0, 1],1 表示完全相同。
216
+ */
217
+ function lcsRatio(a, b) {
218
+ const m = a.length;
219
+ const n = b.length;
220
+ // 对太长的字符串截断处理(防止性能问题)
221
+ const MAX_LEN = 500;
222
+ const aa = m > MAX_LEN ? a.slice(0, MAX_LEN) : a;
223
+ const bb = n > MAX_LEN ? b.slice(0, MAX_LEN) : b;
224
+ const mm = aa.length;
225
+ const nn = bb.length;
226
+ // 用一维 DP 优化空间
227
+ const dp = new Array(nn + 1).fill(0);
228
+ for (let i = 1; i <= mm; i++) {
229
+ let prev = 0;
230
+ for (let j = 1; j <= nn; j++) {
231
+ const temp = dp[j];
232
+ if (aa[i - 1] === bb[j - 1]) {
233
+ dp[j] = prev + 1;
234
+ }
235
+ else {
236
+ dp[j] = Math.max(dp[j], dp[j - 1]);
237
+ }
238
+ prev = temp;
239
+ }
240
+ }
241
+ return dp[nn] / Math.max(mm, nn);
242
+ }
243
+ // ---------------- 6. 导出 ----------------
244
+ export const editTool = {
245
+ name: 'Edit',
246
+ description,
247
+ inputSchema,
248
+ parameters,
249
+ isReadOnly: false,
250
+ isConcurrencySafe: false,
251
+ maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
252
+ call,
253
+ };
@@ -0,0 +1,155 @@
1
+ /**
2
+ * ============================================================
3
+ * src/tools/edit/multi-edit.ts —— MultiEdit 工具(原子化多处替换)
4
+ * ------------------------------------------------------------
5
+ * 对应 kakadeai 主仓库的 FileEditTool.getPatchForEdits 设计思路,
6
+ * 但比上游更严格:**all-or-nothing**。
7
+ *
8
+ * 与 Edit 的关键差异:
9
+ * 1. 一次处理多条 edit,按顺序在内存中应用,全部成功才写盘
10
+ * 2. 任何一条匹配失败 / 多义匹配 → 全部回退(磁盘不动一字)
11
+ * 3. 依赖检测:edits[i].old_string 若是 edits[j<i].new_string 的子串,
12
+ * 会命中前序产物,直接拒绝(要求调用方重排序或合并)
13
+ * 4. 不支持 "old_string='' 创建文件"(那是 Edit 的单文件场景,多 edit 无意义)
14
+ * ============================================================
15
+ */
16
+ import { readFile, writeFile } from 'node:fs/promises';
17
+ import { existsSync } from 'node:fs';
18
+ import { z } from 'zod';
19
+ import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
20
+ import { toToolParameters } from '../../utils/zodToJson.js';
21
+ import { resolveToolPath, detectFileLineEndings, applyLineEnding, findActualString, preserveQuoteStyle, countOccurrences, splitReplaceAll, } from '../shared/fileUtils.js';
22
+ import { filePathField } from '../shared/schemas.js';
23
+ import { assertFresh, recordRead } from '../shared/fileState.js';
24
+ // ---------------- 1. Zod 输入 schema ----------------
25
+ const editItemSchema = z.object({
26
+ old_string: z.string().min(1).describe('要替换的原文本(不允许为空 —— 创建新文件请用 Edit 工具)'),
27
+ new_string: z.string().describe('替换为的新文本'),
28
+ replace_all: z
29
+ .boolean()
30
+ .optional()
31
+ .describe('是否替换所有出现位置(默认 false,要求 old_string 在当前内容中唯一)'),
32
+ });
33
+ const inputSchema = z.object({
34
+ file_path: filePathField('编辑'),
35
+ edits: z
36
+ .array(editItemSchema)
37
+ .min(1)
38
+ .max(50)
39
+ .describe('按顺序应用的 edit 列表(1-50 条),原子化执行:全部成功才落盘'),
40
+ });
41
+ // ---------------- 2. JSON Schema ----------------
42
+ const parameters = toToolParameters(inputSchema);
43
+ // ---------------- 3. Description ----------------
44
+ const description = `Performs multiple exact string replacements in a single file, applied atomically (all-or-nothing).
45
+
46
+ Usage:
47
+ - You MUST use your \`Read\` tool to read the current content of the file BEFORE calling MultiEdit.
48
+ - Provide a list of \`edits\`, each with \`old_string\`, \`new_string\`, and optional \`replace_all\`.
49
+ - All edits are applied sequentially in memory; if ANY edit fails (string not found, ambiguous match, or dependency conflict), the file on disk is UNTOUCHED.
50
+ - Order matters: later edits operate on the result of earlier edits. If a later edit's \`old_string\` is a substring of an earlier edit's \`new_string\`, MultiEdit refuses (reorder or merge instead).
51
+ - Each \`old_string\` must be unique in the current content (after prior edits) unless \`replace_all=true\`.
52
+ - Empty \`old_string\` is NOT allowed in MultiEdit. To create a new file, use the \`Edit\` tool with a single empty-old_string call.
53
+ - Preserve exact indentation (tabs/spaces).`;
54
+ // ---------------- 4. call() ----------------
55
+ async function call(input) {
56
+ // 4a. 路径规范化 + 安全校验
57
+ const pathResult = resolveToolPath(input.file_path);
58
+ if (!pathResult.ok)
59
+ return pathResult;
60
+ const filePath = pathResult.resolvedPath;
61
+ // 4b. Pre-read guard
62
+ const freshness = assertFresh(filePath);
63
+ if (!freshness.ok) {
64
+ return { ok: false, error: freshness.error };
65
+ }
66
+ // 4c. 文件必须存在(MultiEdit 不做"创建新文件"语义)
67
+ if (!existsSync(filePath)) {
68
+ return {
69
+ ok: false,
70
+ error: `文件不存在:${filePath}\n(MultiEdit 不支持创建新文件,请改用 Edit 工具)`,
71
+ };
72
+ }
73
+ // 4d. 依赖检测:edits[i].old_string 若是 edits[j<i].new_string 的子串,
74
+ // 后续 edit 会命中前序产物,直接拒绝。
75
+ const edits = input.edits;
76
+ for (let i = 0; i < edits.length; i++) {
77
+ for (let j = 0; j < i; j++) {
78
+ if (edits[j].new_string.includes(edits[i].old_string)) {
79
+ return {
80
+ ok: false,
81
+ error: `edits[${i}].old_string 是 edits[${j}].new_string 的子串,会导致后续 edit 命中前序产物,请重新排序或合并`,
82
+ };
83
+ }
84
+ }
85
+ }
86
+ // 4e. 读原文
87
+ let originalContent;
88
+ try {
89
+ originalContent = await readFile(filePath, 'utf8');
90
+ }
91
+ catch (e) {
92
+ return { ok: false, error: `读取失败:${e.message}` };
93
+ }
94
+ // 4f. 在内存中顺序应用所有 edit(任一失败立即返回,磁盘不动)
95
+ let currentContent = originalContent;
96
+ for (let i = 0; i < edits.length; i++) {
97
+ const edit = edits[i];
98
+ const replaceAll = edit.replace_all ?? false;
99
+ // 引号风格归一化
100
+ let searchTarget = edit.old_string;
101
+ let processedNewString = edit.new_string;
102
+ const actualOld = findActualString(currentContent, edit.old_string);
103
+ if (actualOld === null) {
104
+ return {
105
+ ok: false,
106
+ error: `edits[${i}] 未匹配到 old_string(在已应用前序 ${i} 处修改后的内容中找不到)。请先用 Read 工具核对当前内容(注意空格/缩进/换行),并检查 edit 顺序。`,
107
+ };
108
+ }
109
+ if (actualOld !== edit.old_string) {
110
+ searchTarget = actualOld;
111
+ processedNewString = preserveQuoteStyle(edit.old_string, actualOld, edit.new_string);
112
+ }
113
+ const occurrences = countOccurrences(currentContent, searchTarget);
114
+ if (occurrences === 0) {
115
+ return {
116
+ ok: false,
117
+ error: `edits[${i}] 未匹配到 old_string(已应用前序 ${i} 处修改后内容中出现 0 次)。`,
118
+ };
119
+ }
120
+ if (occurrences > 1 && !replaceAll) {
121
+ return {
122
+ ok: false,
123
+ error: `edits[${i}].old_string 在当前内容中出现 ${occurrences} 次,不唯一。请扩大 old_string 包含更多上下文,或显式传 replace_all=true。`,
124
+ };
125
+ }
126
+ currentContent = replaceAll
127
+ ? splitReplaceAll(currentContent, searchTarget, processedNewString)
128
+ : currentContent.replace(searchTarget, processedNewString);
129
+ }
130
+ // 4g. 全部成功 → 保持原文件行尾符风格,写盘
131
+ const lineEnding = await detectFileLineEndings(filePath);
132
+ const finalContent = applyLineEnding(currentContent, lineEnding);
133
+ try {
134
+ await writeFile(filePath, finalContent, 'utf8');
135
+ recordRead(filePath);
136
+ }
137
+ catch (e) {
138
+ return { ok: false, error: `写入失败:${e.message}` };
139
+ }
140
+ return {
141
+ ok: true,
142
+ content: `已对 ${filePath} 应用 ${edits.length} 处修改`,
143
+ };
144
+ }
145
+ // ---------------- 5. 导出 ----------------
146
+ export const multiEditTool = {
147
+ name: 'MultiEdit',
148
+ description,
149
+ inputSchema,
150
+ parameters,
151
+ isReadOnly: false,
152
+ isConcurrencySafe: false,
153
+ maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
154
+ call,
155
+ };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * ============================================================
3
+ * src/tools/glob.ts —— Glob 工具(按文件名模式匹配)
4
+ * ------------------------------------------------------------
5
+ * 对应 kakadeai 主仓库的 GlobTool。
6
+ * 实现用 fast-glob(npm 包),结果按 mtime 升序排列(旧文件在前)。
7
+ * 如果结果过多,从尾部截断(保留旧文件,丢弃新文件)。
8
+ * ============================================================
9
+ */
10
+ import { stat } from 'node:fs/promises';
11
+ import { isAbsolute, resolve } from 'node:path';
12
+ import fg from 'fast-glob';
13
+ import { z } from 'zod';
14
+ import { getWorkingDir } from '../../bootstrap/workingDir.js';
15
+ import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
16
+ import { toToolParameters } from '../../utils/zodToJson.js';
17
+ // ---------------- 1. Zod 输入 schema ----------------
18
+ const inputSchema = z.object({
19
+ pattern: z
20
+ .string()
21
+ .min(1)
22
+ .describe('glob 模式,例如 "**/*.ts" 或 "src/components/**/*.tsx"'),
23
+ path: z
24
+ .string()
25
+ .optional()
26
+ .describe('搜索的根目录(默认当前工作目录);省略时不要传 "undefined" 字符串'),
27
+ });
28
+ // ---------------- 2. JSON Schema(由 Zod 自动派生) ----------------
29
+ const parameters = toToolParameters(inputSchema);
30
+ // ---------------- 3. Description(搬自 kakadeai/tools/GlobTool/prompt.ts 的 DESCRIPTION) ----------------
31
+ const description = `- Fast file pattern matching tool that works with any codebase size
32
+ - Supports glob patterns like "**/*.js" or "src/**/*.ts"
33
+ - Returns matching file paths sorted by modification time (oldest first)
34
+ - Use this tool when you need to find files by name patterns
35
+ - When you need to do an open ended search that may require multiple rounds, prefer the Grep tool for content search`;
36
+ // ---------------- 4. call() ----------------
37
+ async function call(input) {
38
+ const cwd = input.path ? resolve(input.path) : getWorkingDir();
39
+ // 跨平台:fast-glob 强制要求 pattern 用正斜杠。
40
+ // Windows 用户/模型可能传 "src\**\*.ts",这里统一替换。
41
+ // (cwd 用原生路径没问题;fast-glob 内部会处理)
42
+ const pattern = input.pattern.replace(/\\/g, '/');
43
+ // fast-glob 自带 ignore 一部分(node_modules 之类还是要显式排除)
44
+ let matches;
45
+ try {
46
+ matches = await fg(pattern, {
47
+ cwd,
48
+ dot: true,
49
+ onlyFiles: true,
50
+ followSymbolicLinks: false,
51
+ // 默认排除一些噪音目录;如果用户的 pattern 显式指了它们,这里也不会盖
52
+ ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'],
53
+ absolute: false,
54
+ });
55
+ }
56
+ catch (e) {
57
+ return { ok: false, error: `glob 失败:${e.message}` };
58
+ }
59
+ if (matches.length === 0) {
60
+ return { ok: true, content: `(no files matched pattern "${pattern}" in ${cwd})` };
61
+ }
62
+ // 按 mtime 升序排:旧文件在前(与 Grep 的新在前形成互补锁定模板)
63
+ const withMtime = await Promise.all(matches.map(async (rel) => {
64
+ const abs = isAbsolute(rel) ? rel : resolve(cwd, rel);
65
+ try {
66
+ const st = await stat(abs);
67
+ return { path: rel, mtime: st.mtimeMs };
68
+ }
69
+ catch {
70
+ return { path: rel, mtime: 0 };
71
+ }
72
+ }));
73
+ withMtime.sort((a, b) => a.mtime - b.mtime);
74
+ // 输出:每行一个相对路径
75
+ let content = withMtime.map((m) => m.path).join('\n');
76
+ // 超条数截断:保留旧文件(头部),丢弃新文件(尾部)
77
+ if (content.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
78
+ content =
79
+ content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS) +
80
+ `\n\n... (共 ${withMtime.length} 个文件,输出超过 ${DEFAULT_MAX_RESULT_SIZE_CHARS} 字符,已截断尾部新文件)`;
81
+ }
82
+ return {
83
+ ok: true,
84
+ content: `${content}\n\n(共 ${withMtime.length} 个文件,按修改时间升序/旧在前)`,
85
+ };
86
+ }
87
+ // ---------------- 5. 导出 ----------------
88
+ export const globTool = {
89
+ name: 'Glob',
90
+ description,
91
+ inputSchema,
92
+ parameters,
93
+ isReadOnly: true,
94
+ isConcurrencySafe: true,
95
+ maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
96
+ call,
97
+ };