oakode 0.0.0-stage → 0.5.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 (53) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +9 -0
  3. package/README.md +243 -2
  4. package/SECURITY.md +13 -0
  5. package/dist/account.js +34 -0
  6. package/dist/agent.js +570 -0
  7. package/dist/agents/approval.js +116 -0
  8. package/dist/agents/budget.js +293 -0
  9. package/dist/agents/child.js +102 -0
  10. package/dist/agents/host.js +289 -0
  11. package/dist/agents/orchestrator.js +286 -0
  12. package/dist/agents/plan.js +143 -0
  13. package/dist/agents/types.js +20 -0
  14. package/dist/agents/verify.js +94 -0
  15. package/dist/app.js +619 -0
  16. package/dist/auth.js +456 -0
  17. package/dist/brief.js +97 -0
  18. package/dist/commands.js +587 -0
  19. package/dist/compact.js +231 -0
  20. package/dist/config.js +82 -0
  21. package/dist/diff.js +98 -0
  22. package/dist/doctor.js +106 -0
  23. package/dist/errors.js +15 -0
  24. package/dist/fileio.js +688 -0
  25. package/dist/input.js +244 -0
  26. package/dist/jobs.js +262 -0
  27. package/dist/main.js +560 -0
  28. package/dist/mask.js +63 -0
  29. package/dist/mcp.js +721 -0
  30. package/dist/models.js +106 -0
  31. package/dist/modes.js +114 -0
  32. package/dist/oicki.js +475 -0
  33. package/dist/output.js +101 -0
  34. package/dist/permissions.js +389 -0
  35. package/dist/permissions_ui.js +134 -0
  36. package/dist/print.js +76 -0
  37. package/dist/proc.js +63 -0
  38. package/dist/runners.js +264 -0
  39. package/dist/serial.js +9 -0
  40. package/dist/session.js +452 -0
  41. package/dist/shell.js +40 -0
  42. package/dist/skills.js +322 -0
  43. package/dist/syntax.js +80 -0
  44. package/dist/tools.js +480 -0
  45. package/dist/tui/editor.js +279 -0
  46. package/dist/tui/highlight.js +78 -0
  47. package/dist/tui/markdown.js +254 -0
  48. package/dist/tui/screen.js +146 -0
  49. package/dist/tui/tui.js +505 -0
  50. package/dist/tui/view.js +337 -0
  51. package/dist/ui.js +105 -0
  52. package/dist/update.js +64 -0
  53. package/package.json +57 -4
