@pentoshi/clai 3.7.9 → 3.8.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 (90) hide show
  1. package/README.md +7 -4
  2. package/dist/commands/update.js +11 -3
  3. package/dist/commands/update.js.map +1 -1
  4. package/dist/prompts/embedded.js +2 -2
  5. package/dist/prompts/embedded.js.map +1 -1
  6. package/dist/prompts/index.js +3 -2
  7. package/dist/prompts/index.js.map +1 -1
  8. package/dist/prompts/system.agent.md +3 -3
  9. package/dist/prompts/system.ask.md +1 -1
  10. package/dist/repl/slash-commands.js +4 -0
  11. package/dist/repl/slash-commands.js.map +1 -1
  12. package/dist/tools/definitions.js +48 -5
  13. package/dist/tools/definitions.js.map +1 -1
  14. package/dist/tools/fs.d.ts +18 -3
  15. package/dist/tools/fs.js +471 -40
  16. package/dist/tools/fs.js.map +1 -1
  17. package/dist/tools/registry.js +10 -1
  18. package/dist/tools/registry.js.map +1 -1
  19. package/dist/tui-v2/actions/format-shortcuts.d.ts +12 -0
  20. package/dist/tui-v2/actions/format-shortcuts.js +257 -0
  21. package/dist/tui-v2/actions/format-shortcuts.js.map +1 -0
  22. package/dist/tui-v2/actions/keymap.js +7 -4
  23. package/dist/tui-v2/actions/keymap.js.map +1 -1
  24. package/dist/tui-v2/app/App.js +77 -21
  25. package/dist/tui-v2/app/App.js.map +1 -1
  26. package/dist/tui-v2/app/command-handlers.js +2 -1
  27. package/dist/tui-v2/app/command-handlers.js.map +1 -1
  28. package/dist/tui-v2/app/commands/config-commands.d.ts +1 -0
  29. package/dist/tui-v2/app/commands/config-commands.js +6 -5
  30. package/dist/tui-v2/app/commands/config-commands.js.map +1 -1
  31. package/dist/tui-v2/app/commands/picker-commands.js +3 -1
  32. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  33. package/dist/tui-v2/app/commands/session-commands.js +19 -2
  34. package/dist/tui-v2/app/commands/session-commands.js.map +1 -1
  35. package/dist/tui-v2/app/plan-lifecycle.js +1 -1
  36. package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
  37. package/dist/tui-v2/components/overlay/overlay-host.js +1 -1
  38. package/dist/tui-v2/components/overlay/overlay-host.js.map +1 -1
  39. package/dist/tui-v2/components/pager/pager-line.d.ts +31 -0
  40. package/dist/tui-v2/components/pager/pager-line.js +184 -0
  41. package/dist/tui-v2/components/pager/pager-line.js.map +1 -0
  42. package/dist/tui-v2/components/pager/pager.d.ts +7 -2
  43. package/dist/tui-v2/components/pager/pager.js +66 -187
  44. package/dist/tui-v2/components/pager/pager.js.map +1 -1
  45. package/dist/tui-v2/components/status/status-line.d.ts +9 -1
  46. package/dist/tui-v2/components/status/status-line.js +3 -3
  47. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  48. package/dist/tui-v2/components/toast/toast-host.d.ts +5 -2
  49. package/dist/tui-v2/components/toast/toast-host.js +63 -28
  50. package/dist/tui-v2/components/toast/toast-host.js.map +1 -1
  51. package/dist/tui-v2/components/transcript/search-bar.d.ts +9 -5
  52. package/dist/tui-v2/components/transcript/search-bar.js +49 -3
  53. package/dist/tui-v2/components/transcript/search-bar.js.map +1 -1
  54. package/dist/tui-v2/components/transcript/transcript-row.d.ts +9 -0
  55. package/dist/tui-v2/components/transcript/transcript-row.js +24 -7
  56. package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
  57. package/dist/tui-v2/components/transcript/transcript-view.js +121 -13
  58. package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
  59. package/dist/tui-v2/components/transcript/use-native-selection-copy.js +7 -4
  60. package/dist/tui-v2/components/transcript/use-native-selection-copy.js.map +1 -1
  61. package/dist/tui-v2/composer/composer-action-port.d.ts +12 -0
  62. package/dist/tui-v2/composer/composer-action-port.js +20 -0
  63. package/dist/tui-v2/composer/composer-action-port.js.map +1 -0
  64. package/dist/tui-v2/composer/composer-editor.js +34 -2
  65. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  66. package/dist/tui-v2/composer/textarea-keybindings.d.ts +5 -3
  67. package/dist/tui-v2/composer/textarea-keybindings.js +29 -2
  68. package/dist/tui-v2/composer/textarea-keybindings.js.map +1 -1
  69. package/dist/tui-v2/controllers/overlay-controller.d.ts +6 -1
  70. package/dist/tui-v2/controllers/overlay-controller.js +2 -1
  71. package/dist/tui-v2/controllers/overlay-controller.js.map +1 -1
  72. package/dist/tui-v2/controllers/toast-controller.d.ts +17 -5
  73. package/dist/tui-v2/controllers/toast-controller.js +33 -7
  74. package/dist/tui-v2/controllers/toast-controller.js.map +1 -1
  75. package/dist/tui-v2/notify.d.ts +21 -0
  76. package/dist/tui-v2/notify.js +27 -0
  77. package/dist/tui-v2/notify.js.map +1 -0
  78. package/dist/tui-v2/rendering/format-help.d.ts +16 -0
  79. package/dist/tui-v2/rendering/format-help.js +95 -0
  80. package/dist/tui-v2/rendering/format-help.js.map +1 -0
  81. package/dist/tui-v2/rendering/pager-markdown.d.ts +40 -0
  82. package/dist/tui-v2/rendering/pager-markdown.js +82 -0
  83. package/dist/tui-v2/rendering/pager-markdown.js.map +1 -0
  84. package/dist/tui-v2/rendering/plan-view.d.ts +1 -2
  85. package/dist/tui-v2/rendering/plan-view.js +39 -55
  86. package/dist/tui-v2/rendering/plan-view.js.map +1 -1
  87. package/dist/version.generated.d.ts +6 -0
  88. package/dist/version.generated.js +7 -0
  89. package/dist/version.generated.js.map +1 -0
  90. package/package.json +5 -1
