smolcoder 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+ // Post-write syntax check — a harness hook that fires after every write_file /
3
+ // edit_file. A local model that writes a 300-line game.js with one stray
4
+ // brace gets a blank page and no idea why; catching the parse error in the
5
+ // tool result (with the line number) turns that into a one-step fix. Only
6
+ // parsers we can trust are used: node's own for JS (and inline <script> in
7
+ // HTML), JSON.parse, python's compiler when python is on PATH. Nothing runs
8
+ // the code.
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.syntaxCheck = syntaxCheck;
44
+ const child_process_1 = require("child_process");
45
+ const fs = __importStar(require("fs"));
46
+ const os = __importStar(require("os"));
47
+ const path = __importStar(require("path"));
48
+ const CHECK_TIMEOUT_MS = 8000;
49
+ const MAX_CHECK_BYTES = 2 * 1024 * 1024;
50
+ let pythonExe; // undefined = not probed yet
51
+ function findPython() {
52
+ if (pythonExe !== undefined)
53
+ return pythonExe;
54
+ pythonExe = null;
55
+ for (const exe of process.platform === "win32" ? ["python", "py"] : ["python3", "python"]) {
56
+ const r = (0, child_process_1.spawnSync)(exe, ["-c", "print(1)"], { encoding: "utf8", timeout: 5000, windowsHide: true });
57
+ if (r.status === 0 && r.stdout.trim() === "1") {
58
+ pythonExe = exe;
59
+ break;
60
+ }
61
+ }
62
+ return pythonExe;
63
+ }
64
+ function parseNodeError(stderr, tmpFile) {
65
+ const lines = stderr.split(/\r?\n/);
66
+ let line = null;
67
+ const loc = lines.find((l) => l.includes(tmpFile));
68
+ if (loc) {
69
+ const m = /:(\d+)\s*$/.exec(loc.trim());
70
+ if (m)
71
+ line = Number(m[1]);
72
+ }
73
+ const err = lines.find((l) => /^\w*Error: /.test(l));
74
+ if (!err)
75
+ return null;
76
+ return { line, message: err.replace(/^SyntaxError: /, "") };
77
+ }
78
+ /** Run `node --check` on a snippet, as a classic script first (non-strict,
79
+ * matches a browser <script>), then as a module if it needs import/export. */
80
+ function checkJs(source, forceModule = false) {
81
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "smolcoder-check-"));
82
+ try {
83
+ const tryAs = (ext) => {
84
+ const tmp = path.join(dir, `snippet${ext}`);
85
+ fs.writeFileSync(tmp, source, "utf8");
86
+ const r = (0, child_process_1.spawnSync)(process.execPath, ["--check", tmp], {
87
+ encoding: "utf8",
88
+ timeout: CHECK_TIMEOUT_MS,
89
+ windowsHide: true,
90
+ });
91
+ if (r.status === 0)
92
+ return null;
93
+ if (r.error)
94
+ return null; // could not run the check — stay silent
95
+ return parseNodeError(r.stderr ?? "", tmp) ?? { line: null, message: "syntax error" };
96
+ };
97
+ if (forceModule)
98
+ return tryAs(".mjs");
99
+ const classic = tryAs(".cjs");
100
+ if (!classic)
101
+ return null;
102
+ if (/import|export|module|await/i.test(classic.message))
103
+ return tryAs(".mjs");
104
+ return classic;
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ finally {
110
+ try {
111
+ fs.rmSync(dir, { recursive: true, force: true });
112
+ }
113
+ catch {
114
+ /* ignore */
115
+ }
116
+ }
117
+ }
118
+ function checkHtml(source) {
119
+ // Inline scripts only (no src=), skipping non-JS types (importmap, JSON, templates).
120
+ const re = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
121
+ let m;
122
+ while ((m = re.exec(source))) {
123
+ const attrs = m[1] ?? "";
124
+ if (/\bsrc\s*=/i.test(attrs))
125
+ continue;
126
+ const type = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(attrs)?.[1]?.toLowerCase();
127
+ if (type && !["module", "text/javascript", "application/javascript"].includes(type))
128
+ continue;
129
+ const body = m[2];
130
+ if (!body.trim())
131
+ continue;
132
+ const err = checkJs(body, type === "module");
133
+ if (err) {
134
+ const startLine = source.slice(0, m.index + m[0].indexOf(body)).split("\n").length;
135
+ const at = err.line ? ` at line ${startLine + err.line - 1}` : "";
136
+ return `an inline <script> has a JavaScript syntax error${at}: ${err.message}`;
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ function checkPython(abs) {
142
+ const py = findPython();
143
+ if (!py)
144
+ return null;
145
+ const r = (0, child_process_1.spawnSync)(py, ["-m", "py_compile", abs], {
146
+ encoding: "utf8",
147
+ timeout: CHECK_TIMEOUT_MS,
148
+ windowsHide: true,
149
+ });
150
+ if (r.status === 0 || r.error)
151
+ return null;
152
+ const text = (r.stderr || r.stdout || "").trim().split(/\r?\n/);
153
+ const line = text.map((l) => /line (\d+)/.exec(l)?.[1]).find(Boolean);
154
+ const last = text[text.length - 1] ?? "syntax error";
155
+ return `Python syntax error${line ? ` at line ${line}` : ""}: ${last.replace(/^.*?Error: ?/, "")}`;
156
+ }
157
+ /**
158
+ * Check a just-written file. Returns a short warning sentence, or null when
159
+ * the file parses (or when we have no parser for it). Never throws.
160
+ */
161
+ function syntaxCheck(absPath, relPath) {
162
+ try {
163
+ const ext = path.extname(absPath).toLowerCase();
164
+ if (![".js", ".mjs", ".cjs", ".json", ".html", ".htm", ".py"].includes(ext))
165
+ return null;
166
+ const stat = fs.statSync(absPath);
167
+ if (stat.size > MAX_CHECK_BYTES)
168
+ return null;
169
+ if (ext === ".py")
170
+ return checkPython(absPath);
171
+ const source = fs.readFileSync(absPath, "utf8");
172
+ if (ext === ".json") {
173
+ try {
174
+ JSON.parse(source);
175
+ return null;
176
+ }
177
+ catch (e) {
178
+ return `${relPath} is not valid JSON: ${e.message}`;
179
+ }
180
+ }
181
+ if (ext === ".html" || ext === ".htm") {
182
+ const msg = checkHtml(source);
183
+ return msg ? `${relPath}: ${msg}` : null;
184
+ }
185
+ const err = checkJs(source, ext === ".mjs");
186
+ if (!err)
187
+ return null;
188
+ return `${relPath} has a JavaScript syntax error${err.line ? ` at line ${err.line}` : ""}: ${err.message}`;
189
+ }
190
+ catch {
191
+ return null;
192
+ }
193
+ }
@@ -0,0 +1,417 @@
1
+ "use strict";
2
+ // The five file tools: read_file, write_file, edit_file, list_files, search.
3
+ // Design rules for small models: flat string params, generous coaching in every
4
+ // error message (an error IS a prompt — write it like one), and hard output
5
+ // caps so a single result can't flood a small context window.
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.SandboxError = void 0;
41
+ exports.readFile = readFile;
42
+ exports.writeFile = writeFile;
43
+ exports.editFile = editFile;
44
+ exports.listFiles = listFiles;
45
+ exports.searchFiles = searchFiles;
46
+ const fs = __importStar(require("fs"));
47
+ const path = __importStar(require("path"));
48
+ const sandbox_1 = require("../sandbox");
49
+ Object.defineProperty(exports, "SandboxError", { enumerable: true, get: function () { return sandbox_1.SandboxError; } });
50
+ const util_1 = require("../util");
51
+ const READ_LINE_LIMIT = 250;
52
+ // Must stay under TOOL_RESULT_CAP (10000) so the registry's outer truncateMiddle
53
+ // never silently middle-cuts a read chunk while the trailer claims lines X-Y
54
+ // were shown contiguously.
55
+ const READ_CHAR_LIMIT = 9000;
56
+ const LIST_ENTRY_LIMIT = 200;
57
+ const SEARCH_MATCH_LIMIT = 50;
58
+ const SEARCH_FILE_SIZE_LIMIT = 512 * 1024;
59
+ const IGNORED_DIRS = new Set([
60
+ "node_modules", ".git", "dist", "out", "build", ".next", ".nuxt", ".cache",
61
+ "coverage", "__pycache__", ".venv", "venv", ".idea", ".vscode", "target",
62
+ ".svelte-kit", ".turbo", "vendor",
63
+ ]);
64
+ const BINARY_EXTS = new Set([
65
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".pdf", ".zip",
66
+ ".gz", ".tar", ".7z", ".rar", ".exe", ".dll", ".so", ".dylib", ".bin",
67
+ ".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp3", ".mp4", ".mov", ".avi",
68
+ ".wasm", ".db", ".sqlite", ".jar", ".class", ".pyc",
69
+ ]);
70
+ function isProbablyBinary(filePath) {
71
+ if (BINARY_EXTS.has(path.extname(filePath).toLowerCase()))
72
+ return true;
73
+ try {
74
+ const fd = fs.openSync(filePath, "r");
75
+ const buf = Buffer.alloc(1024);
76
+ const n = fs.readSync(fd, buf, 0, 1024, 0);
77
+ fs.closeSync(fd);
78
+ for (let i = 0; i < n; i++)
79
+ if (buf[i] === 0)
80
+ return true;
81
+ }
82
+ catch {
83
+ return true;
84
+ }
85
+ return false;
86
+ }
87
+ function readFile(root, args) {
88
+ const abs = (0, sandbox_1.resolveInWorkspace)(root, args.path);
89
+ if (!fs.existsSync(abs)) {
90
+ const dir = path.dirname(abs);
91
+ let hint = "";
92
+ if (fs.existsSync(dir)) {
93
+ const near = fs.readdirSync(dir).slice(0, 15).join(", ");
94
+ if (near)
95
+ hint = ` Files that do exist in ${(0, sandbox_1.relPath)(root, dir)}: ${near}`;
96
+ }
97
+ return `Error: file "${args.path}" does not exist.${hint}`;
98
+ }
99
+ const stat = fs.statSync(abs);
100
+ if (stat.isDirectory()) {
101
+ return `Error: "${args.path}" is a folder, not a file. Use list_files with {"path": "${args.path}"} to see what is inside it.`;
102
+ }
103
+ if (isProbablyBinary(abs)) {
104
+ return `Error: "${args.path}" looks like a binary file (${stat.size} bytes) and cannot be read as text.`;
105
+ }
106
+ const content = fs.readFileSync(abs, "utf8");
107
+ const lines = content.split(/\r?\n/);
108
+ const total = lines.length;
109
+ const offset = Math.max(1, Number(args.offset) || 1);
110
+ if (offset > total) {
111
+ return `The file "${args.path}" has only ${total} line${total === 1 ? "" : "s"}; you have already read all of it.`;
112
+ }
113
+ const limit = Math.min(Math.max(1, Number(args.limit) || READ_LINE_LIMIT), 1000);
114
+ const slice = lines.slice(offset - 1, offset - 1 + limit);
115
+ let body = slice.join("\n");
116
+ let end = offset - 1 + slice.length;
117
+ let charCut = false;
118
+ if (body.length > READ_CHAR_LIMIT) {
119
+ const kept = body.slice(0, READ_CHAR_LIMIT).split("\n");
120
+ if (kept.length > 1) {
121
+ // Cut on a line boundary so the trailer never claims a partially-shown
122
+ // line was read.
123
+ kept.pop();
124
+ body = kept.join("\n");
125
+ end = offset - 1 + kept.length;
126
+ charCut = true;
127
+ }
128
+ else {
129
+ // A single line longer than the limit: there is no line boundary to
130
+ // advance to, so a line-based "continue" would loop forever. Serve the
131
+ // head and say so, without a continuation offset.
132
+ body = body.slice(0, READ_CHAR_LIMIT);
133
+ return (body +
134
+ `\n\n[line ${offset} of ${total} is very long; showing its first ${READ_CHAR_LIMIT} characters only.]`);
135
+ }
136
+ }
137
+ if (offset === 1 && end >= total && !charCut)
138
+ return body;
139
+ return (body +
140
+ `\n\n[showing lines ${offset}-${end} of ${total}. Call read_file with {"path": "${args.path}", "offset": ${end + 1}} to continue.]`);
141
+ }
142
+ function writeFile(root, args) {
143
+ const abs = (0, sandbox_1.resolveInWorkspace)(root, args.path);
144
+ if (typeof args.content !== "string") {
145
+ return 'Error: content is required and must be a string. Example: {"path": "notes.txt", "content": "hello"}';
146
+ }
147
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
148
+ return `Error: "${args.path}" is an existing folder; cannot write a file there.`;
149
+ }
150
+ const existed = fs.existsSync(abs);
151
+ const prevLines = existed ? fs.readFileSync(abs, "utf8").split("\n").length : 0;
152
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
153
+ fs.writeFileSync(abs, args.content, "utf8");
154
+ const newLines = args.content.split("\n").length;
155
+ return existed
156
+ ? `Overwrote ${args.path} (was ${prevLines} lines, now ${newLines} lines).`
157
+ : `Created ${args.path} (${newLines} lines).`;
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // edit_file: exact match first, then a line-trimmed (whitespace-forgiving)
161
+ // fallback, then a "closest match" coaching error. Small models paraphrase
162
+ // whitespace constantly; forgiving matching is the difference between a usable
163
+ // and unusable local edit tool.
164
+ // ---------------------------------------------------------------------------
165
+ function findTrimmedMatch(fileLines, oldLines) {
166
+ const targets = oldLines.map((l) => l.trim());
167
+ const matches = [];
168
+ outer: for (let i = 0; i + targets.length <= fileLines.length; i++) {
169
+ for (let j = 0; j < targets.length; j++) {
170
+ if (fileLines[i + j].trim() !== targets[j])
171
+ continue outer;
172
+ }
173
+ matches.push(i);
174
+ }
175
+ return matches;
176
+ }
177
+ function closestSnippet(fileLines, oldText) {
178
+ const firstLine = oldText.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
179
+ if (!firstLine)
180
+ return "";
181
+ const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
182
+ const target = norm(firstLine);
183
+ let bestIdx = -1;
184
+ let bestScore = 0;
185
+ for (let i = 0; i < fileLines.length; i++) {
186
+ const line = norm(fileLines[i]);
187
+ if (!line)
188
+ continue;
189
+ let score = 0;
190
+ if (line === target)
191
+ score = 1000;
192
+ else if (line.includes(target) || target.includes(line))
193
+ score = 500;
194
+ else {
195
+ const words = target.split(" ").filter((w) => w.length > 2);
196
+ for (const w of words)
197
+ if (line.includes(w))
198
+ score += w.length;
199
+ }
200
+ if (score > bestScore) {
201
+ bestScore = score;
202
+ bestIdx = i;
203
+ }
204
+ }
205
+ if (bestIdx < 0 || bestScore < 6)
206
+ return "";
207
+ const start = Math.max(0, bestIdx - 2);
208
+ const end = Math.min(fileLines.length, bestIdx + 3);
209
+ return fileLines.slice(start, end).join("\n");
210
+ }
211
+ function editFile(root, args) {
212
+ const abs = (0, sandbox_1.resolveInWorkspace)(root, args.path);
213
+ if (!fs.existsSync(abs)) {
214
+ return `Error: file "${args.path}" does not exist. Use write_file to create a new file.`;
215
+ }
216
+ const oldText = args.old_text;
217
+ const newText = args.new_text ?? "";
218
+ if (typeof oldText !== "string" || oldText.length === 0) {
219
+ return 'Error: old_text is required — copy the exact text from the file that you want to replace. To create a new file use write_file instead.';
220
+ }
221
+ if (typeof newText !== "string") {
222
+ return "Error: new_text must be a string (use an empty string to delete the old text).";
223
+ }
224
+ const rawContent = fs.readFileSync(abs, "utf8");
225
+ // Normalize to LF for all matching, re-serialize with the file's dominant EOL.
226
+ // Otherwise a model that sends "\n"-separated old_text can never exact-match a
227
+ // CRLF file, and the fallback rebuild leaves the file with mixed line endings.
228
+ const crlf = rawContent.includes("\r\n");
229
+ const content = crlf ? rawContent.replace(/\r\n/g, "\n") : rawContent;
230
+ const oldNorm = oldText.replace(/\r\n/g, "\n");
231
+ const newNorm = newText.replace(/\r\n/g, "\n");
232
+ const serialize = (s) => (crlf ? s.replace(/\n/g, "\r\n") : s);
233
+ // Tier 1: exact match.
234
+ const occurrences = content.split(oldNorm).length - 1;
235
+ if (occurrences === 1) {
236
+ fs.writeFileSync(abs, serialize(content.replace(oldNorm, newNorm)), "utf8");
237
+ return `Edited ${args.path}: replaced 1 occurrence.`;
238
+ }
239
+ if (occurrences > 1) {
240
+ return `Error: old_text appears ${occurrences} times in ${args.path}. Include a few more surrounding lines in old_text so it matches exactly one place.`;
241
+ }
242
+ // Tier 2: line-trimmed match (forgives leading/trailing whitespace per line).
243
+ const fileLines = content.split("\n");
244
+ const oldLines = oldNorm.split("\n");
245
+ const matches = findTrimmedMatch(fileLines, oldLines);
246
+ if (matches.length === 1) {
247
+ const start = matches[0];
248
+ const replaced = [
249
+ ...fileLines.slice(0, start),
250
+ ...newNorm.split("\n"),
251
+ ...fileLines.slice(start + oldLines.length),
252
+ ].join("\n");
253
+ fs.writeFileSync(abs, serialize(replaced), "utf8");
254
+ return `Edited ${args.path}: replaced 1 occurrence (whitespace differences in old_text were ignored).`;
255
+ }
256
+ if (matches.length > 1) {
257
+ return `Error: old_text matches ${matches.length} places in ${args.path} (ignoring whitespace). Include more surrounding lines to make it unique.`;
258
+ }
259
+ // Tier 3: coach with the closest real snippet.
260
+ const snippet = closestSnippet(fileLines, oldText);
261
+ if (snippet) {
262
+ return (`Error: old_text was not found in ${args.path}. The closest matching part of the file is below — copy it EXACTLY (including spaces) as old_text and try again:\n---\n${(0, util_1.truncateEnd)(snippet, 1500)}\n---`);
263
+ }
264
+ return `Error: old_text was not found in ${args.path}. Call read_file on it first and copy the exact text you want to change.`;
265
+ }
266
+ function listFiles(root, args) {
267
+ const startRel = typeof args.path === "string" && args.path.trim() ? args.path : ".";
268
+ const start = (0, sandbox_1.resolveInWorkspace)(root, startRel);
269
+ if (!fs.existsSync(start))
270
+ return `Error: folder "${startRel}" does not exist.`;
271
+ if (!fs.statSync(start).isDirectory()) {
272
+ return `Error: "${startRel}" is a file, not a folder. Use read_file to read it.`;
273
+ }
274
+ const entries = [];
275
+ let truncated = false;
276
+ const walk = (dir, depth) => {
277
+ if (truncated || depth > 6)
278
+ return;
279
+ let names;
280
+ try {
281
+ names = fs.readdirSync(dir, { withFileTypes: true });
282
+ }
283
+ catch {
284
+ return;
285
+ }
286
+ names.sort((a, b) => a.name.localeCompare(b.name));
287
+ for (const e of names) {
288
+ if (truncated)
289
+ return;
290
+ if (e.name.startsWith(".") && e.isDirectory())
291
+ continue;
292
+ if (IGNORED_DIRS.has(e.name))
293
+ continue;
294
+ if (e.isSymbolicLink())
295
+ continue; // never follow links out of the workspace
296
+ const abs = path.join(dir, e.name);
297
+ const rel = (0, sandbox_1.relPath)(root, abs);
298
+ if (e.isDirectory()) {
299
+ entries.push(rel + "/");
300
+ if (entries.length >= LIST_ENTRY_LIMIT) {
301
+ truncated = true;
302
+ return;
303
+ }
304
+ walk(abs, depth + 1);
305
+ }
306
+ else {
307
+ entries.push(rel);
308
+ if (entries.length >= LIST_ENTRY_LIMIT) {
309
+ truncated = true;
310
+ return;
311
+ }
312
+ }
313
+ }
314
+ };
315
+ walk(start, 0);
316
+ if (entries.length === 0)
317
+ return `The folder "${startRel}" is empty.`;
318
+ let out = entries.join("\n");
319
+ if (truncated) {
320
+ out += `\n\n[listing capped at ${LIST_ENTRY_LIMIT} entries. Call list_files with {"path": "<subfolder>"} to explore deeper.]`;
321
+ }
322
+ return out;
323
+ }
324
+ function searchFiles(root, args) {
325
+ const pattern = args.pattern;
326
+ if (typeof pattern !== "string" || !pattern) {
327
+ return 'Error: pattern is required. Example: {"pattern": "function main"}';
328
+ }
329
+ const startRel = typeof args.path === "string" && args.path.trim() ? args.path : ".";
330
+ const start = (0, sandbox_1.resolveInWorkspace)(root, startRel);
331
+ if (!fs.existsSync(start))
332
+ return `Error: folder "${startRel}" does not exist.`;
333
+ let re;
334
+ try {
335
+ re = new RegExp(pattern, "i");
336
+ }
337
+ catch {
338
+ re = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
339
+ }
340
+ const matches = [];
341
+ let filesScanned = 0;
342
+ let done = false;
343
+ const walk = (dir, depth) => {
344
+ if (done || depth > 8)
345
+ return;
346
+ let names;
347
+ try {
348
+ names = fs.readdirSync(dir, { withFileTypes: true });
349
+ }
350
+ catch {
351
+ return;
352
+ }
353
+ for (const e of names) {
354
+ if (done)
355
+ return;
356
+ if (e.name.startsWith(".") && e.isDirectory())
357
+ continue;
358
+ if (IGNORED_DIRS.has(e.name))
359
+ continue;
360
+ // A symlink's Dirent.isDirectory() is false, so a file symlink would
361
+ // otherwise fall straight into readFileSync and leak its target's
362
+ // contents (e.g. creds -> ~/.ssh/id_rsa) into model context. Skip all.
363
+ if (e.isSymbolicLink())
364
+ continue;
365
+ const abs = path.join(dir, e.name);
366
+ if (e.isDirectory()) {
367
+ walk(abs, depth + 1);
368
+ continue;
369
+ }
370
+ if (filesScanned++ > 5000) {
371
+ done = true;
372
+ return;
373
+ }
374
+ let stat;
375
+ try {
376
+ stat = fs.statSync(abs);
377
+ }
378
+ catch {
379
+ continue;
380
+ }
381
+ if (stat.size > SEARCH_FILE_SIZE_LIMIT || isProbablyBinary(abs))
382
+ continue;
383
+ let text;
384
+ try {
385
+ text = fs.readFileSync(abs, "utf8");
386
+ }
387
+ catch {
388
+ continue;
389
+ }
390
+ const lines = text.split(/\r?\n/);
391
+ for (let i = 0; i < lines.length; i++) {
392
+ if (re.test(lines[i])) {
393
+ matches.push(`${(0, sandbox_1.relPath)(root, abs)}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
394
+ if (matches.length >= SEARCH_MATCH_LIMIT) {
395
+ done = true;
396
+ break;
397
+ }
398
+ }
399
+ }
400
+ }
401
+ };
402
+ const startIsFile = fs.statSync(start).isFile();
403
+ if (startIsFile) {
404
+ walk(path.dirname(start), 8); // degenerate case; just scan that dir shallowly
405
+ }
406
+ else {
407
+ walk(start, 0);
408
+ }
409
+ if (matches.length === 0) {
410
+ return `No matches for "${pattern}" in ${startRel}. (Searched ${filesScanned} files. Tip: try a shorter or simpler pattern.)`;
411
+ }
412
+ let out = matches.join("\n");
413
+ if (matches.length >= SEARCH_MATCH_LIMIT) {
414
+ out += `\n\n[stopped at ${SEARCH_MATCH_LIMIT} matches — narrow the pattern or search a subfolder with {"path": "..."}]`;
415
+ }
416
+ return out;
417
+ }