@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.
- package/README.md +7 -4
- package/dist/commands/update.js +11 -3
- package/dist/commands/update.js.map +1 -1
- package/dist/prompts/embedded.js +2 -2
- package/dist/prompts/embedded.js.map +1 -1
- package/dist/prompts/index.js +3 -2
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/system.agent.md +3 -3
- package/dist/prompts/system.ask.md +1 -1
- package/dist/repl/slash-commands.js +4 -0
- package/dist/repl/slash-commands.js.map +1 -1
- package/dist/tools/definitions.js +48 -5
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/fs.d.ts +18 -3
- package/dist/tools/fs.js +471 -40
- package/dist/tools/fs.js.map +1 -1
- package/dist/tools/registry.js +10 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tui-v2/actions/format-shortcuts.d.ts +12 -0
- package/dist/tui-v2/actions/format-shortcuts.js +257 -0
- package/dist/tui-v2/actions/format-shortcuts.js.map +1 -0
- package/dist/tui-v2/actions/keymap.js +7 -4
- package/dist/tui-v2/actions/keymap.js.map +1 -1
- package/dist/tui-v2/app/App.js +77 -21
- package/dist/tui-v2/app/App.js.map +1 -1
- package/dist/tui-v2/app/command-handlers.js +2 -1
- package/dist/tui-v2/app/command-handlers.js.map +1 -1
- package/dist/tui-v2/app/commands/config-commands.d.ts +1 -0
- package/dist/tui-v2/app/commands/config-commands.js +6 -5
- package/dist/tui-v2/app/commands/config-commands.js.map +1 -1
- package/dist/tui-v2/app/commands/picker-commands.js +3 -1
- package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
- package/dist/tui-v2/app/commands/session-commands.js +19 -2
- package/dist/tui-v2/app/commands/session-commands.js.map +1 -1
- package/dist/tui-v2/app/plan-lifecycle.js +1 -1
- package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
- package/dist/tui-v2/components/overlay/overlay-host.js +1 -1
- package/dist/tui-v2/components/overlay/overlay-host.js.map +1 -1
- package/dist/tui-v2/components/pager/pager-line.d.ts +31 -0
- package/dist/tui-v2/components/pager/pager-line.js +184 -0
- package/dist/tui-v2/components/pager/pager-line.js.map +1 -0
- package/dist/tui-v2/components/pager/pager.d.ts +7 -2
- package/dist/tui-v2/components/pager/pager.js +66 -187
- package/dist/tui-v2/components/pager/pager.js.map +1 -1
- package/dist/tui-v2/components/status/status-line.d.ts +9 -1
- package/dist/tui-v2/components/status/status-line.js +3 -3
- package/dist/tui-v2/components/status/status-line.js.map +1 -1
- package/dist/tui-v2/components/toast/toast-host.d.ts +5 -2
- package/dist/tui-v2/components/toast/toast-host.js +63 -28
- package/dist/tui-v2/components/toast/toast-host.js.map +1 -1
- package/dist/tui-v2/components/transcript/search-bar.d.ts +9 -5
- package/dist/tui-v2/components/transcript/search-bar.js +49 -3
- package/dist/tui-v2/components/transcript/search-bar.js.map +1 -1
- package/dist/tui-v2/components/transcript/transcript-row.d.ts +9 -0
- package/dist/tui-v2/components/transcript/transcript-row.js +24 -7
- package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
- package/dist/tui-v2/components/transcript/transcript-view.js +121 -13
- package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
- package/dist/tui-v2/components/transcript/use-native-selection-copy.js +7 -4
- package/dist/tui-v2/components/transcript/use-native-selection-copy.js.map +1 -1
- package/dist/tui-v2/composer/composer-action-port.d.ts +12 -0
- package/dist/tui-v2/composer/composer-action-port.js +20 -0
- package/dist/tui-v2/composer/composer-action-port.js.map +1 -0
- package/dist/tui-v2/composer/composer-editor.js +34 -2
- package/dist/tui-v2/composer/composer-editor.js.map +1 -1
- package/dist/tui-v2/composer/textarea-keybindings.d.ts +5 -3
- package/dist/tui-v2/composer/textarea-keybindings.js +29 -2
- package/dist/tui-v2/composer/textarea-keybindings.js.map +1 -1
- package/dist/tui-v2/controllers/overlay-controller.d.ts +6 -1
- package/dist/tui-v2/controllers/overlay-controller.js +2 -1
- package/dist/tui-v2/controllers/overlay-controller.js.map +1 -1
- package/dist/tui-v2/controllers/toast-controller.d.ts +17 -5
- package/dist/tui-v2/controllers/toast-controller.js +33 -7
- package/dist/tui-v2/controllers/toast-controller.js.map +1 -1
- package/dist/tui-v2/notify.d.ts +21 -0
- package/dist/tui-v2/notify.js +27 -0
- package/dist/tui-v2/notify.js.map +1 -0
- package/dist/tui-v2/rendering/format-help.d.ts +16 -0
- package/dist/tui-v2/rendering/format-help.js +95 -0
- package/dist/tui-v2/rendering/format-help.js.map +1 -0
- package/dist/tui-v2/rendering/pager-markdown.d.ts +40 -0
- package/dist/tui-v2/rendering/pager-markdown.js +82 -0
- package/dist/tui-v2/rendering/pager-markdown.js.map +1 -0
- package/dist/tui-v2/rendering/plan-view.d.ts +1 -2
- package/dist/tui-v2/rendering/plan-view.js +39 -55
- package/dist/tui-v2/rendering/plan-view.js.map +1 -1
- package/dist/version.generated.d.ts +6 -0
- package/dist/version.generated.js +7 -0
- package/dist/version.generated.js.map +1 -0
- package/package.json +5 -1
package/dist/tools/fs.d.ts
CHANGED
|
@@ -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
|
|
16
|
+
export interface FsReadOptions {
|
|
17
17
|
maxBytes?: number | undefined;
|
|
18
18
|
confirmed?: boolean | undefined;
|
|
19
|
-
/** 1-indexed first line to return (inclusive).
|
|
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
|
-
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
-
|
|
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:
|
|
212
|
-
output:
|
|
213
|
-
|
|
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
|
|
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:
|
|
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
|
|
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", [
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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:
|
|
466
|
-
output:
|
|
467
|
-
|
|
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
|
}
|