@@ -13,14 +13,27 @@ export declare function pathInsideSandbox(resolvedPath: string, mode: "read" | "
13
13
  export declare function isOutsideWorkingDirectory(resolvedPath: string): boolean;
14
14
  /** Resolve a tool path for permission checks (tilde + project root). */
15
15
  export declare function resolveFsToolPath(path: string): string;
16
- export declare function fsRead(path: string, options?: {
16
+ export interface FsReadOptions {
17
17
  maxBytes?: number | undefined;
18
18
  confirmed?: boolean | undefined;
19
- /** 1-indexed first line to return (inclusive). Lets the model page a large file instead of re-reading the whole thing. */
19
+ /** 1-indexed first line to return (inclusive). */
20
20
  offset?: number | undefined;
21
21
  /** Max number of lines to return from `offset`. */
22
22
  limit?: number | undefined;
23
- }): Promise<ToolResult>;
23
+ /** Alias for `offset` (1-indexed inclusive). */
24
+ startLine?: number | undefined;
25
+ /** Inclusive end line; implies a line window when set. */
26
+ endLine?: number | undefined;
27
+ /** Regex source (no surrounding slashes). Return matching windows with context. */
28
+ pattern?: string | undefined;
29
+ /** Lines of context each side of a pattern match (default 2, max 20). */
30
+ context?: number | undefined;
31
+ /** Max pattern matches to return (default 20, hard max 100). */
32
+ maxMatches?: number | undefined;
33
+ /** Case-insensitive pattern match. */
34
+ caseInsensitive?: boolean | undefined;
35
+ }
36
+ export declare function fsRead(path: string, options?: FsReadOptions): Promise<ToolResult>;
24
37
  export declare function fsWrite(path: string, content: string, options?: {
25
38
  confirmed?: boolean | undefined;
26
39
  }): Promise<ToolResult>;
@@ -51,6 +64,8 @@ export declare function fsList(path: string, options?: {
51
64
  }): Promise<ToolResult>;
52
65
  export declare function fsSearch(pattern: string, path?: string, options?: {
53
66
  confirmed?: boolean | undefined;
67
+ /** Max matching lines to return (default 50, hard cap 200). */
68
+ maxMatches?: number | undefined;
54
69
  }): Promise<ToolResult>;
55
70
  /**
56
71
  * Atomic search-and-replace edit. Reads the file, validates the match
package/dist/tools/fs.js CHANGED
@@ -1,7 +1,8 @@
1
- import { lstatSync, readlinkSync, realpathSync } from "node:fs";
1
+ import { createReadStream, lstatSync, readlinkSync, realpathSync } from "node:fs";
2
2
  import { open, readdir, readFile, writeFile, unlink, rm, rename, mkdir, stat } from "node:fs/promises";
3
3
  import { join, dirname, basename, relative, resolve } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
+ import { createInterface } from "node:readline";
5
6
  import { homedir, tmpdir } from "node:os";
6
7
  import { execa } from "execa";
7
8
  import { getConfig } from "../store/config.js";
@@ -28,13 +29,24 @@ function describeWrite(path, content, verb) {
28
29
  ` ends_with: ${JSON.stringify(tail)}\n` +
29
30
  ` Do NOT re-read this file to verify the write unless editing further — trust this receipt.`);
30
31
  }
31
- // Read the WHOLE file by default. Models repeatedly complained that fs.read
32
- // returned a truncated body and then wasted turns re-reading with other
33
- // methods, so the cap is set high enough to return any normal source/text
34
- // file in one shot. Only genuinely huge files (logs, dumps, minified bundles)
35
- // exceed it, and those should be paged with offset/limit on purpose.
32
+ // Full-file soft caps. Normal source files fit; logs/dumps/minified bundles
33
+ // auto-page with a head window + next-offset instructions instead of dumping
34
+ // megabytes into context. Hard byte ceiling still applies for raw full reads.
36
35
  const DEFAULT_READ_MAX_BYTES = 8 * 1024 * 1024;
36
+ /** Soft auto-head when full read would blow the budget (bytes). */
37
+ const SOFT_FULL_READ_BYTES = 256 * 1024;
38
+ /** Soft auto-head when file has more than this many lines. */
39
+ const SOFT_FULL_READ_LINES = 2000;
40
+ /** Default lines for auto-head / default line-window limit. */
41
+ const DEFAULT_LINE_WINDOW = 200;
37
42
  const DEFAULT_LIST_MAX_ENTRIES = 500;
43
+ /** Pattern scan hard stop (bytes streamed). */
44
+ const PATTERN_SCAN_MAX_BYTES = 32 * 1024 * 1024;
45
+ const DEFAULT_PATTERN_MAX_MATCHES = 20;
46
+ const HARD_PATTERN_MAX_MATCHES = 100;
47
+ const DEFAULT_PATTERN_CONTEXT = 2;
48
+ const HARD_PATTERN_CONTEXT = 20;
49
+ const BINARY_SAMPLE_BYTES = 8192;
38
50
  function expandHome(path) {
39
51
  if (path === "~")
40
52
  return homedir();
@@ -160,10 +172,338 @@ function ensureWriteAllowed(path, confirmed) {
160
172
  void confirmed;
161
173
  return resolved;
162
174
  }
175
+ /** Stream file lines without loading the whole file into memory. */
176
+ async function* iterateFileLines(resolved) {
177
+ const stream = createReadStream(resolved, { encoding: "utf8" });
178
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
179
+ try {
180
+ for await (const line of rl) {
181
+ yield line;
182
+ }
183
+ }
184
+ finally {
185
+ rl.close();
186
+ stream.destroy();
187
+ }
188
+ }
189
+ async function sampleLooksBinary(resolved) {
190
+ const handle = await open(resolved, "r");
191
+ try {
192
+ const buf = Buffer.alloc(BINARY_SAMPLE_BYTES);
193
+ const { bytesRead } = await handle.read(buf, 0, BINARY_SAMPLE_BYTES, 0);
194
+ if (bytesRead === 0)
195
+ return false;
196
+ const sample = buf.subarray(0, bytesRead);
197
+ // NUL in the first chunk → almost certainly binary.
198
+ if (sample.includes(0))
199
+ return true;
200
+ // High ratio of non-text control bytes (excluding tab/lf/cr).
201
+ let control = 0;
202
+ for (let i = 0; i < sample.length; i += 1) {
203
+ const c = sample[i];
204
+ if (c < 9 || (c > 13 && c < 32) || c === 127)
205
+ control += 1;
206
+ }
207
+ return control / sample.length > 0.1;
208
+ }
209
+ finally {
210
+ await handle.close().catch(() => undefined);
211
+ }
212
+ }
213
+ /**
214
+ * Normalize pattern strings models commonly emit:
215
+ * - `/foo/i` or `/foo/gim` → body + flags
216
+ * - bare `foo` → source as-is
217
+ * Never throws; invalid regex returns a clear error the model can fix.
218
+ */
219
+ function compileReadPattern(source, caseInsensitive) {
220
+ let trimmed = source.trim();
221
+ if (!trimmed) {
222
+ return {
223
+ ok: false,
224
+ error: 'fs.read pattern must be a non-empty string. Examples: "function\\\\s+foo", "export function handle", or "/TODO/i". Do not pass an empty pattern.',
225
+ };
226
+ }
227
+ let flags = caseInsensitive ? "i" : "";
228
+ // Accept /pattern/flags form that models often copy from editors.
229
+ const slashForm = trimmed.match(/^\/([\s\S]+)\/([gimsuy]*)$/);
230
+ if (slashForm) {
231
+ trimmed = slashForm[1];
232
+ const fromSlash = slashForm[2] ?? "";
233
+ // Drop global — we scan line-by-line; g would make lastIndex sticky bugs.
234
+ flags = [...new Set(`${flags}${fromSlash}`.replace(/g/g, "").split(""))].join("");
235
+ }
236
+ if (!trimmed) {
237
+ return {
238
+ ok: false,
239
+ error: 'fs.read pattern body is empty after stripping /…/ delimiters. Pass a real pattern, e.g. "class\\\\s+App".',
240
+ };
241
+ }
242
+ try {
243
+ return { ok: true, re: new RegExp(trimmed, flags || undefined) };
244
+ }
245
+ catch (error) {
246
+ const msg = error instanceof Error ? error.message : String(error);
247
+ return {
248
+ ok: false,
249
+ error: `Invalid regex pattern: ${msg}. ` +
250
+ `Pass a JS regex source (escape special chars) or /pattern/flags. ` +
251
+ `For literal text with dots/parens, escape them (e.g. "foo\\\\.bar\\\\(") or use fs.search then fs.read with offset around the hit line.`,
252
+ };
253
+ }
254
+ }
255
+ function resolveLineWindow(options) {
256
+ const hasStart = typeof options.startLine === "number" || typeof options.offset === "number";
257
+ const hasEnd = typeof options.endLine === "number";
258
+ const hasLimit = typeof options.limit === "number";
259
+ if (!hasStart && !hasEnd && !hasLimit) {
260
+ return { ok: true, start: 1, limit: DEFAULT_LINE_WINDOW };
261
+ }
262
+ let start = 1;
263
+ let note;
264
+ if (typeof options.startLine === "number" && typeof options.offset === "number") {
265
+ if (options.startLine !== options.offset) {
266
+ note = `note: both startLine=${options.startLine} and offset=${options.offset} set; using startLine`;
267
+ }
268
+ start = options.startLine;
269
+ }
270
+ else if (typeof options.startLine === "number") {
271
+ start = options.startLine;
272
+ }
273
+ else if (typeof options.offset === "number") {
274
+ start = options.offset;
275
+ }
276
+ else if (hasEnd) {
277
+ // endLine alone: treat as lines 1..endLine
278
+ start = 1;
279
+ }
280
+ if (!Number.isFinite(start)) {
281
+ return { ok: false, error: "fs.read startLine/offset must be a number" };
282
+ }
283
+ start = Math.floor(start);
284
+ // Models often send 0-based offsets; lines are 1-indexed — coerce gently.
285
+ if (start === 0) {
286
+ start = 1;
287
+ note = note
288
+ ? `${note}; offset/startLine 0 treated as 1 (lines are 1-indexed)`
289
+ : "note: offset/startLine 0 treated as 1 (lines are 1-indexed)";
290
+ }
291
+ else if (start < 1) {
292
+ return {
293
+ ok: false,
294
+ error: "fs.read startLine/offset must be an integer >= 1 (or 0, treated as 1)",
295
+ };
296
+ }
297
+ let limit;
298
+ if (hasEnd) {
299
+ const end = options.endLine;
300
+ if (!Number.isInteger(end) && !Number.isFinite(end)) {
301
+ return { ok: false, error: "fs.read endLine must be a number" };
302
+ }
303
+ const endLine = Math.floor(end);
304
+ if (endLine < start) {
305
+ return {
306
+ ok: false,
307
+ error: `fs.read requires startLine/offset <= endLine (got ${start}..${endLine})`,
308
+ };
309
+ }
310
+ limit = endLine - start + 1;
311
+ if (hasLimit && options.limit > 0 && options.limit < limit) {
312
+ limit = Math.floor(options.limit);
313
+ note = note
314
+ ? `${note}; limit=${limit} caps endLine window`
315
+ : `note: limit=${limit} caps endLine window`;
316
+ }
317
+ }
318
+ else if (hasLimit && options.limit > 0) {
319
+ limit = Math.floor(options.limit);
320
+ }
321
+ else {
322
+ limit = DEFAULT_LINE_WINDOW;
323
+ }
324
+ if (limit < 1) {
325
+ return { ok: false, error: "fs.read limit must be a positive integer" };
326
+ }
327
+ // Hard cap per call so a bad limit cannot dump millions of lines.
328
+ limit = Math.min(limit, 5000);
329
+ return note ? { ok: true, start, limit, note } : { ok: true, start, limit };
330
+ }
331
+ async function readLineWindow(resolved, start, limit, fileBytes, note) {
332
+ const collected = [];
333
+ let lineNo = 0;
334
+ let totalLines = 0;
335
+ let reachedEnd = true;
336
+ for await (const line of iterateFileLines(resolved)) {
337
+ lineNo += 1;
338
+ totalLines = lineNo;
339
+ if (lineNo < start)
340
+ continue;
341
+ if (collected.length < limit) {
342
+ collected.push(`${lineNo}: ${line}`);
343
+ }
344
+ else {
345
+ // Keep counting remaining lines for accurate "of N" footer when cheap.
346
+ // For huge files we still stream once; stop counting past a soft ceiling
347
+ // after the window is full so we don't burn CPU on multi-GB logs.
348
+ if (lineNo >= start + limit + 200_000) {
349
+ reachedEnd = false;
350
+ break;
351
+ }
352
+ }
353
+ }
354
+ if (collected.length === 0) {
355
+ const header = `# fs.read path=${resolved} bytes=${fileBytes}\n` +
356
+ (totalLines === 0
357
+ ? `# file is empty\n`
358
+ : `# requested lines ${start}+ but file has only ${totalLines} line(s)\n`) +
359
+ `# next: use a smaller offset, or omit offset/limit for auto-head on large files`;
360
+ return {
361
+ ok: true,
362
+ output: note ? `${header}\n# ${note}` : header,
363
+ truncated: false,
364
+ };
365
+ }
366
+ const first = start;
367
+ const last = start + collected.length - 1;
368
+ const hasMore = !reachedEnd || totalLines > last;
369
+ const totalLabel = reachedEnd ? String(totalLines) : `${totalLines}+`;
370
+ const header = `# fs.read path=${resolved} lines=${first}-${last} of ${totalLabel} bytes=${fileBytes}` +
371
+ (note ? `\n# ${note}` : "");
372
+ const next = hasMore
373
+ ? `\n# hasMore=true next=${JSON.stringify({ offset: last + 1, limit })}`
374
+ : `\n# hasMore=false`;
375
+ return {
376
+ ok: true,
377
+ output: `${header}\n${collected.join("\n")}${next}`,
378
+ truncated: hasMore,
379
+ };
380
+ }
381
+ async function readByPattern(resolved, options, fileBytes) {
382
+ const compiled = compileReadPattern(options.pattern ?? "", options.caseInsensitive);
383
+ if (!compiled.ok) {
384
+ return { ok: false, output: compiled.error, exitCode: 1 };
385
+ }
386
+ const re = compiled.re;
387
+ const context = Math.min(HARD_PATTERN_CONTEXT, Math.max(0, Math.floor(options.context ?? DEFAULT_PATTERN_CONTEXT)));
388
+ const maxMatches = Math.min(HARD_PATTERN_MAX_MATCHES, Math.max(1, Math.floor(options.maxMatches ?? DEFAULT_PATTERN_MAX_MATCHES)));
389
+ // Optional range filter when offset/startLine/endLine also provided.
390
+ let rangeStart = 1;
391
+ let rangeEnd = Number.POSITIVE_INFINITY;
392
+ if (typeof options.offset === "number" ||
393
+ typeof options.startLine === "number" ||
394
+ typeof options.endLine === "number" ||
395
+ typeof options.limit === "number") {
396
+ const win = resolveLineWindow(options);
397
+ if (!win.ok)
398
+ return { ok: false, output: win.error, exitCode: 1 };
399
+ rangeStart = win.start;
400
+ rangeEnd = win.start + win.limit - 1;
401
+ }
402
+ // Ring buffer of recent lines for leading context.
403
+ const ring = [];
404
+ const matchBlocks = [];
405
+ let matches = 0;
406
+ let lineNo = 0;
407
+ let bytesSeen = 0;
408
+ let truncatedScan = false;
409
+ /** Lines still needed as trailing context after a match. */
410
+ let pendingAfter = 0;
411
+ let currentBlock = [];
412
+ const flushBlock = () => {
413
+ if (currentBlock.length === 0)
414
+ return;
415
+ matchBlocks.push(currentBlock.join("\n"));
416
+ currentBlock = [];
417
+ };
418
+ for await (const line of iterateFileLines(resolved)) {
419
+ lineNo += 1;
420
+ bytesSeen += Buffer.byteLength(line, "utf8") + 1;
421
+ if (bytesSeen > PATTERN_SCAN_MAX_BYTES) {
422
+ truncatedScan = true;
423
+ break;
424
+ }
425
+ // Maintain ring for context-before (only lines we might need).
426
+ ring.push(line);
427
+ if (ring.length > context + 1)
428
+ ring.shift();
429
+ const inRange = lineNo >= rangeStart && lineNo <= rangeEnd;
430
+ const isMatch = inRange && re.test(line);
431
+ if (pendingAfter > 0 && !isMatch) {
432
+ currentBlock.push(`${lineNo}: ${line}`);
433
+ pendingAfter -= 1;
434
+ if (pendingAfter === 0)
435
+ flushBlock();
436
+ // After draining trailing context for the last shown match, keep
437
+ // scanning only long enough to learn whether more matches exist.
438
+ if (matches >= maxMatches && pendingAfter === 0) {
439
+ // fall through to isMatch checks below on later lines
440
+ }
441
+ continue;
442
+ }
443
+ if (isMatch && matches < maxMatches) {
444
+ // If we were still emitting after-context, close previous block first
445
+ // only when this match is outside that window; otherwise merge.
446
+ if (pendingAfter === 0 && currentBlock.length > 0)
447
+ flushBlock();
448
+ if (pendingAfter === 0) {
449
+ // Leading context from ring (exclude current line, last is current).
450
+ const before = ring.slice(0, Math.max(0, ring.length - 1));
451
+ const startCtx = before.slice(Math.max(0, before.length - context));
452
+ const ctxStartLine = lineNo - startCtx.length;
453
+ for (let i = 0; i < startCtx.length; i += 1) {
454
+ currentBlock.push(`${ctxStartLine + i}: ${startCtx[i]}`);
455
+ }
456
+ }
457
+ currentBlock.push(`${lineNo}: ${line}`);
458
+ matches += 1;
459
+ pendingAfter = context;
460
+ if (pendingAfter === 0)
461
+ flushBlock();
462
+ // Do not break yet: keep scanning so hasMore can detect further hits.
463
+ continue;
464
+ }
465
+ if (isMatch && matches >= maxMatches) {
466
+ // One more hit beyond the display cap → hasMore.
467
+ matches += 1;
468
+ break;
469
+ }
470
+ }
471
+ if (pendingAfter > 0)
472
+ flushBlock();
473
+ const capped = matches > maxMatches;
474
+ const shown = Math.min(matches, maxMatches);
475
+ const header = `# fs.read path=${resolved} pattern=${JSON.stringify(options.pattern)} ` +
476
+ `matches=${shown}${capped ? `+` : ""} ` +
477
+ `context=${context} bytes=${fileBytes}` +
478
+ (truncatedScan
479
+ ? `\n# scan stopped at ${PATTERN_SCAN_MAX_BYTES} bytes (file large; narrow with startLine/endLine or use fs.search)`
480
+ : "") +
481
+ (rangeEnd !== Number.POSITIVE_INFINITY
482
+ ? `\n# searched lines ${rangeStart}-${rangeEnd === Number.POSITIVE_INFINITY ? "∞" : rangeEnd}`
483
+ : "");
484
+ if (shown === 0) {
485
+ return {
486
+ ok: true,
487
+ output: `${header}\n# no matches. Try a simpler pattern, caseInsensitive:true, or fs.search for multi-file hits.\n` +
488
+ `# tip: fs.read with offset/limit to page, or omit pattern for full/auto-head read`,
489
+ truncated: truncatedScan,
490
+ };
491
+ }
492
+ const body = matchBlocks.join("\n--\n");
493
+ const footer = capped
494
+ ? `\n# hasMore=true (capped at maxMatches=${maxMatches}; raise maxMatches up to ${HARD_PATTERN_MAX_MATCHES} or narrow the range)`
495
+ : `\n# hasMore=false`;
496
+ return {
497
+ ok: true,
498
+ output: `${header}\n${body}${footer}`,
499
+ truncated: truncatedScan || capped,
500
+ };
501
+ }
163
502
  export async function fsRead(path, options = {}) {
164
503
  const resolved = resolvePath(path);
165
504
  ensureReadAllowed(resolved, path, options.confirmed);
166
505
  // Directory → list contents (models often fs.read a folder by mistake).
506
+ let fileBytes = 0;
167
507
  try {
168
508
  const st = await stat(resolved);
169
509
  if (st.isDirectory()) {
@@ -185,48 +525,67 @@ export async function fsRead(path, options = {}) {
185
525
  exitCode: 1,
186
526
  };
187
527
  }
528
+ fileBytes = st.size;
188
529
  }
189
530
  catch (error) {
190
531
  const msg = error instanceof Error ? error.message : String(error);
191
532
  return { ok: false, output: msg, exitCode: 1 };
192
533
  }
193
- const maxBytes = options.maxBytes ?? DEFAULT_READ_MAX_BYTES;
194
- const useLines = typeof options.offset === "number" || typeof options.limit === "number";
195
- if (useLines) {
196
- const offset = Math.max(1, options.offset ?? 1);
197
- const limit = options.limit && options.limit > 0 ? options.limit : 2000;
198
- const full = await readFile(resolved, "utf8");
199
- const lines = full.split(/\r?\n/);
200
- const totalLines = lines.length;
201
- const startIdx = Math.min(offset - 1, totalLines);
202
- const endIdx = Math.min(startIdx + limit, totalLines);
203
- const slice = lines.slice(startIdx, endIdx);
204
- const numbered = slice.map((line, i) => `${startIdx + i + 1}: ${line}`);
205
- const hasMore = endIdx < totalLines;
206
- const prefix = startIdx > 0 ? `[lines ${startIdx + 1}-${endIdx} of ${totalLines}]\n` : "";
207
- const suffix = hasMore
208
- ? `\n... (${totalLines - endIdx} more line(s); call fs.read with offset=${endIdx + 1} to continue)`
209
- : "";
534
+ if (await sampleLooksBinary(resolved)) {
210
535
  return {
211
- ok: true,
212
- output: `${prefix}${numbered.join("\n")}${suffix}`,
213
- truncated: hasMore,
536
+ ok: false,
537
+ output: `Binary or non-text file: ${resolved} (${fileBytes} bytes). ` +
538
+ `Use shell tools for hex/binary inspection, or fs.read with maxBytes only after confirming it is text.`,
539
+ exitCode: 1,
214
540
  };
215
541
  }
542
+ // 1) Pattern mode
543
+ if (typeof options.pattern === "string") {
544
+ return readByPattern(resolved, options, fileBytes);
545
+ }
546
+ // 2) Explicit line window
547
+ const wantsWindow = typeof options.offset === "number" ||
548
+ typeof options.limit === "number" ||
549
+ typeof options.startLine === "number" ||
550
+ typeof options.endLine === "number";
551
+ if (wantsWindow) {
552
+ const win = resolveLineWindow(options);
553
+ if (!win.ok)
554
+ return { ok: false, output: win.error, exitCode: 1 };
555
+ return readLineWindow(resolved, win.start, win.limit, fileBytes, win.note);
556
+ }
557
+ // 3) Auto-head for large files (soft byte/line budget)
558
+ const maxBytes = options.maxBytes ?? DEFAULT_READ_MAX_BYTES;
559
+ if (fileBytes > SOFT_FULL_READ_BYTES) {
560
+ const result = await readLineWindow(resolved, 1, DEFAULT_LINE_WINDOW, fileBytes, `auto-head: file is ${fileBytes} bytes (>${SOFT_FULL_READ_BYTES}); returning first ${DEFAULT_LINE_WINDOW} lines. ` +
561
+ `Use offset/limit, startLine/endLine, or pattern= to fetch more without loading the whole file.`);
562
+ return { ...result, truncated: true };
563
+ }
564
+ // Full read with hard byte cap (stream into string up to maxBytes).
216
565
  const handle = await open(resolved, "r");
217
566
  try {
218
567
  const st = await handle.stat();
219
568
  const cap = Math.min(st.size, maxBytes);
220
569
  const buffer = Buffer.alloc(cap);
221
570
  const { bytesRead } = await handle.read(buffer, 0, cap, 0);
222
- const truncated = st.size > maxBytes;
223
571
  const text = buffer.subarray(0, bytesRead).toString("utf8");
572
+ // Soft line cap even when under byte soft limit (huge single-line minified
573
+ // is handled by bytes; many-line small files by counting).
574
+ const lineCount = text.length === 0 ? 0 : text.split(/\r?\n/).length;
575
+ if (lineCount > SOFT_FULL_READ_LINES && st.size <= maxBytes) {
576
+ // Re-read as window instead of dumping 2k+ lines.
577
+ return readLineWindow(resolved, 1, DEFAULT_LINE_WINDOW, fileBytes, `auto-head: file has ${lineCount} lines (>${SOFT_FULL_READ_LINES}); returning first ${DEFAULT_LINE_WINDOW}. ` +
578
+ `Use offset/limit or pattern= for the rest.`);
579
+ }
580
+ const truncated = st.size > maxBytes;
224
581
  const suffix = truncated
225
- ? `\n... (truncated at ${maxBytes.toLocaleString()} bytes of ${st.size.toLocaleString()} — the file is larger than the read cap; call fs.read with offset=1 and limit=N to page through it in line ranges instead of re-reading the whole file)`
582
+ ? `\n# truncated at ${maxBytes.toLocaleString()} bytes of ${st.size.toLocaleString()} — page with offset/limit or pattern= instead of re-reading the whole file`
226
583
  : "";
227
584
  return {
228
585
  ok: true,
229
- output: `${text}${suffix}`,
586
+ output: truncated
587
+ ? `# fs.read path=${resolved} bytes=${fileBytes} truncated=true\n${text}${suffix}`
588
+ : text,
230
589
  truncated,
231
590
  };
232
591
  }
@@ -442,29 +801,101 @@ export async function fsList(path, options = {}) {
442
801
  export async function fsSearch(pattern, path = safeCwd(), options = {}) {
443
802
  const resolved = resolvePath(path);
444
803
  ensureReadAllowed(resolved, path, options.confirmed);
445
- const maxLines = 50;
804
+ const maxMatches = Math.min(200, Math.max(1, Math.floor(options.maxMatches ?? 50)));
805
+ if (!pattern.trim()) {
806
+ return {
807
+ ok: false,
808
+ output: 'fs.search requires a non-empty "pattern"',
809
+ exitCode: 1,
810
+ };
811
+ }
812
+ // Prefer content hits (path:line:text) so the model can jump to fs.read
813
+ // with offset around interesting lines — not just file names.
446
814
  try {
447
- const result = await execa("rg", ["--max-count", "5", "--max-filesize", "1M", "-l", pattern, resolved], {
815
+ const result = await execa("rg", [
816
+ "--line-number",
817
+ "--no-heading",
818
+ "--color",
819
+ "never",
820
+ // Cap hits per file so one noisy log cannot fill the budget alone.
821
+ "--max-count",
822
+ "20",
823
+ "--max-filesize",
824
+ "1M",
825
+ "--max-columns",
826
+ "300",
827
+ "--max-columns-preview",
828
+ // Global-ish budget via head_limit on our side after the fact.
829
+ pattern,
830
+ resolved,
831
+ ], {
448
832
  reject: false,
449
833
  all: true,
450
834
  timeout: 15_000,
451
835
  });
452
- return {
453
- ok: result.exitCode === 0,
454
- output: result.all ?? "",
455
- exitCode: result.exitCode,
456
- };
836
+ // rg exit 1 = no matches (still ok for the model); 2 = error
837
+ if (result.exitCode === 0 || result.exitCode === 1) {
838
+ const body = (result.all ?? "").trim();
839
+ if (!body) {
840
+ return {
841
+ ok: true,
842
+ output: `# fs.search pattern=${JSON.stringify(pattern)} path=${resolved}\n# no matches`,
843
+ exitCode: 0,
844
+ };
845
+ }
846
+ const allLines = body.split("\n").filter(Boolean);
847
+ const lines = allLines.slice(0, maxMatches);
848
+ const truncated = allLines.length > maxMatches;
849
+ return {
850
+ ok: true,
851
+ output: `# fs.search pattern=${JSON.stringify(pattern)} path=${resolved} hits=${lines.length}` +
852
+ (truncated ? ` (capped at ${maxMatches})` : "") +
853
+ `\n# tip: fs.read path=… offset=<line> limit=… or pattern= for a focused window\n` +
854
+ lines.join("\n"),
855
+ exitCode: 0,
856
+ truncated,
857
+ };
858
+ }
859
+ // Fall through to grep on rg hard failure.
457
860
  }
458
861
  catch {
459
- const result = await execa("grep", ["-R", "-l", "-m", String(maxLines), pattern, resolved], {
862
+ // rg missing try grep
863
+ }
864
+ try {
865
+ const result = await execa("grep", ["-R", "-n", "-I", "-m", String(maxMatches), "--", pattern, resolved], {
460
866
  reject: false,
461
867
  all: true,
462
868
  timeout: 15_000,
463
869
  });
870
+ const body = (result.all ?? "").trim();
871
+ if (!body || result.exitCode === 1) {
872
+ return {
873
+ ok: true,
874
+ output: `# fs.search pattern=${JSON.stringify(pattern)} path=${resolved}\n# no matches`,
875
+ exitCode: 0,
876
+ };
877
+ }
878
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
879
+ return {
880
+ ok: false,
881
+ output: body || `fs.search failed (exit ${result.exitCode})`,
882
+ exitCode: result.exitCode ?? 1,
883
+ };
884
+ }
464
885
  return {
465
- ok: result.exitCode === 0,
466
- output: result.all ?? "",
467
- exitCode: result.exitCode,
886
+ ok: true,
887
+ output: `# fs.search pattern=${JSON.stringify(pattern)} path=${resolved}\n` +
888
+ `# tip: fs.read path=… offset=<line> limit=… for a focused window\n` +
889
+ body,
890
+ exitCode: 0,
891
+ };
892
+ }
893
+ catch (error) {
894
+ const msg = error instanceof Error ? error.message : String(error);
895
+ return {
896
+ ok: false,
897
+ output: `fs.search failed (need ripgrep or grep): ${msg}`,
898
+ exitCode: 1,
468
899
  };
469
900
  }
470
901
  }