mouaif 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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,1150 @@
1
+ 'use strict';
2
+
3
+ // Native file tools — `read_file`, `list_files`, `search_files`, `write_file`,
4
+ // `edit_file`.
5
+ //
6
+ // Implements the "Native file tools" feature: read / list / search / write
7
+ // inside the project directory, with the same authorization gate and
8
+ // path-safety rules as the rest of the tool surface (decisions §16, §17).
9
+ //
10
+ // `read_file` also opens images: a path whose extension is a picture
11
+ // (`.png`, `.jpg`, `.gif`, `.webp`, `.bmp`, `.ico`) comes back as an
12
+ // `image` content block instead of a decoded text body, so the model
13
+ // actually sees the picture and the chat card renders a thumbnail. The
14
+ // block rides the same `toolResultImageParts` path MCP image results use
15
+ // (src/ai-stream.js). See docs/features/read-file-images.md.
16
+ //
17
+ // Public surface:
18
+ // SPECS : { 'read_file', 'list_files', 'search_files', 'write_file', 'edit_file' }
19
+ // each value is an OpenAI-compatible function spec
20
+ // runFileTool(name, opts) -> Promise<{ ok, content, result }>
21
+ // resolveSandbox(projectDir) -> string (re-exported from shell.js for parity)
22
+ //
23
+ // Each runner is self-contained: path safety, size caps, and the textual
24
+ // response shape are all enforced here. The AI client in src/ai.js maps
25
+ // any tool_call whose name matches one of the five over to runFileTool().
26
+ //
27
+ // Path safety: every path the model supplies is normalized to a
28
+ // POSIX-relative path under the project root, then resolved back to an
29
+ // absolute path with realpath. Anything that escapes the root (`. .`,
30
+ // absolute path, or a symlink that points outside) throws EOUTSIDE_PROJECT
31
+ // and the call becomes a typed EOUTSIDE_PROJECT tool_result — the same
32
+ // error shape the shell tool already uses.
33
+ //
34
+ // Defaults:
35
+ // fileReadMaxLines = 10000 (whole-file reads cap by line count; use startLine/endLine for larger files)
36
+ // fileReadDefaultLines = 2000 (default window when the model asks for a slice)
37
+ // fileReadMaxImageBytes = 4 MB (cap on an image attached to the result)
38
+ // fileListMaxEntries = 1000 (cap on list_files result rows)
39
+ // fileSearchMaxMatches = 200 (cap on search_files matches; in src/tools/searchEngine.js)
40
+ // fileSearchMaxBytes = 2 MB (cap on matched line text returned by one search).
41
+ // A match is redacted per line, so `search_files`
42
+ // refuses `(?s)` rather than return a result it
43
+ // cannot redact. See src/tools/searchEngine.js.
44
+ // fileWriteMaxBytes = 1 MB (cap on a single write_file call)
45
+
46
+ const fs = require('fs');
47
+ const fsp = require('fs/promises');
48
+ const path = require('path');
49
+ const hideFileContent = require('../hideFileContent.js');
50
+ // The search engine owns its own skip-dirs / text-extension list and its own
51
+ // default caps; importing them keeps list_files and the engine from drifting
52
+ // apart while the two live in different files.
53
+ const searchEngine = require('./searchEngine.js');
54
+
55
+ // Image extension knowledge (and the ext -> MIME map) lives in src/files.js
56
+ // — the same list the file editor previews with — so `read_file` and the
57
+ // editor never disagree about what counts as an image.
58
+ const { isImageExt, mimeForExt } = require('../files.js');
59
+
60
+ // ---- Constants ---------------------------------------------------------
61
+
62
+ const DEFAULT_READ_MAX_LINES = 10000;
63
+ const DEFAULT_READ_LINES = 2000;
64
+ const DEFAULT_READ_MAX_IMAGE_BYTES = 4 * 1024 * 1024; // 4 MiB
65
+ const DEFAULT_LIST_MAX_ENTRIES = 1000;
66
+ const DEFAULT_WRITE_MAX_BYTES = 1024 * 1024;
67
+
68
+ const MAX_TIMEOUT_MS = 60_000; // hard ceiling per call (defensive)
69
+
70
+ // The search engine owns the search caps and its skip-dir list. Re-exported
71
+ // from here because this module used to define them and tests reference the
72
+ // names through this module's exports.
73
+ const DEFAULT_SEARCH_MAX_MATCHES = searchEngine.DEFAULT_MAX_MATCHES;
74
+ const DEFAULT_SEARCH_MAX_BYTES = searchEngine.DEFAULT_MAX_BYTES;
75
+ const SKIP_DIRS = new Set(searchEngine.SKIP_DIRS);
76
+
77
+ // Extension allowlist for list_files. search_files decides for itself (the
78
+ // engine compares content, and the walk searches extensionless files).
79
+ const TEXT_EXTS = new Set(searchEngine.TEXT_EXTS);
80
+
81
+ // ---- Errors ------------------------------------------------------------
82
+
83
+ // Shared typed-error helper; single definition lives in src/util.js.
84
+ const { err } = require('../util.js');
85
+
86
+ // ---- Path safety -------------------------------------------------------
87
+
88
+ // Resolve a project root, refusing anything that is missing or not a
89
+ // directory. Returns the realpath so symlinks point to the real on-disk
90
+ // root; the rest of the path checks are then anchored on this string.
91
+ function resolveSandbox(projectDir) {
92
+ if (!projectDir || typeof projectDir !== 'string') {
93
+ throw err('EBADINPUT', 'projectDir is required');
94
+ }
95
+ let real;
96
+ try { real = fs.realpathSync(projectDir); }
97
+ catch { throw err('ENOENT', 'project directory not found'); }
98
+ let st;
99
+ try { st = fs.statSync(real); }
100
+ catch { throw err('ENOENT', 'project directory not found'); }
101
+ if (!st.isDirectory()) throw err('ENOTDIR', 'projectDir is not a directory');
102
+ return real;
103
+ }
104
+
105
+ // Normalize a user- or model-supplied path to a POSIX-relative path
106
+ // under the resolved project root. Throws EOUTSIDE_PROJECT on escape.
107
+ function toRelPath(root, p) {
108
+ if (typeof p !== 'string' || !p.trim()) throw err('EBADINPUT', 'path is required');
109
+ const abs = path.isAbsolute(p) ? path.resolve(p) : path.resolve(root, p);
110
+ const rel = path.relative(root, abs);
111
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
112
+ throw err('EOUTSIDE_PROJECT', 'Path escapes the project root', { path: p });
113
+ }
114
+ return rel.split(path.sep).join('/');
115
+ }
116
+
117
+ // Resolve a stored relPath back to an absolute path, with symlink check.
118
+ // For files that exist on disk, realpath is used so a symlink that
119
+ // points outside the project root is rejected. For files that don't
120
+ // exist yet (write_file, mkdir -p), we walk up to the first existing
121
+ // ancestor, realpath that, and verify the join is still inside the
122
+ // root. The path safety contract is "no escape", not "file exists",
123
+ // so the two cases share the same final check.
124
+ function toAbsInside(root, rel) {
125
+ const abs = path.resolve(root, rel);
126
+ // Fast path: file exists, realpath it, verify containment.
127
+ let real;
128
+ try { real = fs.realpathSync(abs); }
129
+ catch { real = null; }
130
+ if (real) {
131
+ const rel2 = path.relative(root, real);
132
+ if (!rel2 || rel2.startsWith('..') || path.isAbsolute(rel2)) {
133
+ throw err('EOUTSIDE_PROJECT', 'Path escapes the project root', { path: rel });
134
+ }
135
+ return real;
136
+ }
137
+ // Slow path: file (or a parent) doesn't exist. Walk up until we
138
+ // find an ancestor that does, realpath that, then re-join. Each
139
+ // step is still subject to the same containment check. `suffix`
140
+ // accumulates the parts we peeled off on the way up, joined back
141
+ // on the way down.
142
+ let probe = abs;
143
+ let suffix = '';
144
+ while (probe !== root && probe !== path.dirname(probe)) {
145
+ let probeReal;
146
+ try { probeReal = fs.realpathSync(probe); }
147
+ catch {
148
+ // Append the segment we are about to step over to `suffix`,
149
+ // then step the probe up to its parent. Order matters: the
150
+ // basename we are peeling is `path.basename(probe)` BEFORE we
151
+ // reassign `probe` to its dirname.
152
+ const peeled = path.basename(probe);
153
+ probe = path.dirname(probe);
154
+ suffix = suffix ? (peeled + path.sep + suffix) : peeled;
155
+ continue;
156
+ }
157
+ if (suffix) probeReal = path.join(probeReal, suffix);
158
+ const rel2 = path.relative(root, probeReal);
159
+ if (!rel2 || rel2.startsWith('..') || path.isAbsolute(rel2)) {
160
+ throw err('EOUTSIDE_PROJECT', 'Path escapes the project root', { path: rel });
161
+ }
162
+ return probeReal;
163
+ }
164
+ // If we walked all the way to the root, the file is "inside" (even
165
+ // though the parent doesn't exist yet, e.g. mkdir -p). Verify the
166
+ // original `abs` is at least lexically inside the root.
167
+ const rel2 = path.relative(root, abs);
168
+ if (!rel2 || rel2.startsWith('..') || path.isAbsolute(rel2)) {
169
+ throw err('EOUTSIDE_PROJECT', 'Path escapes the project root', { path: rel });
170
+ }
171
+ return abs;
172
+ }
173
+
174
+ // ---- read_file ---------------------------------------------------------
175
+
176
+ // Read a file. Optional `startLine` / `endLine` (1-indexed, inclusive) pin
177
+ // a window. Whole-file reads over the line cap are refused with ETOOL_CAP.
178
+ //
179
+ // Images are the exception: a picture extension (`.png`, `.jpg`, `.jpeg`,
180
+ // `.gif`, `.webp`, `.bmp`, `.ico`) comes back as an `image` content block
181
+ // instead of decoded text, so the model gets the pixels and the chat card
182
+ // renders a thumbnail. `.svg` stays text — its markup is what a model can
183
+ // use, and the extension is on the text allowlist.
184
+ async function runReadFile(opts) {
185
+ const { projectDir, args, settings } = opts;
186
+ const root = resolveSandbox(projectDir);
187
+ const rel = toRelPath(root, args && args.path);
188
+ const abs = toAbsInside(root, rel);
189
+
190
+ const st = await fsp.stat(abs);
191
+ if (!st.isFile()) throw err('ENOTFILE', 'Not a file: ' + rel);
192
+ const ext = path.extname(abs).toLowerCase();
193
+ if (isImageExt(ext) && !TEXT_EXTS.has(ext)) {
194
+ return await readImageFile(rel, abs, st, settings);
195
+ }
196
+
197
+ // The cap only applies to whole-file reads. A bounded slice always
198
+ // succeeds, no matter how large the file is.
199
+ const cap = (settings && settings.fileReadMaxLines) || DEFAULT_READ_MAX_LINES;
200
+ const startLine = Number.isInteger(args && args.startLine) ? args.startLine : null;
201
+ const endLine = Number.isInteger(args && args.endLine) ? args.endLine : null;
202
+ const isSlice = startLine != null && endLine != null && endLine >= startLine;
203
+
204
+ const raw = await fsp.readFile(abs, 'utf8');
205
+ const totalLines = raw ? raw.split('\n').length : 0;
206
+ // Project-level redaction: any 1-indexed line the user marked hidden in
207
+ // project settings is replaced with the REDACT_MARKER before the model
208
+ // sees the body. Applied to both whole-file reads and slices so a hidden
209
+ // range cannot leak through a `startLine`/`endLine` window. Line numbers
210
+ // and the total count stay identical to the on-disk file.
211
+ if (!isSlice) {
212
+ if (totalLines > cap) {
213
+ throw err('ETOOL_CAP', 'file has ' + totalLines + ' lines, exceeds cap ' + cap + ' (use startLine/endLine)', { lines: totalLines, cap });
214
+ }
215
+ const out = hideFileContent.redactText(projectDir, rel, raw);
216
+ return {
217
+ relPath: rel,
218
+ startLine: 1,
219
+ endLine: totalLines,
220
+ body: out.text,
221
+ truncated: false,
222
+ redacted: out.redacted,
223
+ redactedLines: out.hiddenLines
224
+ };
225
+ }
226
+
227
+ // Slice: split on \n, keep the inclusive range. If the range extends
228
+ // past the end, the tail is returned. Line numbers in the response
229
+ // header stay 1-indexed for human readability.
230
+ const lines = raw.split('\n');
231
+ const a = Math.max(1, startLine);
232
+ const b = Math.min(totalLines, endLine);
233
+ const slice = lines.slice(a - 1, b).join('\n');
234
+ const sliceOut = hideFileContent.redactText(projectDir, rel, slice, a);
235
+ return {
236
+ relPath: rel,
237
+ startLine: a,
238
+ endLine: b,
239
+ totalLines,
240
+ body: sliceOut.text,
241
+ truncated: b < endLine,
242
+ redacted: sliceOut.redacted,
243
+ redactedLines: sliceOut.hiddenLines
244
+ };
245
+ }
246
+
247
+ // ---- read_file: images -------------------------------------------------
248
+
249
+ // readImageFile(rel, abs, st, settings) -> image result
250
+ //
251
+ // Reads a picture as base64 and returns it in the `content` array shape the
252
+ // MCP image path already uses (`[{ type: 'image', data, mimeType }]`), so
253
+ // src/ai-stream.js forwards it to a vision model as a real image part and the
254
+ // chat card can render it. The bytes are never decoded to text: a model
255
+ // handed 400 KB of base64 as a `tool` message learns nothing, and the text
256
+ // body would blow the tool-feedback budget.
257
+ //
258
+ // Cap: `fileReadMaxImageBytes` (default 4 MB). Bigger pictures are refused
259
+ // with ETOOL_CAP — the model can downscale with the shell tool and read
260
+ // again, and the transcript does not grow by tens of megabytes.
261
+ async function readImageFile(rel, abs, st, settings) {
262
+ const capBytes = (settings && Number.isInteger(settings.fileReadMaxImageBytes) && settings.fileReadMaxImageBytes > 0)
263
+ ? settings.fileReadMaxImageBytes
264
+ : DEFAULT_READ_MAX_IMAGE_BYTES;
265
+ if (st.size > capBytes) {
266
+ throw err('ETOOL_CAP', 'image is ' + formatBytes(st.size) + ', exceeds cap ' + formatBytes(capBytes)
267
+ + ' (downscale it — e.g. with the shell tool — then read it again)', { size: st.size, cap: capBytes });
268
+ }
269
+ let buf;
270
+ try { buf = await fsp.readFile(abs); }
271
+ catch (e) {
272
+ if (e.code === 'EACCES') throw err('EACCES', e.message, { path: abs });
273
+ throw e;
274
+ }
275
+ const mime = mimeForExt(path.extname(abs).toLowerCase());
276
+ return {
277
+ relPath: rel,
278
+ kind: 'image',
279
+ mimeType: mime,
280
+ bytes: buf.length,
281
+ // The model-facing summary. The pixels ride in `content` below and reach
282
+ // the model as a vision message part attached after the tool result.
283
+ note: 'The picture is attached to this tool result as an image part.',
284
+ content: [{ type: 'image', data: buf.toString('base64'), mimeType: mime }]
285
+ };
286
+ }
287
+
288
+ // formatBytes(n) -> "12 KB" / "1.4 MB" (approximate, for headers only).
289
+ function formatBytes(n) {
290
+ if (!Number.isFinite(n) || n < 0) return String(n);
291
+ if (n < 1024) return n + ' B';
292
+ if (n < 1024 * 1024) return Math.round(n / 1024) + ' KB';
293
+ return (Math.round((n / (1024 * 1024)) * 10) / 10) + ' MB';
294
+ }
295
+
296
+ function formatReadFileResult(r, structure) {
297
+ if (structure === 'json') return JSON.stringify(r);
298
+ if (r && r.kind === 'image') {
299
+ return '# File: ' + r.relPath
300
+ + '\n# Kind: image (' + r.mimeType + ', ' + r.bytes + ' bytes)'
301
+ + '\n# ' + (r.note || 'The picture is attached to this tool result as an image part.');
302
+ }
303
+ const header = '# File: ' + r.relPath
304
+ + '\n# Lines: ' + r.startLine + '-' + r.endLine + (r.totalLines ? ' / ' + r.totalLines : '')
305
+ + (r.truncated ? '\n# Truncated: yes' : '');
306
+ return header + '\n\n' + r.body;
307
+ }
308
+
309
+ // ---- list_files --------------------------------------------------------
310
+
311
+ // One-pass directory walk honoring the same skip-dirs and text-extension
312
+ // allowlist as src/tags.js. Files are returned as POSIX-relative paths
313
+ // with `binary` flagged by the same NUL-byte heuristic.
314
+ async function runListFiles(opts) {
315
+ const { projectDir, args, settings } = opts;
316
+ const root = resolveSandbox(projectDir);
317
+ const cap = (settings && settings.fileListMaxEntries) || DEFAULT_LIST_MAX_ENTRIES;
318
+
319
+ const pattern = (args && typeof args.pattern === 'string' && args.pattern.trim()) ? args.pattern.trim() : null;
320
+ // Compile a simple glob: '*' matches any path segment, '**' matches
321
+ // any number of segments. Anything else is a literal segment match.
322
+ // A bare (non-glob) pattern is resolved against the filesystem first:
323
+ // - an existing directory → everything under it (`src` → all of src/);
324
+ // - an existing file → just that file;
325
+ // - a non-existing path → the longest existing ancestor, so `src/util`
326
+ // (typo or new dir) still lists something instead of nothing (`doc`
327
+ // falls back to the whole project).
328
+ // This is the same prefix-tolerant behavior the search_files `path`
329
+ // parameter uses; the two surfaces should not disagree.
330
+ let re = pattern ? globToRegExp(pattern) : null;
331
+ if (pattern && !/[\\*?]/.test(pattern)) {
332
+ const rawPath = pattern.replace(/\/+$/, '');
333
+ try {
334
+ const safe = toRelPath(root, rawPath);
335
+ const absProbe = path.resolve(root, safe);
336
+ const probeStat = fs.statSync(absProbe);
337
+ if (probeStat.isDirectory()) {
338
+ re = globToRegExp(safe + '/**');
339
+ } else {
340
+ re = globToRegExp(safe);
341
+ }
342
+ } catch {
343
+ // Outside root or otherwise invalid — keep the strict literal glob so
344
+ // the model gets an honest empty result instead of a fallback walk.
345
+ re = globToRegExp(pattern);
346
+ }
347
+ } else if (pattern && pattern.endsWith('/')) {
348
+ // A trailing slash is a directory intent; match everything under it.
349
+ re = globToRegExp(pattern + '**');
350
+ }
351
+
352
+ const out = [];
353
+ let truncated = false;
354
+ let skipped = 0;
355
+
356
+ async function walk(dirAbs, dirRel) {
357
+ if (out.length >= cap) { truncated = true; return; }
358
+ let entries;
359
+ try { entries = await fsp.readdir(dirAbs, { withFileTypes: true }); }
360
+ catch { skipped++; return; }
361
+ for (const ent of entries) {
362
+ if (out.length >= cap) { truncated = true; return; }
363
+ const childAbs = path.join(dirAbs, ent.name);
364
+ const childRel = (dirRel ? dirRel + '/' : '') + ent.name;
365
+ if (ent.isDirectory()) {
366
+ if (SKIP_DIRS.has(ent.name)) { skipped++; continue; }
367
+ await walk(childAbs, childRel);
368
+ continue;
369
+ }
370
+ if (!ent.isFile()) { skipped++; continue; }
371
+ // Pattern filter.
372
+ if (re && !re.test(childRel)) continue;
373
+ // Extension allowlist — text files the model can read, plus pictures
374
+ // it can now open with `read_file`. Images are flagged in the entry so
375
+ // the model knows which read returns pixels instead of a body.
376
+ const ext = path.extname(ent.name).toLowerCase();
377
+ const image = !TEXT_EXTS.has(ext) && isImageExt(ext);
378
+ if (!TEXT_EXTS.has(ext) && !image) { skipped++; continue; }
379
+ out.push(image ? { path: childRel, image: true } : { path: childRel });
380
+ }
381
+ }
382
+ await walk(root, '');
383
+ return { entries: out, skipped, truncated, cap, pattern: pattern || '' };
384
+ }
385
+
386
+ function formatListFilesResult(r, structure) {
387
+ const header = '# Listing: ' + (r.pattern || '<all text and image files>') + '\n# Count: ' + r.entries.length + (r.truncated ? ' (capped at ' + r.cap + ')' : '') + (r.skipped ? '\n# Skipped: ' + r.skipped : '');
388
+ if (!r.entries.length) return header + '\n\n(no matching files)';
389
+
390
+ const sorted = [...r.entries].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
391
+
392
+ // `json` — the structured result verbatim, so the model can parse it.
393
+ if (structure === 'json') return JSON.stringify(r);
394
+
395
+ // `tree` (default) — an indented hierarchical tree: each path segment is
396
+ // a node, directories printed once and files nested under their parent.
397
+ // Two spaces per depth level, matching how a file explorer reads. This is
398
+ // the only text layout: it prints every shared path prefix once, which is
399
+ // what the old `# dir/` group-header layout did, without the header lines
400
+ // the model has to context-switch on.
401
+ const treeLines = [];
402
+ let prev = [];
403
+ for (const e of sorted) {
404
+ const parts = e.path.split('/');
405
+ const name = parts[parts.length - 1];
406
+ const dirs = parts.slice(0, -1);
407
+ let shared = 0;
408
+ while (shared < dirs.length && shared < prev.length && dirs[shared] === prev[shared]) shared++;
409
+ for (let i = shared; i < dirs.length; i++) treeLines.push(' '.repeat(i) + dirs[i] + '/');
410
+ treeLines.push(' '.repeat(dirs.length) + name + (e.image ? ' (image)' : ''));
411
+ prev = dirs;
412
+ }
413
+ return header + '\n\n' + treeLines.join('\n');
414
+ }
415
+
416
+ // Minimal glob: **/foo matches foo anywhere; foo/** matches a directory
417
+ // tree under foo; otherwise treat the pattern as a right-anchored regex
418
+ // of `^pattern$` with `*` -> `[^/]*` and `?` -> `[^/]`. We deliberately
419
+ // avoid a full glob library — the model only needs "everything under
420
+ // src/", "all *.test.js", "the file named README.md".
421
+ //
422
+ // Two relaxations over a strict pure glob:
423
+ // - A pattern with no wildcard at all (a bare path like `src` or
424
+ // `README.md`) is treated as a directory-or-file *prefix*: `src`
425
+ // matches everything under src/, `src/utils/index.js` matches that
426
+ // file and any subtree under it. This is what the file tools'
427
+ // `search_files path` parameter already does, and it keeps "list
428
+ // src" from silently returning nothing when the model forgets the
429
+ // `/**`.
430
+ // - Globs are case-insensitive (`README*` matches `readme.md`). Path
431
+ // matching works on the lowercase form so the model doesn't have to
432
+ // guess the project's casing.
433
+ function globToRegExp(pattern) {
434
+ let src = '';
435
+ if (!/[\\*?]/.test(pattern)) {
436
+ // Bare path: anchor the literal, then allow a deeper subtree.
437
+ return new RegExp('^' + escapeGlob(pattern) + '(?:/.*)?$', 'i');
438
+ }
439
+ for (let i = 0; i < pattern.length; i++) {
440
+ const c = pattern[i];
441
+ if (c === '*' && pattern[i + 1] === '*') {
442
+ src += '.*';
443
+ i++;
444
+ if (pattern[i + 1] === '/') i++;
445
+ } else if (c === '*') {
446
+ src += '[^/]*';
447
+ } else if (c === '?') {
448
+ src += '[^/]';
449
+ } else if ('.+^$()|{}[]\\'.includes(c)) {
450
+ src += '\\' + c;
451
+ } else {
452
+ src += c;
453
+ }
454
+ }
455
+ return new RegExp('^' + src + '$', 'i');
456
+ }
457
+ function escapeGlob(pattern) {
458
+ return String(pattern).replace(/[.+^$()|{}[\]\\]/g, '\\$&');
459
+ }
460
+
461
+ // ---- search_files ------------------------------------------------------
462
+
463
+ // Text search over the project. The engine (ripgrep when available, a JS
464
+ // walk when not) lives in src/tools/searchEngine.js; this function owns the
465
+ // tool-facing contract only: validate the input, hand the caps over, and
466
+ // return the same result shape the formatter and the tests expect.
467
+ async function runSearchFiles(opts) {
468
+ const { projectDir, args, settings } = opts;
469
+ return await searchEngine.runSearch({ projectDir, args, settings });
470
+ }
471
+
472
+ function formatSearchFilesResult(r, structure) {
473
+ const header = '# Search: ' + r.query
474
+ + '\n# Matches: ' + r.matches.length + (r.truncated ? ' (capped at ' + r.capMatches + ' matches / ' + r.capBytes + ' chars)' : '');
475
+ if (!r.matches.length) return header + '\n\n(no matches)';
476
+
477
+ // `json` — the structured result verbatim.
478
+ if (structure === 'json') return JSON.stringify(r);
479
+
480
+ // `tree` (default) — matches nested under an indented path hierarchy.
481
+ // Each file's path segments are printed once, then its matching lines are
482
+ // indented one level deeper.
483
+ const treeLines = [];
484
+ let prevDirs = [];
485
+ let currentPath = null;
486
+ for (const m of r.matches) {
487
+ if (m.path !== currentPath) {
488
+ const parts = m.path.split('/');
489
+ const name = parts[parts.length - 1];
490
+ const dirs = parts.slice(0, -1);
491
+ let shared = 0;
492
+ while (shared < dirs.length && shared < prevDirs.length && dirs[shared] === prevDirs[shared]) shared++;
493
+ for (let i = shared; i < dirs.length; i++) treeLines.push(' '.repeat(i) + dirs[i] + '/');
494
+ treeLines.push(' '.repeat(dirs.length) + name);
495
+ prevDirs = dirs;
496
+ currentPath = m.path;
497
+ }
498
+ treeLines.push(' '.repeat(prevDirs.length + 1) + m.line + ': ' + m.text);
499
+ }
500
+ return header + '\n\n' + treeLines.join('\n');
501
+ }
502
+
503
+ // ---- write_file / edit_file --------------------------------------------
504
+
505
+ // Normalize line endings for comparison while retaining a map from each
506
+ // normalized character boundary back to its byte-for-byte source offset.
507
+ // This lets edit_file accept an LF oldText for a CRLF file (and vice versa)
508
+ // without rewriting any content outside the matched block.
509
+ function normalizedTextWithOffsets(text) {
510
+ let normalized = '';
511
+ const offsets = [];
512
+ for (let i = 0; i < text.length;) {
513
+ offsets.push(i);
514
+ if (text[i] === '\r') {
515
+ normalized += '\n';
516
+ i += text[i + 1] === '\n' ? 2 : 1;
517
+ } else {
518
+ normalized += text[i];
519
+ i++;
520
+ }
521
+ }
522
+ offsets.push(text.length);
523
+ return { normalized, offsets };
524
+ }
525
+
526
+ // Pick the file's dominant newline convention. Ties use the first newline,
527
+ // which keeps small or mixed files stable. A file with no newline leaves the
528
+ // replacement exactly as supplied by the caller.
529
+ function detectLineEnding(text) {
530
+ const counts = { '\r\n': 0, '\n': 0, '\r': 0 };
531
+ let first = null;
532
+ for (let i = 0; i < text.length; i++) {
533
+ let eol = null;
534
+ if (text[i] === '\r') {
535
+ eol = text[i + 1] === '\n' ? '\r\n' : '\r';
536
+ if (eol === '\r\n') i++;
537
+ } else if (text[i] === '\n') {
538
+ eol = '\n';
539
+ }
540
+ if (!eol) continue;
541
+ if (!first) first = eol;
542
+ counts[eol]++;
543
+ }
544
+ if (!first) return null;
545
+ return Object.keys(counts).reduce((best, eol) => counts[eol] > counts[best] ? eol : best, first);
546
+ }
547
+
548
+ function convertLineEndings(text, eol) {
549
+ return eol ? text.replace(/\r\n|\r|\n/g, eol) : text;
550
+ }
551
+
552
+ // ---- Indentation helpers (ported from crush's edit_whitespace.go) -------
553
+ //
554
+ // `edit_file` matches a block even when the caller's indentation differs from
555
+ // the file (leading whitespace is formatter-inert), but the replacement must
556
+ // land at the file's indentation depth, not the caller's. These helpers detect
557
+ // the file's indent unit and re-offset the replacement lines so the edit reads
558
+ // as if written by a native tool rather than spliced in at a random depth.
559
+
560
+ // Detect the indentation unit used by the given lines: "\t" for tab-indented
561
+ // files, or a string of N spaces for space-indented files. Returns "" when
562
+ // indentation cannot be determined (no indented non-empty lines).
563
+ function detectIndentUnit(lines) {
564
+ let minSpaces = 0;
565
+ let hasTabs = false;
566
+ for (const line of lines) {
567
+ const trimmed = String(line).replace(/^[ \t]+/, '');
568
+ if (!trimmed) continue;
569
+ const leading = String(line).slice(0, String(line).length - trimmed.length);
570
+ if (!leading) continue;
571
+ if (leading.indexOf('\t') !== -1) { hasTabs = true; break; }
572
+ const n = leading.length;
573
+ if (n > 0 && (minSpaces === 0 || n < minSpaces)) minSpaces = n;
574
+ }
575
+ if (hasTabs) return '\t';
576
+ if (minSpaces > 0) return ' '.repeat(minSpaces);
577
+ return '';
578
+ }
579
+
580
+ // Count how many `unit` deep the leading whitespace of `leading` represents.
581
+ // Returns a whole number so the caller can safely use it in `repeat()`.
582
+ function measureIndentDepth(leading, unit) {
583
+ if (!unit) return 0;
584
+ if (unit === '\t') return leading.split('\t').length - 1;
585
+ return Math.floor((leading.split(' ').length - 1) / unit.length);
586
+ }
587
+
588
+ // Depth of the first non-empty line in `lines`, in units of `unit`.
589
+ function firstIndentDepth(lines, unit) {
590
+ for (const line of lines) {
591
+ const trimmed = String(line).replace(/^[ \t]+/, '');
592
+ if (!trimmed) continue;
593
+ const leading = String(line).slice(0, String(line).length - trimmed.length);
594
+ return measureIndentDepth(leading, unit);
595
+ }
596
+ return 0;
597
+ }
598
+
599
+ // Re-indent `newStr` to match the file `unit`, offsetting by the difference in
600
+ // nesting depth between the actual matched text (`actualStr`) and the caller's
601
+ // `oldStr`. Mirrors crush's `adaptIndentation`, but only when a unit could be
602
+ // inferred and the two strings are not already depth-consistent.
603
+ function adaptIndentation(actualStr, oldStr, newStr, fileUnit) {
604
+ if (!fileUnit) return newStr;
605
+ const actualLines = String(actualStr).split('\n');
606
+ const oldLines = String(oldStr).split('\n');
607
+ const newLines = String(newStr).split('\n');
608
+
609
+ const sourceUnit = detectIndentUnit(oldLines) || detectIndentUnit(newLines) || fileUnit;
610
+ const actualBase = firstIndentDepth(actualLines, fileUnit);
611
+ const oldBase = firstIndentDepth(oldLines, sourceUnit);
612
+ const depthOffset = actualBase - oldBase;
613
+ if (sourceUnit === fileUnit && depthOffset === 0) return newStr;
614
+
615
+ const out = [];
616
+ for (const line of newLines) {
617
+ const trimmed = String(line).replace(/^[ \t]+/, '');
618
+ if (!trimmed) { out.push(line); continue; }
619
+ const leading = String(line).slice(0, String(line).length - trimmed.length);
620
+ const depth = Math.max(measureIndentDepth(leading, sourceUnit) + depthOffset, 0);
621
+ out.push(fileUnit.repeat(depth) + trimmed);
622
+ }
623
+ return out.join('\n');
624
+ }
625
+
626
+ // Undo JSON-style escaping ("\n", "\t", "\"", "\\", "\r", etc.) in a string a
627
+ // model authored as a literal. This rescues `edit_file` calls where the model
628
+ // emitted its block with doubled escape sequences, so the bytes on disk match
629
+ // the literal text the model intended. Only applied when it actually differs,
630
+ // and only as a fallback after the direct match fails.
631
+ function unescapeEditString(str) {
632
+ return String(str).replace(/\\(n|t|r|'|"|`|\\|\/|\$)/g, (m, c) => {
633
+ switch (c) {
634
+ case 'n': return '\n';
635
+ case 't': return '\t';
636
+ case 'r': return '\r';
637
+ case "'": return "'";
638
+ case '"': return '"';
639
+ case '`': return '`';
640
+ case '\\': return '\\';
641
+ case '/': return '/';
642
+ case '$': return '$';
643
+ default: return m;
644
+ }
645
+ });
646
+ }
647
+
648
+ function previewEditDiff(rel, before, after) {
649
+ const oldLines = String(before || '').replace(/\r\n|\r/g, '\n').split('\n');
650
+ const newLines = String(after || '').replace(/\r\n|\r/g, '\n').split('\n');
651
+ const out = ['--- ' + rel, '+++ ' + rel];
652
+ const max = Math.max(oldLines.length, newLines.length);
653
+ for (let i = 0; i < max; i++) {
654
+ const a = i < oldLines.length ? oldLines[i] : null;
655
+ const b = i < newLines.length ? newLines[i] : null;
656
+ if (a === b) {
657
+ if (out.length < 80) out.push(' ' + a);
658
+ } else {
659
+ if (a != null) out.push('-' + a);
660
+ if (b != null) out.push('+' + b);
661
+ }
662
+ if (out.length >= 80) {
663
+ out.push('...[diff truncated]');
664
+ break;
665
+ }
666
+ }
667
+ return out.join('\n');
668
+ }
669
+
670
+ // Find the closest matching snippet in the file to help an agent understand
671
+ // why `oldText` was not matched (e.g. stale cache or slight typo).
672
+ function findClosestContext(original, oldText) {
673
+ const fileLines = String(original || '').split(/\r?\n/);
674
+ const oldLines = String(oldText || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
675
+ if (!oldLines.length || !fileLines.length) return null;
676
+
677
+ function tokenize(str) {
678
+ return str.toLowerCase().match(/[a-zA-Z0-9_$]+/g) || [];
679
+ }
680
+
681
+ const oldTokens = new Set(tokenize(oldText));
682
+ if (oldTokens.size === 0) return null;
683
+
684
+ let bestScore = 0;
685
+ let bestIndex = -1;
686
+ const windowLen = Math.max(1, oldLines.length);
687
+
688
+ for (let i = 0; i < fileLines.length; i++) {
689
+ const windowLines = fileLines.slice(i, i + windowLen);
690
+ const windowText = windowLines.join('\n');
691
+ const windowTokens = tokenize(windowText);
692
+ if (windowTokens.length === 0) continue;
693
+
694
+ let overlap = 0;
695
+ for (const t of windowTokens) {
696
+ if (oldTokens.has(t)) overlap++;
697
+ }
698
+ const score = overlap / Math.max(windowTokens.length, oldTokens.size);
699
+ if (score > bestScore) {
700
+ bestScore = score;
701
+ bestIndex = i;
702
+ }
703
+ }
704
+
705
+ if (bestScore >= 0.25 && bestIndex >= 0) {
706
+ const startLine = Math.max(1, bestIndex + 1);
707
+ const endLine = Math.min(fileLines.length, bestIndex + windowLen);
708
+ const excerptLines = [];
709
+ for (let i = startLine; i <= endLine; i++) {
710
+ excerptLines.push(i + ': ' + (fileLines[i - 1] || ''));
711
+ }
712
+ return {
713
+ startLine,
714
+ endLine,
715
+ score: bestScore,
716
+ excerpt: excerptLines.join('\n')
717
+ };
718
+ }
719
+ return null;
720
+ }
721
+
722
+ // Create or overwrite a file. `dirs: true` allows the path to include
723
+ // new directories (the runner mkdir -p's them); otherwise the parent
724
+ // dir must already exist. Refuses paths that escape the root.
725
+ async function runWriteFile(opts) {
726
+ const { projectDir, args, settings } = opts;
727
+ const root = resolveSandbox(projectDir);
728
+ const requestedPath = args && (args.path || args.file);
729
+ const rel = toRelPath(root, requestedPath);
730
+ const abs = toAbsInside(root, rel);
731
+ if (!args || typeof args.content !== 'string') throw err('EBADINPUT', 'content is required');
732
+ const content = args.content;
733
+ const cap = (settings && settings.fileWriteMaxBytes) || DEFAULT_WRITE_MAX_BYTES;
734
+ if (Buffer.byteLength(content, 'utf8') > cap) {
735
+ throw err('ETOOL_CAP', 'content is ' + Buffer.byteLength(content, 'utf8') + ' bytes, exceeds cap ' + cap);
736
+ }
737
+ await fsp.mkdir(path.dirname(abs), { recursive: true });
738
+ await fsp.writeFile(abs, content, 'utf8');
739
+ return { relPath: rel, chars: content.length, lines: content ? content.split('\n').length : 0 };
740
+ }
741
+
742
+ // Replace one unique block in an existing text file. Line-ending styles are
743
+ // considered equivalent during matching. This is kept
744
+ // deliberately separate from write_file: coding models commonly interpret
745
+ // "edit" as a patch operation and send only the changed line. Treating that
746
+ // payload as a full-file body silently destroys the rest of the file.
747
+ function matchEditSpan(original, needleText, rel) {
748
+ const source = normalizedTextWithOffsets(original);
749
+ // Normalized comparison form: line endings are equalized and runs of
750
+ // spaces/tabs inside a line are collapsed, so a block that differs from
751
+ // the file only by indentation or extra whitespace still matches. The
752
+ // collapse keeps a 1:1 character->position map (offsets) so the matched
753
+ // span still maps back to exact original byte offsets.
754
+ function softNormalizeWithOffsets(text) {
755
+ let norm = '';
756
+ const off = [];
757
+ for (let i = 0; i < text.length;) {
758
+ off.push(i);
759
+ const c = text[i];
760
+ if (c === '\r') {
761
+ norm += '\n';
762
+ i += text[i + 1] === '\n' ? 2 : 1;
763
+ } else if (c === ' ' || c === '\t') {
764
+ norm += ' ';
765
+ i++;
766
+ while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i++;
767
+ } else {
768
+ norm += c;
769
+ i++;
770
+ }
771
+ }
772
+ off.push(text.length);
773
+ return { norm, off };
774
+ }
775
+ const needle = softNormalizeWithOffsets(needleText);
776
+ const target = softNormalizeWithOffsets(source.normalized);
777
+
778
+ // Strategy 1: Direct soft match on collapsed horizontal whitespace.
779
+ let normStart = -1;
780
+ let normEnd = -1;
781
+ const first = target.norm.indexOf(needle.norm);
782
+ if (first >= 0) {
783
+ if (target.norm.indexOf(needle.norm, first + needle.norm.length) >= 0) {
784
+ throw err('EMULTI_MATCH', 'oldText occurs more than once in ' + rel + '; include more surrounding context');
785
+ }
786
+ normStart = target.off[first];
787
+ normEnd = target.off[first + needle.norm.length];
788
+ } else {
789
+ // Strategy 2: Formatter-tolerant match. Whitespace between punctuation is
790
+ // insignificant, while a separator between word characters remains
791
+ // significant (`return x` must not match `returnx`). This accepts line
792
+ // wraps, blank lines, and spacing around operators without accepting
793
+ // changed code.
794
+ function layoutNormalizeWithOffsets(text) {
795
+ let norm = '';
796
+ const starts = [];
797
+ const ends = [];
798
+ const isWord = (c) => !!c && /[\p{L}\p{N}_$]/u.test(c);
799
+ let quote = '';
800
+ let escaped = false;
801
+ for (let i = 0; i < text.length;) {
802
+ const c = text[i];
803
+ if (quote || !/\s/u.test(c)) {
804
+ starts.push(i);
805
+ ends.push(i + 1);
806
+ norm += c;
807
+ if (quote) {
808
+ if (escaped) escaped = false;
809
+ else if (c === '\\') escaped = true;
810
+ else if (c === quote) quote = '';
811
+ } else if (c === "'" || c === '"' || c === '`') {
812
+ quote = c;
813
+ }
814
+ i++;
815
+ continue;
816
+ }
817
+ const wsStart = i;
818
+ while (i < text.length && /\s/u.test(text[i])) i++;
819
+ const prev = norm[norm.length - 1] || '';
820
+ const next = text[i] || '';
821
+ if (isWord(prev) && isWord(next)) {
822
+ starts.push(wsStart);
823
+ ends.push(i);
824
+ norm += ' ';
825
+ }
826
+ }
827
+ return { norm, starts, ends };
828
+ }
829
+ const layoutNeedle = layoutNormalizeWithOffsets(needleText);
830
+ const layoutTarget = layoutNormalizeWithOffsets(source.normalized);
831
+ if (layoutNeedle.norm) {
832
+ const layoutFirst = layoutTarget.norm.indexOf(layoutNeedle.norm);
833
+ if (layoutFirst >= 0) {
834
+ if (layoutTarget.norm.indexOf(layoutNeedle.norm, layoutFirst + layoutNeedle.norm.length) >= 0) {
835
+ throw err('EMULTI_MATCH', 'oldText occurs more than once in ' + rel + '; include more surrounding context');
836
+ }
837
+ normStart = layoutTarget.starts[layoutFirst];
838
+ normEnd = layoutTarget.ends[layoutFirst + layoutNeedle.norm.length - 1];
839
+ }
840
+ }
841
+
842
+ // Strategy 3: Line-trimmed block match (ignoring leading/trailing blank
843
+ // lines in oldText and line-by-line whitespace variations).
844
+ if (normStart < 0 || normEnd < 0) {
845
+ const needleLines = needle.norm.split('\n');
846
+ let nStart = 0;
847
+ let nEnd = needleLines.length;
848
+ while (nStart < nEnd && !needleLines[nStart].trim()) nStart++;
849
+ while (nEnd > nStart && !needleLines[nEnd - 1].trim()) nEnd--;
850
+
851
+ let matchedLineIdx = -1;
852
+ let isMulti = false;
853
+ if (nEnd > nStart) {
854
+ const targetLines = target.norm.split('\n');
855
+ const targetLineOffsets = [0];
856
+ for (let i = 0; i < target.norm.length; i++) {
857
+ if (target.norm[i] === '\n') targetLineOffsets.push(i + 1);
858
+ }
859
+ const needleLineCount = nEnd - nStart;
860
+ const matches = [];
861
+ for (let i = 0; i <= targetLines.length - needleLineCount; i++) {
862
+ let match = true;
863
+ for (let j = 0; j < needleLineCount; j++) {
864
+ if (targetLines[i + j].trim() !== needleLines[nStart + j].trim()) {
865
+ match = false;
866
+ break;
867
+ }
868
+ }
869
+ if (match) matches.push(i);
870
+ }
871
+ if (matches.length === 1) {
872
+ matchedLineIdx = matches[0];
873
+ const lineStartNorm = targetLineOffsets[matchedLineIdx];
874
+ const endLineIdx = matchedLineIdx + needleLineCount - 1;
875
+ const lineEndNorm = (endLineIdx + 1 < targetLineOffsets.length)
876
+ ? targetLineOffsets[endLineIdx + 1] - 1
877
+ : target.norm.length;
878
+ normStart = target.off[lineStartNorm];
879
+ normEnd = target.off[lineEndNorm];
880
+ } else if (matches.length > 1) {
881
+ isMulti = true;
882
+ }
883
+ }
884
+
885
+ if (isMulti) {
886
+ throw err('EMULTI_MATCH', 'oldText occurs more than once in ' + rel + '; include more surrounding context');
887
+ }
888
+
889
+ if (normStart < 0 || normEnd < 0) {
890
+ // Find closest matching snippet to give the model actionable feedback.
891
+ const hint = findClosestContext(original, needleText);
892
+ let msg = 'oldText was not found in ' + rel + '; read the file and retry with an exact block';
893
+ if (hint && hint.excerpt) {
894
+ msg += '\n\nClosest match found around lines ' + hint.startLine + '-' + hint.endLine + ':\n' + hint.excerpt;
895
+ }
896
+ throw err('ENO_MATCH', msg);
897
+ }
898
+ }
899
+ }
900
+ return { normStart, normEnd };
901
+ }
902
+
903
+ async function runEditFile(opts) {
904
+ const { projectDir, args, settings } = opts;
905
+ const root = resolveSandbox(projectDir);
906
+ const requestedPath = args && (args.path || args.file);
907
+ const rel = toRelPath(root, requestedPath);
908
+ const abs = toAbsInside(root, rel);
909
+ const oldText = args && (typeof args.oldText === 'string' ? args.oldText : args.old_string);
910
+ const newText = args && (typeof args.newText === 'string' ? args.newText : args.new_string);
911
+ if (typeof oldText !== 'string' || !oldText) {
912
+ throw err('EBADINPUT', 'oldText is required and must be a non-empty exact block; use write_file for full-file replacement');
913
+ }
914
+ if (typeof newText !== 'string') throw err('EBADINPUT', 'newText is required');
915
+
916
+ const st = await fsp.stat(abs);
917
+ if (!st.isFile()) throw err('ENOTFILE', 'Not a file: ' + rel);
918
+ const original = await fsp.readFile(abs, 'utf8');
919
+ const source = normalizedTextWithOffsets(original);
920
+
921
+ // Try the caller's oldText verbatim. If it fails with ENO_MATCH and the
922
+ // block was authored with escape sequences (so it matches nothing on disk),
923
+ // retry the whole match against its unescaped form so a JSON-escaped block
924
+ // can still land. When that rescues the match we also unescape the
925
+ // replacement, because a model that escapes the block escapes both sides.
926
+ let matched;
927
+ let matchedOld = oldText;
928
+ let escapedFallback = false;
929
+ try {
930
+ matched = matchEditSpan(original, oldText, rel);
931
+ } catch (e) {
932
+ if (e && e.code === 'ENO_MATCH') {
933
+ const unescaped = unescapeEditString(oldText);
934
+ if (unescaped !== oldText) {
935
+ matched = matchEditSpan(original, unescaped, rel);
936
+ matchedOld = unescaped;
937
+ escapedFallback = true;
938
+ } else {
939
+ throw e;
940
+ }
941
+ } else {
942
+ throw e;
943
+ }
944
+ }
945
+
946
+ // Map the normalized source positions back to original byte offsets.
947
+ const originalStart = source.offsets[matched.normStart];
948
+ const originalEnd = source.offsets[matched.normEnd];
949
+ const replaced = original.slice(originalStart, originalEnd);
950
+
951
+ // Re-indent the replacement so it lands at the file's indentation depth,
952
+ // not the caller's — but only when the matched span sits on its own line
953
+ // boundaries. A mid-line (substring) match keeps its leading whitespace as
954
+ // part of the surrounding slice, so re-indenting there would double-indent.
955
+ let newTextToApply = escapedFallback ? unescapeEditString(newText) : newText;
956
+ const alignedStart = originalStart === 0 || original[originalStart - 1] === '\n';
957
+ const alignedEnd = originalEnd >= original.length
958
+ || original[originalEnd] === '\n' || original[originalEnd] === '\r';
959
+ if (alignedStart && alignedEnd) {
960
+ const fileUnit = detectIndentUnit(original.split('\n'));
961
+ newTextToApply = adaptIndentation(replaced, matchedOld, newTextToApply, fileUnit);
962
+ }
963
+ const replacement = convertLineEndings(newTextToApply, detectLineEnding(original));
964
+ const content = original.slice(0, originalStart) + replacement + original.slice(originalEnd);
965
+ const cap = (settings && settings.fileWriteMaxBytes) || DEFAULT_WRITE_MAX_BYTES;
966
+ const bytes = Buffer.byteLength(content, 'utf8');
967
+ if (bytes > cap) throw err('ETOOL_CAP', 'edited file is ' + bytes + ' bytes, exceeds cap ' + cap);
968
+
969
+ // Write beside the target and rename so an interrupted write cannot leave a
970
+ // partially-written source file. Preserve the existing permission bits.
971
+ const tmp = abs + '.mouaif-edit-' + process.pid + '-' + Math.random().toString(16).slice(2);
972
+ try {
973
+ await fsp.writeFile(tmp, content, { encoding: 'utf8', mode: st.mode });
974
+ await fsp.rename(tmp, abs);
975
+ } finally {
976
+ try { await fsp.unlink(tmp); } catch { /* rename succeeded or cleanup best-effort */ }
977
+ }
978
+ return {
979
+ relPath: rel,
980
+ addedChars: replacement.length,
981
+ removedChars: replaced.length,
982
+ diff: previewEditDiff(rel, replaced, replacement)
983
+ };
984
+ }
985
+ function formatWriteFileResult(r) {
986
+ let out = '# Wrote: ' + r.relPath;
987
+ if (r.chars != null) out += '\n# Chars: ' + r.chars + (r.lines != null ? ' · ' + r.lines + ' lines' : '');
988
+ else if (r.addedChars != null) out += '\n# Chars: +' + r.addedChars + ' / -' + (r.removedChars || 0);
989
+ return out;
990
+ }
991
+
992
+ // ---- Dispatcher --------------------------------------------------------
993
+
994
+ // runFileTool(name, opts) -> Promise<{ ok, content, result }>
995
+ // ok : true on success, false on handled error
996
+ // content : the JSON-serialized object sent back as the model's tool
997
+ // message (the same shape the shell tool uses)
998
+ // result : the richer object surfaced to the chat UI in the
999
+ // tool_result SSE event
1000
+ async function runFileTool(name, opts) {
1001
+ let out;
1002
+ try {
1003
+ if (name === 'read_file') out = await runReadFile(opts);
1004
+ else if (name === 'list_files') out = await runListFiles(opts);
1005
+ else if (name === 'search_files') out = await runSearchFiles(opts);
1006
+ else if (name === 'write_file') out = await runWriteFile(opts);
1007
+ else if (name === 'edit_file') out = await runEditFile(opts);
1008
+ else throw err('EUNKNOWN_TOOL', 'Unknown file tool: ' + name);
1009
+ } catch (e) {
1010
+ const r = { error: { code: e.code || 'EUNKNOWN', message: e.message } };
1011
+ if (e.path) r.error.path = e.path;
1012
+ return { ok: false, content: JSON.stringify(r), result: r };
1013
+ }
1014
+ // Format the model's response as a clean header + body string. The
1015
+ // model-facing content is the string the upstream API will see as
1016
+ // the `tool` message; the chat UI gets the structured result.
1017
+ // Pick the file-listing layout from the per-project tool-output profile.
1018
+ // Only the two file structures (`json` / `tree`) change rendering here;
1019
+ // every other value (including the legacy `grouped`, `full`, `concise`)
1020
+ // falls through to the default `tree` layout.
1021
+ const FILE_STRUCTURES = ['json', 'tree'];
1022
+ const rawStructure = opts && opts.toolOutput && typeof opts.toolOutput === 'object'
1023
+ ? opts.toolOutput.structure : null;
1024
+ const structure = FILE_STRUCTURES.includes(rawStructure) ? rawStructure : 'tree';
1025
+
1026
+ let content;
1027
+ try {
1028
+ if (name === 'read_file') content = formatReadFileResult(out, structure);
1029
+ else if (name === 'list_files') content = formatListFilesResult(out, structure);
1030
+ else if (name === 'search_files') content = formatSearchFilesResult(out, structure);
1031
+ else if (name === 'write_file' || name === 'edit_file') content = formatWriteFileResult(out);
1032
+ else content = JSON.stringify(out);
1033
+ } catch (e) {
1034
+ const r = { error: { code: 'EENCODE', message: 'failed to encode result: ' + e.message } };
1035
+ return { ok: false, content: JSON.stringify(r), result: r };
1036
+ }
1037
+ return { ok: true, content, result: out };
1038
+ }
1039
+
1040
+ // ---- Tool specs (OpenAI-compatible function shape) --------------------
1041
+
1042
+ const SPECS = Object.freeze({
1043
+ read_file: {
1044
+ type: 'function',
1045
+ function: {
1046
+ name: 'read_file',
1047
+ description: 'Read a text file from the project directory, or open an image (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`, `.ico`) by attaching its pixels as an image part so a vision model can see it. A text read returns the file body with a header that shows the path, line range, and total line count. Use startLine/endLine (1-indexed, inclusive) to read a slice of a large file; whole-file reads over 10000 lines are refused. An image read returns a header (path, MIME type, size) plus the picture; images over 4 MB are refused — downscale first.',
1048
+ parameters: {
1049
+ type: 'object',
1050
+ properties: {
1051
+ path: { type: 'string', description: 'POSIX path relative to the project root (e.g. "src/index.js").' },
1052
+ startLine: { type: 'integer', description: 'Optional 1-indexed start line for a slice.' },
1053
+ endLine: { type: 'integer', description: 'Optional 1-indexed inclusive end line for a slice.' }
1054
+ },
1055
+ required: ['path'],
1056
+ additionalProperties: false
1057
+ }
1058
+ }
1059
+ },
1060
+ list_files: {
1061
+ type: 'function',
1062
+ function: {
1063
+ name: 'list_files',
1064
+ description: 'List text and image files in the project directory. Honors a simple glob pattern ("src/**/*.js", "**/*.test.*", "README.md"). Image files are marked "(image)" — read them with read_file to see the picture. Skips node_modules, .git, .mouaif, dist, build. Result is capped at 1000 entries.',
1065
+ parameters: {
1066
+ type: 'object',
1067
+ properties: {
1068
+ pattern: { type: 'string', description: 'Optional glob relative to the project root. Omit to list every text file.' }
1069
+ },
1070
+ additionalProperties: false
1071
+ }
1072
+ }
1073
+ },
1074
+ search_files: {
1075
+ type: 'function',
1076
+ function: {
1077
+ name: 'search_files',
1078
+ description: 'Search file contents with a ripgrep-style regular expression. Honors the project\'s .gitignore, so generated and build output trees are skipped. Binary files are skipped. Matches are printed as an indented tree: each file\'s path segments once, then its "line: text" rows one level deeper. Optional `path` scopes the search to a directory or a single file, and a path that does not exist yet still searches its nearest existing ancestor ("src/util" searches "src/"). Optional `include` filters by glob, e.g. "*.js" or "*.{ts,tsx}". Destructive and named patterns stay plain: backreferences (\\1), lookahead and lookbehind are rejected. Capped at 200 matches / 2M chars scanned.',
1079
+ parameters: {
1080
+ type: 'object',
1081
+ properties: {
1082
+ query: { type: 'string', description: 'Ripgrep-style regular expression, matched one line at a time. `(?i)` for case-insensitive is honored. `(?s)` is not: matches are reported and redacted per line, so search for the two anchors separately. Lookaround and backreferences are not supported.' },
1083
+ path: { type: 'string', description: 'Optional directory or single file to scope the search. Use "." or omit for the whole project.' },
1084
+ include: { type: 'string', description: 'Optional glob to filter the files searched, e.g. "*.js", "*.{ts,tsx}", "src/**/*.test.js", "src/[ab].js".' }
1085
+ },
1086
+ required: ['query'],
1087
+ additionalProperties: false
1088
+ }
1089
+ }
1090
+ },
1091
+ write_file: {
1092
+ type: 'function',
1093
+ function: {
1094
+ name: 'write_file',
1095
+ description: 'Create or overwrite a text file in the project directory. Creates parent directories as needed. Content is capped at 1 MB. Use with care — this overwrites without a merge.',
1096
+ parameters: {
1097
+ type: 'object',
1098
+ properties: {
1099
+ path: { type: 'string', description: 'POSIX path relative to the project root (e.g. "src/utils/helper.js").' },
1100
+ content: { type: 'string', description: 'The full file body to write.' }
1101
+ },
1102
+ required: ['path', 'content'],
1103
+ additionalProperties: false
1104
+ }
1105
+ }
1106
+ },
1107
+ edit_file: {
1108
+ type: 'function',
1109
+ function: {
1110
+ name: 'edit_file',
1111
+ description: 'Safely edit an existing text file by replacing one unique block. Read the relevant lines first, then send their text as oldText and the replacement as newText. LF and CRLF are treated as equivalent, and newText adopts the file line endings. Fails without changing the file if oldText is missing or appears more than once. Use write_file only to create a file or intentionally replace its complete contents.',
1112
+ parameters: {
1113
+ type: 'object',
1114
+ properties: {
1115
+ path: { type: 'string', description: 'POSIX path relative to the project root.' },
1116
+ file: { type: 'string', description: 'Compatibility alias for path.' },
1117
+ oldText: { type: 'string', description: 'Existing text to replace. LF/CRLF differences are ignored. Include surrounding lines when needed to make it unique.' },
1118
+ newText: { type: 'string', description: 'Replacement text. May be empty to delete the matched block.' }
1119
+ },
1120
+ required: ['oldText', 'newText'],
1121
+ additionalProperties: false
1122
+ }
1123
+ }
1124
+ }
1125
+ });
1126
+
1127
+ const FILE_TOOL_NAMES = Object.freeze(['read_file', 'list_files', 'search_files', 'write_file', 'edit_file']);
1128
+
1129
+ function isFileToolName(name) {
1130
+ return FILE_TOOL_NAMES.indexOf(name) !== -1;
1131
+ }
1132
+
1133
+ module.exports = {
1134
+ SPECS,
1135
+ FILE_TOOL_NAMES,
1136
+ isFileToolName,
1137
+ runFileTool,
1138
+ resolveSandbox,
1139
+ // exposed for tests
1140
+ globToRegExp,
1141
+ // constants
1142
+ DEFAULT_READ_MAX_LINES,
1143
+ DEFAULT_READ_LINES,
1144
+ DEFAULT_READ_MAX_IMAGE_BYTES,
1145
+ DEFAULT_LIST_MAX_ENTRIES,
1146
+ DEFAULT_SEARCH_MAX_MATCHES,
1147
+ DEFAULT_SEARCH_MAX_BYTES,
1148
+ DEFAULT_WRITE_MAX_BYTES,
1149
+ MAX_TIMEOUT_MS
1150
+ };