package/dist/fileio.js ADDED
@@ -0,0 +1,688 @@
1
+ /** Workspace-confined file helpers: path safety, text decoding (BOM/CRLF), edit application, atomic writes, grep, ls. */
2
+ import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, statSync, unlinkSync, writeSync } from "node:fs";
3
+ import { randomBytes } from "node:crypto";
4
+ import { renameRetry } from "./config.js";
5
+ import { execFile } from "node:child_process";
6
+ import { diffArrays } from "diff";
7
+ import { promisify } from "node:util";
8
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
9
+ export class ToolError extends Error {
10
+ }
11
+ export const MAX_LINES = 2000;
12
+ export const MAX_BYTES = 50 * 1024;
13
+ const MAX_TEXT_FILE = 5 * 1024 * 1024;
14
+ // ---------------------------------------------------------------- paths
15
+ /** Resolve `p` against cwd; refuse anything (including via symlinks) that lands outside it. */
16
+ export function safePath(cwd, p) {
17
+ if (typeof p !== "string" || !p)
18
+ throw new ToolError("`path` must be a non-empty string");
19
+ if (p.includes("\0"))
20
+ throw new ToolError("`path` contains a NUL byte");
21
+ const root = realpathSync(cwd);
22
+ const abs = resolve(root, p);
23
+ let probe = abs;
24
+ for (;;) {
25
+ try {
26
+ lstatSync(probe);
27
+ break;
28
+ }
29
+ catch {
30
+ const up = dirname(probe);
31
+ if (up === probe)
32
+ break;
33
+ probe = up;
34
+ }
35
+ }
36
+ let real;
37
+ try {
38
+ real = realpathSync(probe);
39
+ }
40
+ catch {
41
+ throw new ToolError(`Cannot resolve path (dangling symlink?): ${p}`);
42
+ }
43
+ if (real !== root && !real.startsWith(root.endsWith(sep) ? root : root + sep)) {
44
+ throw new ToolError(`Path is outside the working directory (${root}): ${p}`);
45
+ }
46
+ return abs;
47
+ }
48
+ /** Workspace-relative path as the model and the user see it: forward slashes on every platform. */
49
+ export const rel = (cwd, abs) => relative(cwd, abs).split(sep).join("/") || ".";
50
+ /**
51
+ * The workspace-relative path of the file a write would really reach: symlinks in the existing part of the path are
52
+ * resolved (a committed `gitlink -> .git` makes `gitlink/config` really `.git/config`), the not-yet-existing rest is
53
+ * appended as written. Permission decisions use this, never the spelling the model chose.
54
+ */
55
+ export function realRel(cwd, abs) {
56
+ const root = realpathSync(cwd);
57
+ const rest = [];
58
+ let probe = abs;
59
+ for (;;) {
60
+ try {
61
+ return rel(root, join(realpathSync(probe), ...rest.reverse()));
62
+ }
63
+ catch {
64
+ const up = dirname(probe);
65
+ if (up === probe)
66
+ return rel(cwd, abs);
67
+ rest.push(basename(probe));
68
+ probe = up;
69
+ }
70
+ }
71
+ }
72
+ /** Only regular files: reading a FIFO or device inside the workspace would hang or misbehave. */
73
+ export function requireFile(p, shown) {
74
+ const st = statSync(p);
75
+ if (st.isDirectory())
76
+ throw new ToolError(`${shown} is a directory; use the ls tool.`);
77
+ if (!st.isFile())
78
+ throw new ToolError(`${shown} is not a regular file`);
79
+ }
80
+ export function optInt(v, name, max = Infinity) {
81
+ if (v === undefined || v === null)
82
+ return undefined;
83
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > max)
84
+ throw new ToolError(`\`${name}\` must be a positive integer${max === Infinity ? "" : ` up to ${max}`}`);
85
+ return v;
86
+ }
87
+ /** Keep the head (for files) or tail (for command output) within line/byte limits. */
88
+ export function truncate(text, from, maxBytes = MAX_BYTES) {
89
+ let lines = text.split("\n");
90
+ let truncated = false;
91
+ if (lines.length > MAX_LINES) {
92
+ lines = from === "head" ? lines.slice(0, MAX_LINES) : lines.slice(-MAX_LINES);
93
+ truncated = true;
94
+ }
95
+ let out = lines.join("\n");
96
+ if (Buffer.byteLength(out) > maxBytes) {
97
+ const buf = Buffer.from(out);
98
+ out = from === "head" ? buf.subarray(0, maxBytes).toString() : buf.subarray(buf.length - maxBytes).toString();
99
+ truncated = true;
100
+ }
101
+ return { text: out, truncated };
102
+ }
103
+ export function readTextFile(path, shown, opts = {}) {
104
+ const buf = readFileSync(path);
105
+ if (buf.length > MAX_TEXT_FILE)
106
+ throw new ToolError(`${shown} is too large (${buf.length} bytes; limit ${MAX_TEXT_FILE}).`);
107
+ if (buf.subarray(0, 8192).includes(0))
108
+ throw new ToolError(`${shown} looks like a binary file (${buf.length} bytes).`);
109
+ const bom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
110
+ let raw;
111
+ try {
112
+ raw = new TextDecoder("utf-8", { fatal: !!opts.strict }).decode(bom ? buf.subarray(3) : buf);
113
+ }
114
+ catch {
115
+ throw new ToolError(`${shown} is not valid UTF-8; refusing to modify it (that would corrupt it).`);
116
+ }
117
+ const crlf = (raw.match(/\r\n/g) ?? []).length;
118
+ const lf = (raw.match(/(?<!\r)\n/g) ?? []).length;
119
+ return { text: raw.replace(/\r\n/g, "\n"), bom, eol: crlf > lf ? "\r\n" : "\n", mixed: crlf > 0 && lf > 0 };
120
+ }
121
+ /** Encode \n-normalized text with the BOM and line endings of the file it replaces. */
122
+ export function encodeLike(orig, text) {
123
+ let out = text.replace(/\r\n/g, "\n");
124
+ if (orig?.eol === "\r\n")
125
+ out = out.replace(/\n/g, "\r\n");
126
+ return orig?.bom && !out.startsWith("") ? "" + out : out;
127
+ }
128
+ /** Write via a temp file in the same directory + rename, preserving mode and following (in-workspace) symlinks. */
129
+ export function atomicWrite(target, data) {
130
+ const real = existsSync(target) ? realpathSync(target) : target;
131
+ const mode = existsSync(real) ? statSync(real).mode & 0o7777 : 0o666;
132
+ const tmp = join(dirname(real), `.${basename(real)}.oakode-${process.pid}-${randomBytes(3).toString("hex")}.tmp`);
133
+ let fd;
134
+ try {
135
+ fd = openSync(tmp, "wx", mode);
136
+ writeSync(fd, data);
137
+ fsyncSync(fd);
138
+ closeSync(fd);
139
+ fd = undefined;
140
+ renameRetry(tmp, real);
141
+ }
142
+ catch (e) {
143
+ if (fd !== undefined)
144
+ try {
145
+ closeSync(fd);
146
+ }
147
+ catch { }
148
+ try {
149
+ unlinkSync(tmp);
150
+ }
151
+ catch { }
152
+ throw e;
153
+ }
154
+ }
155
+ const QUOTES = /[‘’‚‛]/g;
156
+ const DQUOTES = /[“”„‟]/g;
157
+ const DASHES = /[‐-―−]/g;
158
+ const SPACES = /[  -    ]/g;
159
+ const foldChar = (c) => c.replace(QUOTES, "'").replace(DQUOTES, '"').replace(DASHES, "-").replace(SPACES, " ");
160
+ /** Whitespace/typography-tolerant view of `text`: trailing blanks per line removed, smart quotes/dashes folded. Keeps an index map back. */
161
+ function fold(text) {
162
+ let s = "";
163
+ const map = [];
164
+ let ls = 0;
165
+ while (ls <= text.length) {
166
+ let le = text.indexOf("\n", ls);
167
+ if (le < 0)
168
+ le = text.length;
169
+ let te = le;
170
+ while (te > ls && (text[te - 1] === " " || text[te - 1] === "\t"))
171
+ te--;
172
+ for (let i = ls; i < te; i++) {
173
+ s += foldChar(text[i]);
174
+ map.push(i);
175
+ }
176
+ if (le < text.length) {
177
+ s += "\n";
178
+ map.push(le);
179
+ }
180
+ ls = le + 1;
181
+ }
182
+ map.push(text.length);
183
+ return { s, map };
184
+ }
185
+ const lineOf = (text, idx) => text.slice(0, idx).split("\n").length;
186
+ function allIndexes(hay, needle) {
187
+ const out = [];
188
+ for (let i = hay.indexOf(needle); i >= 0; i = hay.indexOf(needle, i + needle.length))
189
+ out.push(i);
190
+ return out;
191
+ }
192
+ /**
193
+ * After a tolerant match, the replacement for the matched file text `orig`: lines the model kept unchanged (equal in
194
+ * old_text and new_text) are taken from the FILE, not from the model, so its quotes, dashes, odd spaces and trailing
195
+ * whitespace survive; only the lines the model actually changed come from new_text. Falls back to new_text verbatim
196
+ * when the lines cannot be aligned.
197
+ */
198
+ function keepUnchangedLines(orig, old, neu) {
199
+ const o = orig.split("\n"), a = old.split("\n"), b = neu.split("\n");
200
+ if (o.length !== a.length)
201
+ return neu;
202
+ const out = [];
203
+ let ai = 0;
204
+ for (const part of diffArrays(a, b)) {
205
+ if (part.added)
206
+ out.push(...part.value);
207
+ else if (part.removed)
208
+ ai += part.count ?? part.value.length;
209
+ else
210
+ for (let k = 0; k < part.value.length; k++)
211
+ out.push(o[ai++]);
212
+ }
213
+ return out.join("\n");
214
+ }
215
+ /** Apply edits; every `old_text` is located in the ORIGINAL text, and none may overlap. */
216
+ export function applyEdits(text, edits) {
217
+ if (!Array.isArray(edits) || !edits.length)
218
+ throw new ToolError("`edits` must be a non-empty array");
219
+ if (edits.length > 50)
220
+ throw new ToolError("At most 50 edits per call");
221
+ let folded;
222
+ const ranges = [];
223
+ const notes = [];
224
+ edits.forEach((e, k) => {
225
+ const n = k + 1;
226
+ const tag = edits.length > 1 ? `edit #${n}: ` : "";
227
+ if (!e || typeof e.old_text !== "string" || typeof e.new_text !== "string")
228
+ throw new ToolError(`${tag}\`old_text\` and \`new_text\` must be strings`);
229
+ const old = e.old_text.replace(/\r\n/g, "\n");
230
+ const neu = e.new_text.replace(/\r\n/g, "\n");
231
+ if (!old)
232
+ throw new ToolError(`${tag}\`old_text\` must not be empty`);
233
+ if (old === neu)
234
+ throw new ToolError(`${tag}\`old_text\` and \`new_text\` are identical`);
235
+ let found = allIndexes(text, old).map((i) => ({ start: i, end: i + old.length }));
236
+ let fuzzy = false;
237
+ if (!found.length) {
238
+ folded ??= fold(text);
239
+ const needle = fold(old).s;
240
+ found = needle ? allIndexes(folded.s, needle).map((i) => ({ start: folded.map[i], end: folded.map[i + needle.length - 1] + 1 })) : [];
241
+ fuzzy = found.length > 0;
242
+ }
243
+ if (!found.length)
244
+ throw new ToolError(`${tag}old_text not found.${notFound(text, old)}`);
245
+ if (found.length > 1 && !e.replace_all) {
246
+ const lines = found.slice(0, 5).map((f) => lineOf(text, f.start)).join(", ");
247
+ throw new ToolError(`${tag}old_text matches ${found.length} places (lines ${lines}${found.length > 5 ? ", …" : ""}). Add surrounding context to make it unique, or set replace_all.`);
248
+ }
249
+ if (fuzzy)
250
+ notes.push(`${tag}matched ignoring trailing whitespace / quote and dash style; lines you did not change were kept as they are in the file`);
251
+ for (const f of found)
252
+ ranges.push({ ...f, neu: fuzzy ? keepUnchangedLines(text.slice(f.start, f.end), old, neu) : neu, i: n });
253
+ });
254
+ ranges.sort((a, b) => a.start - b.start || a.end - b.end);
255
+ for (let k = 1; k < ranges.length; k++) {
256
+ if (ranges[k].start < ranges[k - 1].end) {
257
+ throw new ToolError(`edit #${ranges[k - 1].i} and edit #${ranges[k].i} overlap; merge them into one edit.`);
258
+ }
259
+ }
260
+ let out = "";
261
+ let pos = 0;
262
+ let line = 1; // the line in `out` where the next replacement starts
263
+ const spans = [];
264
+ for (const r of ranges) {
265
+ const before = text.slice(pos, r.start);
266
+ line += before.split("\n").length - 1;
267
+ out += before + r.neu;
268
+ const n = r.neu.split("\n").length - 1;
269
+ spans.push({ from: line, to: line + n - (r.neu.endsWith("\n") && n > 0 ? 1 : 0) });
270
+ line += n;
271
+ pos = r.end;
272
+ }
273
+ out += text.slice(pos);
274
+ if (out === text)
275
+ throw new ToolError("The edits would not change the file.");
276
+ // one span per distinct place (replace_all can hit one line twice), never past the end of the new file
277
+ const last = out.split("\n").length;
278
+ const merged = [];
279
+ for (const x of spans.map((x) => ({ from: Math.min(x.from, last), to: Math.min(x.to, last) }))) {
280
+ const prev = merged.at(-1);
281
+ if (prev && x.from <= prev.to + 1)
282
+ prev.to = Math.max(prev.to, x.to);
283
+ else
284
+ merged.push(x);
285
+ }
286
+ return { text: out, replacements: ranges.length, notes, spans: merged };
287
+ }
288
+ /**
289
+ * Why old_text did not match, with the file's real lines where it should have (numbered, the first line that differs
290
+ * marked), so the next attempt can be fixed without reading the file again.
291
+ */
292
+ function notFound(text, old) {
293
+ const want = old.replace(/\n$/, "").split("\n").slice(0, 200);
294
+ const have = text.split("\n");
295
+ const norm = (l) => foldChar(l).trim();
296
+ const w = want.map(norm);
297
+ const first = w.findIndex(Boolean);
298
+ if (first < 0)
299
+ return " re-read the file.";
300
+ // candidates: exact first-line matches first, then lines containing it; each scored once, over a bounded window
301
+ const exact = [], partial = [];
302
+ for (let i = 0; i < have.length && exact.length < 200; i++) {
303
+ const h = norm(have[i]);
304
+ if (h === w[first])
305
+ exact.push(i);
306
+ else if (partial.length < 200 && h.includes(w[first]))
307
+ partial.push(i);
308
+ }
309
+ const starts = exact.length ? exact : partial;
310
+ if (!starts.length)
311
+ return " Its first line does not appear in the file; re-read the file.";
312
+ const span = Math.min(want.length - first, 50);
313
+ const score = (s) => {
314
+ let n = 0;
315
+ for (let k = 0; k < span; k++)
316
+ if (norm(have[s + k] ?? "") === w[first + k])
317
+ n++;
318
+ return n;
319
+ };
320
+ let best = starts[0], bestScore = -1;
321
+ for (const s of starts) {
322
+ const sc = score(s);
323
+ if (sc > bestScore)
324
+ (best = s), (bestScore = sc);
325
+ }
326
+ const at = best - first;
327
+ const from = Math.max(0, at), to = Math.min(have.length, at + want.length + 2, from + 30);
328
+ const fold = (l) => foldChar(l).trimEnd();
329
+ const diff = want.findIndex((l, k) => fold(have[at + k] ?? "") !== fold(l));
330
+ const cut = (l) => (l.length > 300 ? l.slice(0, 300) + ` … (${l.length - 300} more chars)` : l);
331
+ const lines = have.slice(from, to).map((l, k) => `${from + k === at + diff ? "→" : " "}${String(from + k + 1).padStart(5)}\t${cut(l)}`);
332
+ return ` Its first line appears at line ${best + 1}, but the text after it differs. The file there (→ marks the first line that differs):\n${lines.join("\n")}\nMatch these lines exactly (no need to read the file again).`;
333
+ }
334
+ // ---------------------------------------------------------------- grep / ls (never follow symlinks)
335
+ const SKIP_DIRS = new Set([".git", "node_modules", ".hg", ".svn"]);
336
+ const MAX_GREP_FILE = 2 * 1024 * 1024;
337
+ function globToRegExp(glob) {
338
+ let re = "";
339
+ for (let i = 0; i < glob.length; i++) {
340
+ const c = glob[i];
341
+ if (c === "*") {
342
+ if (glob[i + 1] === "*") {
343
+ i++;
344
+ if (glob[i + 1] === "/") {
345
+ i++;
346
+ re += "(?:.*/)?";
347
+ }
348
+ else
349
+ re += ".*";
350
+ }
351
+ else
352
+ re += "[^/]*";
353
+ }
354
+ else if (c === "?")
355
+ re += "[^/]";
356
+ else if (c === "{") {
357
+ const end = glob.indexOf("}", i);
358
+ if (end < 0)
359
+ re += "\\{";
360
+ else {
361
+ re += "(?:" + glob.slice(i + 1, end).split(",").map((x) => x.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join("|") + ")";
362
+ i = end;
363
+ }
364
+ }
365
+ else
366
+ re += c.replace(/[.+^$()|[\]\\}]/g, "\\$&");
367
+ }
368
+ return new RegExp("^" + re + "$");
369
+ }
370
+ const MAX_LISTED_FILES = 50_000;
371
+ const execFileP = promisify(execFile);
372
+ /**
373
+ * Files under `root` (absolute, sorted). In a git work tree this is `git ls-files` (tracked plus untracked files that
374
+ * .gitignore does not exclude), so build output and dependencies stay out of searches; elsewhere a plain walk that
375
+ * skips .git and node_modules. Symlinks are never listed or followed.
376
+ */
377
+ export async function workspaceFiles(root, signal) {
378
+ const git = await gitFiles(root, signal);
379
+ return git ? { files: git, gitignore: true } : { files: walkFiles(root), gitignore: false };
380
+ }
381
+ /**
382
+ * An absolute path to a program from PATH, never one inside the workspace or from a relative PATH entry: on Windows a
383
+ * plain "git" would also run a git.exe sitting in the working directory, and `npm run` puts node_modules/.bin first.
384
+ */
385
+ export function trustedExecutable(name, root) {
386
+ let ws;
387
+ try {
388
+ ws = realpathSync(root);
389
+ }
390
+ catch {
391
+ return undefined;
392
+ }
393
+ const names = process.platform === "win32" ? [`${name}.exe`] : [name];
394
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
395
+ if (!dir || !isAbsolute(dir))
396
+ continue;
397
+ for (const n of names) {
398
+ const cand = join(dir, n);
399
+ try {
400
+ const real = realpathSync(cand);
401
+ if (real === ws || real.startsWith(ws + sep) || !statSync(real).isFile())
402
+ continue;
403
+ return cand;
404
+ }
405
+ catch { }
406
+ }
407
+ }
408
+ return undefined;
409
+ }
410
+ async function gitFiles(root, signal) {
411
+ const gitBin = trustedExecutable("git", root);
412
+ if (!gitBin)
413
+ return undefined;
414
+ let stdout;
415
+ try {
416
+ // A repository's own config must not run anything: fsmonitor is the one hook-like setting ls-files would honor.
417
+ ({ stdout } = await execFileP(gitBin, ["-c", "core.fsmonitor=false", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
418
+ cwd: root,
419
+ signal,
420
+ timeout: 15_000,
421
+ maxBuffer: 64 * 1024 * 1024,
422
+ encoding: "utf8",
423
+ env: { ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(OICKI|OAKODE)_/i.test(k))), GIT_OPTIONAL_LOCKS: "0" },
424
+ }));
425
+ }
426
+ catch {
427
+ if (signal.aborted)
428
+ throw new ToolError("Interrupted");
429
+ return undefined; // not a git work tree, or no git
430
+ }
431
+ const rootReal = realpathSync(root);
432
+ const dirInside = new Map();
433
+ // An index entry like evil/x with evil -> ~/somewhere is a regular file to lstat: every directory on the way must
434
+ // resolve inside the workspace too.
435
+ const inside = (dir) => {
436
+ let ok = dirInside.get(dir);
437
+ if (ok === undefined) {
438
+ try {
439
+ const real = realpathSync(dir);
440
+ ok = real === rootReal || real.startsWith(rootReal + sep);
441
+ }
442
+ catch {
443
+ ok = false;
444
+ }
445
+ dirInside.set(dir, ok);
446
+ }
447
+ return ok;
448
+ };
449
+ const out = new Set();
450
+ for (const r of stdout.split("\0")) {
451
+ if (!r || r.split("/").some((seg) => SKIP_DIRS.has(seg)))
452
+ continue;
453
+ const full = join(root, r.replace(/\/$/, ""));
454
+ let st;
455
+ try {
456
+ st = lstatSync(full);
457
+ }
458
+ catch {
459
+ continue; // deleted
460
+ }
461
+ if (!inside(dirname(full)))
462
+ continue;
463
+ if (st.isFile())
464
+ out.add(full);
465
+ else if (st.isDirectory())
466
+ for (const f of walkFiles(full))
467
+ out.add(f); // a nested repository or submodule
468
+ if (out.size >= MAX_LISTED_FILES)
469
+ break;
470
+ }
471
+ // Nothing listed (a gitignored directory asked for explicitly, a home-directory dotfiles repo): walk instead.
472
+ return out.size ? [...out].sort() : undefined;
473
+ }
474
+ function walkFiles(root) {
475
+ const files = [];
476
+ const stack = [root];
477
+ while (stack.length && files.length < MAX_LISTED_FILES) {
478
+ const dir = stack.pop();
479
+ let names;
480
+ try {
481
+ names = readdirSync(dir, { withFileTypes: true }).sort((x, y) => x.name.localeCompare(y.name));
482
+ }
483
+ catch {
484
+ continue;
485
+ }
486
+ const subdirs = [];
487
+ for (const d of names) {
488
+ if (d.isSymbolicLink())
489
+ continue; // never followed: nothing outside the workspace is reachable
490
+ const full = join(dir, d.name);
491
+ if (d.isDirectory()) {
492
+ if (!SKIP_DIRS.has(d.name))
493
+ subdirs.push(full);
494
+ }
495
+ else if (d.isFile())
496
+ files.push(full);
497
+ }
498
+ stack.push(...subdirs.reverse());
499
+ }
500
+ return files;
501
+ }
502
+ /** Files under `a.path` (default: the workspace) matching `a.glob`/`a.pattern`; the shared front half of grep and glob. */
503
+ async function selectFiles(cwd, path, glob, signal) {
504
+ const root = safePath(cwd, path ?? ".");
505
+ const st = statSync(root);
506
+ if (st.isFile())
507
+ return { root, files: [root], gitignore: false };
508
+ if (!st.isDirectory())
509
+ throw new ToolError(`${path} is not a file or directory`);
510
+ const { files, gitignore } = await workspaceFiles(root, signal);
511
+ if (typeof glob !== "string" || !glob)
512
+ return { root, files, gitignore };
513
+ const re = globToRegExp(glob);
514
+ const byPath = glob.includes("/");
515
+ return { root, files: files.filter((f) => re.test(byPath ? relative(root, f).split(sep).join("/") : basename(f))), gitignore };
516
+ }
517
+ export async function grepWorkspace(cwd, a, signal) {
518
+ if (typeof a.pattern !== "string" || !a.pattern)
519
+ throw new ToolError("`pattern` must be a non-empty string");
520
+ const max = optInt(a.max_results, "max_results", 500) ?? 100;
521
+ const ctxLines = optInt(a.context, "context", 10) ?? 0;
522
+ const filesOnly = a.files_only === true;
523
+ let re;
524
+ try {
525
+ re = new RegExp(a.literal ? a.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : a.pattern, a.ignore_case ? "i" : "");
526
+ }
527
+ catch (e) {
528
+ throw new ToolError(`Invalid regular expression: ${e.message}`);
529
+ }
530
+ const { files, gitignore } = await selectFiles(cwd, a.path, a.glob, signal);
531
+ const out = [];
532
+ let searched = 0, skipped = 0, matchedFiles = 0, total = 0;
533
+ for (const f of files) {
534
+ if (signal.aborted)
535
+ throw new ToolError("Interrupted");
536
+ if (total >= max)
537
+ break;
538
+ if (searched % 40 === 39)
539
+ await new Promise((r) => setImmediate(r)); // stay responsive to Ctrl+C
540
+ let buf;
541
+ try {
542
+ if (statSync(f).size > MAX_GREP_FILE) {
543
+ skipped++;
544
+ continue;
545
+ }
546
+ buf = readFileSync(f);
547
+ }
548
+ catch {
549
+ skipped++;
550
+ continue;
551
+ }
552
+ if (buf.subarray(0, 8192).includes(0)) {
553
+ skipped++;
554
+ continue;
555
+ }
556
+ searched++;
557
+ const lines = buf.toString("utf8").split("\n").map((l) => l.replace(/\r$/, ""));
558
+ if (lines.at(-1) === "")
559
+ lines.pop(); // the newline that ends the file is not a line
560
+ const hits = [];
561
+ for (let i = 0; i < lines.length && total < max; i++) {
562
+ if (re.test(lines[i].length > 2000 ? lines[i].slice(0, 2000) : lines[i]))
563
+ (hits.push(i), total++);
564
+ }
565
+ if (!hits.length)
566
+ continue;
567
+ matchedFiles++;
568
+ const name = rel(cwd, f);
569
+ if (filesOnly) {
570
+ out.push(`${name} (${hits.length} match${hits.length > 1 ? "es" : ""})`);
571
+ continue;
572
+ }
573
+ const clip = (l) => (l.length > 300 ? l.slice(0, 300) + "…" : l);
574
+ if (!ctxLines) {
575
+ for (const i of hits)
576
+ out.push(`${name}:${i + 1}: ${clip(lines[i])}`);
577
+ continue;
578
+ }
579
+ // with context: like grep -C, matches as path:line:, context as path-line-, groups separated by --
580
+ const hit = new Set(hits);
581
+ let last = -1;
582
+ for (const h of hits) {
583
+ const from = Math.max(h - ctxLines, last + 1);
584
+ if (last >= 0 && from > last + 1)
585
+ out.push("--");
586
+ else if (last < 0 && out.length)
587
+ out.push("--");
588
+ for (let i = from; i <= Math.min(h + ctxLines, lines.length - 1); i++) {
589
+ out.push(`${name}${hit.has(i) ? ":" : "-"}${i + 1}${hit.has(i) ? ":" : "-"} ${clip(lines[i])}`);
590
+ last = i;
591
+ }
592
+ }
593
+ }
594
+ const footer = `[${total} match${total === 1 ? "" : "es"} in ${matchedFiles} file${matchedFiles === 1 ? "" : "s"}; searched ${searched} files${gitignore ? " (.gitignore respected)" : ""}${skipped ? `, skipped ${skipped} binary/large/unreadable` : ""}${total >= max ? `; stopped at max_results=${max}` : ""}]`;
595
+ if (!out.length)
596
+ return `No matches. ${footer}`;
597
+ const { text, truncated } = truncate(out.join("\n"), "head");
598
+ return `${text}\n${footer}${truncated ? " [output truncated]" : ""}`;
599
+ }
600
+ export async function globWorkspace(cwd, a, signal) {
601
+ if (typeof a.pattern !== "string" || !a.pattern)
602
+ throw new ToolError("`pattern` must be a non-empty glob, e.g. **/*.test.ts");
603
+ const { files, gitignore } = await selectFiles(cwd, a.path, a.pattern, signal);
604
+ const LIMIT = 500;
605
+ if (!files.length)
606
+ return `No files match ${a.pattern}.${gitignore ? " (.gitignore respected)" : ""}`;
607
+ const shown = files.slice(0, LIMIT).map((f) => rel(cwd, f));
608
+ return `${shown.join("\n")}\n[${files.length} file${files.length > 1 ? "s" : ""}${files.length > LIMIT ? `; showing the first ${LIMIT}, narrow the pattern or path` : ""}${gitignore ? "; .gitignore respected" : ""}]`;
609
+ }
610
+ export function listDir(cwd, a) {
611
+ const depth = optInt(a.depth, "depth", 3) ?? 1;
612
+ const root = safePath(cwd, a.path ?? ".");
613
+ if (!statSync(root).isDirectory())
614
+ throw new ToolError(`${a.path ?? "."} is not a directory`);
615
+ const lines = [];
616
+ let count = 0;
617
+ const walk = (dir, level) => {
618
+ let ents;
619
+ try {
620
+ ents = readdirSync(dir, { withFileTypes: true });
621
+ }
622
+ catch (e) {
623
+ lines.push(`${" ".repeat(level)}[unreadable: ${e.code}]`);
624
+ return;
625
+ }
626
+ ents.sort((x, y) => Number(y.isDirectory()) - Number(x.isDirectory()) || x.name.localeCompare(y.name));
627
+ for (const d of ents) {
628
+ if (++count > 500)
629
+ return;
630
+ const pad = " ".repeat(level);
631
+ const full = join(dir, d.name);
632
+ if (d.isSymbolicLink()) {
633
+ let target = "?";
634
+ try {
635
+ target = readlinkSync(full);
636
+ }
637
+ catch { }
638
+ lines.push(`${pad}${d.name}@ -> ${target}`);
639
+ }
640
+ else if (d.isDirectory()) {
641
+ lines.push(`${pad}${d.name}/${SKIP_DIRS.has(d.name) ? " (not expanded)" : ""}`);
642
+ if (level + 1 < depth && !SKIP_DIRS.has(d.name))
643
+ walk(full, level + 1);
644
+ }
645
+ else {
646
+ let size = "";
647
+ try {
648
+ size = ` (${statSync(full).size} bytes)`;
649
+ }
650
+ catch { }
651
+ lines.push(`${pad}${d.name}${d.isFile() ? size : " [special file]"}`);
652
+ }
653
+ }
654
+ };
655
+ walk(root, 0);
656
+ if (!lines.length)
657
+ return "(empty directory)";
658
+ return lines.join("\n") + (count > 500 ? "\n[truncated at 500 entries]" : "");
659
+ }
660
+ // ---------------------------------------------------------------- images
661
+ /** Base64 makes this about 2.1 MB, leaving most of OICKI's 4,000,000-byte request limit for the conversation. */
662
+ export const MAX_IMAGE_BYTES = 1.5 * 1024 * 1024;
663
+ /** Recognised by content, not by extension: png, jpeg, gif, webp. */
664
+ export function imageMime(path) {
665
+ let head;
666
+ try {
667
+ const fd = openSync(path, "r");
668
+ try {
669
+ head = Buffer.alloc(12);
670
+ readSync(fd, head, 0, 12, 0);
671
+ }
672
+ finally {
673
+ closeSync(fd);
674
+ }
675
+ }
676
+ catch {
677
+ return undefined;
678
+ }
679
+ if (head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
680
+ return "image/png";
681
+ if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff)
682
+ return "image/jpeg";
683
+ if (head.subarray(0, 4).toString("latin1") === "GIF8")
684
+ return "image/gif";
685
+ if (head.subarray(0, 4).toString("latin1") === "RIFF" && head.subarray(8, 12).toString("latin1") === "WEBP")
686
+ return "image/webp";
687
+ return undefined;
688
+ }