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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- 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
|
+
};
|