@giovannijecha/jecode 0.8.4 → 0.8.5
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 +8 -6
- package/dist/accounts.js +47 -10
- package/dist/atomic.js +24 -14
- package/dist/batch.js +37 -4
- package/dist/bounded-file.js +212 -0
- package/dist/commands.js +2 -0
- package/dist/config.js +2 -1
- package/dist/context/automatic.js +35 -0
- package/dist/context/compactor.js +32 -2
- package/dist/context/manual.js +20 -3
- package/dist/context/request-projection.js +130 -0
- package/dist/controller-request.js +33 -11
- package/dist/credential-commands.js +22 -6
- package/dist/credentials.js +56 -18
- package/dist/directory-anchor.js +91 -0
- package/dist/file-identity.js +12 -0
- package/dist/model-command.js +5 -4
- package/dist/openai-account-command.js +13 -2
- package/dist/process-lease.js +329 -0
- package/dist/provider-commands.js +11 -5
- package/dist/provider-errors.js +37 -4
- package/dist/provider-label.js +13 -0
- package/dist/providers/anthropic-stream.js +4 -1
- package/dist/providers/anthropic-wire.js +8 -3
- package/dist/providers/anthropic.js +39 -19
- package/dist/providers/catalog.js +4 -4
- package/dist/providers/failure.js +181 -0
- package/dist/providers/http.js +82 -23
- package/dist/providers/ollama-stream.js +5 -1
- package/dist/providers/ollama.js +31 -19
- package/dist/providers/openai-codex.js +67 -41
- package/dist/providers/openai-stream.js +26 -2
- package/dist/providers/openai.js +51 -24
- package/dist/providers/sse.js +52 -8
- package/dist/request-identity.js +32 -0
- package/dist/sessions/lease.js +132 -49
- package/dist/sessions/runtime.js +15 -8
- package/dist/sessions/store.js +366 -142
- package/dist/settings.js +62 -10
- package/dist/stable-directory.js +148 -0
- package/dist/store-lock.js +68 -84
- package/dist/tools/args.js +2 -2
- package/dist/tools/fs.js +124 -102
- package/dist/tools/search.js +65 -100
- package/dist/tools/text-boundary.js +7 -33
- package/dist/tui/app-workflows.js +32 -4
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/feedback.js +4 -0
- package/dist/tui/session-view.js +7 -2
- package/dist/tui/workspace.js +21 -7
- package/dist/user-store.js +23 -31
- package/package.json +3 -4
- package/dist/tools/ripgrep.js +0 -230
package/dist/tools/fs.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Filesystem tools: read, list, write, edit.
|
|
2
|
-
import { constants } from "node:fs";
|
|
3
2
|
import * as fs from "node:fs/promises";
|
|
4
3
|
import * as path from "node:path";
|
|
4
|
+
import { withStableFile } from "../bounded-file.js";
|
|
5
|
+
import { readStableDirectory } from "../stable-directory.js";
|
|
5
6
|
import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
|
|
6
7
|
import { assertDirectWritableInRoot, displayPath, resolveDirectWritableInRoot, resolveExistingInRoot, } from "./paths.js";
|
|
7
8
|
import { assertEditableText, assertReplacementFits, leadingText, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
|
|
@@ -10,6 +11,7 @@ const MAX_READ_CHARS = 60_000;
|
|
|
10
11
|
const MAX_LIST_CHARS = 60_000;
|
|
11
12
|
const MAX_LIST_ENTRIES = 2_000;
|
|
12
13
|
const READ_CHUNK_BYTES = 64 * 1024;
|
|
14
|
+
const MAX_READ_SCAN_BYTES = 16 * 1024 * 1024;
|
|
13
15
|
const DEFAULT_MUTATION_DEPENDENCIES = { atomicWrite };
|
|
14
16
|
export const readFile = {
|
|
15
17
|
name: "read_file",
|
|
@@ -27,22 +29,32 @@ export const readFile = {
|
|
|
27
29
|
required: ["path"],
|
|
28
30
|
},
|
|
29
31
|
async run(args, ctx) {
|
|
30
|
-
|
|
31
|
-
const target = await resolveExistingInRoot(root, requireString(args, "path"));
|
|
32
|
-
const offset = optionalInt(args, "offset");
|
|
33
|
-
const limit = optionalInt(args, "limit");
|
|
34
|
-
const { text, truncated } = await readRange(target, offset, limit, ctx.signal);
|
|
35
|
-
if (truncated) {
|
|
36
|
-
return {
|
|
37
|
-
output: `${text}\n\n[truncated at ${MAX_READ_CHARS} characters — read a narrower range]`,
|
|
38
|
-
summary: `truncated at ${MAX_READ_CHARS} characters`,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
if (text === "")
|
|
42
|
-
return { output: "[file is empty]", summary: "empty" };
|
|
43
|
-
return { output: text, summary: `${count(text, "line")}` };
|
|
32
|
+
return runReadFile(args, ctx);
|
|
44
33
|
},
|
|
45
34
|
};
|
|
35
|
+
export async function runReadFile(args, ctx, dependencies = {}) {
|
|
36
|
+
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
37
|
+
const target = await resolveExistingInRoot(root, requireString(args, "path"));
|
|
38
|
+
const offset = optionalInt(args, "offset");
|
|
39
|
+
const limit = optionalInt(args, "limit");
|
|
40
|
+
const { text, truncated, scanCapped } = await readRange(root, target, offset, limit, ctx.signal, dependencies);
|
|
41
|
+
if (truncated) {
|
|
42
|
+
return {
|
|
43
|
+
output: `${text}\n\n[truncated at ${MAX_READ_CHARS} characters — read a narrower range]`,
|
|
44
|
+
summary: `truncated at ${MAX_READ_CHARS} characters`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (scanCapped) {
|
|
48
|
+
const notice = `[read stopped after scanning ${MAX_READ_SCAN_BYTES} bytes — use a smaller offset or search_text]`;
|
|
49
|
+
return {
|
|
50
|
+
output: text === "" ? notice : `${text}\n\n${notice}`,
|
|
51
|
+
summary: `scan capped at ${MAX_READ_SCAN_BYTES} bytes`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (text === "")
|
|
55
|
+
return { output: "[file is empty]", summary: "empty" };
|
|
56
|
+
return { output: text, summary: `${count(text, "line")}` };
|
|
57
|
+
}
|
|
46
58
|
export const listDir = {
|
|
47
59
|
name: "list_dir",
|
|
48
60
|
description: "List the entries of a directory inside the workspace. Directories end with a slash.",
|
|
@@ -56,39 +68,44 @@ export const listDir = {
|
|
|
56
68
|
required: [],
|
|
57
69
|
},
|
|
58
70
|
async run(args, ctx) {
|
|
59
|
-
|
|
60
|
-
const requested = optionalString(args, "path");
|
|
61
|
-
const relative = requested === undefined || requested.trim() === "" ? "." : requested;
|
|
62
|
-
const target = await resolveExistingInRoot(root, relative);
|
|
63
|
-
const entries = [];
|
|
64
|
-
let chars = 0;
|
|
65
|
-
let truncated = false;
|
|
66
|
-
const directory = await fs.opendir(target);
|
|
67
|
-
for await (const entry of directory) {
|
|
68
|
-
const label = entry.isDirectory() ? `${entry.name}/` : entry.name;
|
|
69
|
-
const separator = entries.length === 0 ? 0 : 1;
|
|
70
|
-
if (entries.length >= MAX_LIST_ENTRIES ||
|
|
71
|
-
chars + separator + label.length > MAX_LIST_CHARS) {
|
|
72
|
-
truncated = true;
|
|
73
|
-
break;
|
|
74
|
-
}
|
|
75
|
-
entries.push(label);
|
|
76
|
-
chars += separator + label.length;
|
|
77
|
-
}
|
|
78
|
-
if (entries.length === 0)
|
|
79
|
-
return { output: "[empty directory]", summary: "empty" };
|
|
80
|
-
const listing = entries
|
|
81
|
-
.sort((a, b) => a.localeCompare(b))
|
|
82
|
-
.join("\n");
|
|
83
|
-
if (truncated) {
|
|
84
|
-
return {
|
|
85
|
-
output: `${listing}\n\n[truncated after ${entries.length} entries]`,
|
|
86
|
-
summary: `${entries.length}+ entries`,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
return { output: listing, summary: plural(entries.length, "entry", "entries") };
|
|
71
|
+
return runListDir(args, ctx);
|
|
90
72
|
},
|
|
91
73
|
};
|
|
74
|
+
export async function runListDir(args, ctx, dependencies = {}) {
|
|
75
|
+
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
76
|
+
const requested = optionalString(args, "path");
|
|
77
|
+
const relative = requested === undefined || requested.trim() === "" ? "." : requested;
|
|
78
|
+
const target = await resolveExistingInRoot(root, relative);
|
|
79
|
+
const inspected = await readStableDirectory(root, target, {
|
|
80
|
+
maxEntries: MAX_LIST_ENTRIES + 1,
|
|
81
|
+
signal: ctx.signal,
|
|
82
|
+
beforeOpen: dependencies.beforeOpen,
|
|
83
|
+
});
|
|
84
|
+
const entries = [];
|
|
85
|
+
let chars = 0;
|
|
86
|
+
let truncated = inspected.capped;
|
|
87
|
+
for (const entry of inspected.entries) {
|
|
88
|
+
const label = entry.kind === "directory" ? `${entry.name}/` : entry.name;
|
|
89
|
+
const separator = entries.length === 0 ? 0 : 1;
|
|
90
|
+
if (entries.length >= MAX_LIST_ENTRIES ||
|
|
91
|
+
chars + separator + label.length > MAX_LIST_CHARS) {
|
|
92
|
+
truncated = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
entries.push(label);
|
|
96
|
+
chars += separator + label.length;
|
|
97
|
+
}
|
|
98
|
+
if (entries.length === 0)
|
|
99
|
+
return { output: "[empty directory]", summary: "empty" };
|
|
100
|
+
const listing = entries.join("\n");
|
|
101
|
+
if (truncated) {
|
|
102
|
+
return {
|
|
103
|
+
output: `${listing}\n\n[truncated after ${entries.length} entries]`,
|
|
104
|
+
summary: `${entries.length}+ entries`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { output: listing, summary: plural(entries.length, "entry", "entries") };
|
|
108
|
+
}
|
|
92
109
|
export const writeFile = {
|
|
93
110
|
name: "write_file",
|
|
94
111
|
description: "Create a file, or replace its entire contents. Parent directories are " +
|
|
@@ -204,81 +221,86 @@ export async function runEditFile(args, ctx, dependencies = DEFAULT_MUTATION_DEP
|
|
|
204
221
|
summary: plural(made, "replacement", "replacements"),
|
|
205
222
|
};
|
|
206
223
|
}
|
|
207
|
-
async function readRange(target, offset, limit, signal) {
|
|
224
|
+
async function readRange(root, target, offset, limit, signal, dependencies) {
|
|
208
225
|
throwIfAborted(signal);
|
|
209
226
|
const firstLine = Math.max(1, offset ?? 1);
|
|
210
227
|
const lineCount = limit === undefined ? undefined : Math.max(0, limit);
|
|
211
228
|
const endLine = lineCount === undefined ? Number.POSITIVE_INFINITY : firstLine + lineCount;
|
|
212
229
|
if (lineCount === 0)
|
|
213
|
-
return { text: "", truncated: false };
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const selected = (at) => at >= firstLine && at < endLine;
|
|
229
|
-
const append = (fragment) => {
|
|
230
|
-
if (fragment === "")
|
|
231
|
-
return;
|
|
232
|
-
const room = MAX_READ_CHARS - text.length;
|
|
233
|
-
if (fragment.length > room) {
|
|
234
|
-
text += leadingText(fragment, room);
|
|
235
|
-
truncated = true;
|
|
236
|
-
stopped = true;
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
text += fragment;
|
|
240
|
-
};
|
|
241
|
-
const consume = (chunk) => {
|
|
242
|
-
let start = 0;
|
|
243
|
-
while (!stopped && start < chunk.length) {
|
|
244
|
-
const newline = chunk.indexOf("\n", start);
|
|
245
|
-
const end = newline === -1 ? chunk.length : newline;
|
|
246
|
-
if (selected(line))
|
|
247
|
-
append(chunk.slice(start, end));
|
|
248
|
-
if (stopped || newline === -1)
|
|
230
|
+
return { text: "", truncated: false, scanCapped: false };
|
|
231
|
+
const result = await withStableFile(target, {
|
|
232
|
+
label: "read file",
|
|
233
|
+
signal,
|
|
234
|
+
beforeOpen: dependencies.beforeOpen,
|
|
235
|
+
}, async (handle, opened) => {
|
|
236
|
+
const decoder = new TextDecoder();
|
|
237
|
+
let text = "";
|
|
238
|
+
let line = 1;
|
|
239
|
+
let stopped = false;
|
|
240
|
+
let truncated = false;
|
|
241
|
+
let reachedEnd = false;
|
|
242
|
+
const selected = (at) => at >= firstLine && at < endLine;
|
|
243
|
+
const append = (fragment) => {
|
|
244
|
+
if (fragment === "")
|
|
249
245
|
return;
|
|
250
|
-
const
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (line >= endLine) {
|
|
246
|
+
const room = MAX_READ_CHARS - text.length;
|
|
247
|
+
if (fragment.length > room) {
|
|
248
|
+
text += leadingText(fragment, room);
|
|
249
|
+
truncated = true;
|
|
255
250
|
stopped = true;
|
|
256
251
|
return;
|
|
257
252
|
}
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
253
|
+
text += fragment;
|
|
254
|
+
};
|
|
255
|
+
const consume = (chunk) => {
|
|
256
|
+
let start = 0;
|
|
257
|
+
while (!stopped && start < chunk.length) {
|
|
258
|
+
const newline = chunk.indexOf("\n", start);
|
|
259
|
+
const end = newline === -1 ? chunk.length : newline;
|
|
260
|
+
if (selected(line))
|
|
261
|
+
append(chunk.slice(start, end));
|
|
262
|
+
if (stopped || newline === -1)
|
|
263
|
+
return;
|
|
264
|
+
const nextLine = line + 1;
|
|
265
|
+
if (selected(line) && selected(nextLine))
|
|
266
|
+
append("\n");
|
|
267
|
+
line = nextLine;
|
|
268
|
+
if (line >= endLine) {
|
|
269
|
+
stopped = true;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
start = newline + 1;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
264
275
|
let position = 0;
|
|
265
276
|
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
|
266
|
-
while (!stopped) {
|
|
277
|
+
while (!stopped && position < MAX_READ_SCAN_BYTES) {
|
|
267
278
|
throwIfAborted(signal);
|
|
268
|
-
const
|
|
279
|
+
const length = Math.min(buffer.length, MAX_READ_SCAN_BYTES - position);
|
|
280
|
+
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
269
281
|
throwIfAborted(signal);
|
|
270
|
-
if (bytesRead === 0)
|
|
282
|
+
if (bytesRead === 0) {
|
|
283
|
+
reachedEnd = true;
|
|
271
284
|
break;
|
|
285
|
+
}
|
|
272
286
|
position += bytesRead;
|
|
273
287
|
consume(decoder.decode(buffer.subarray(0, bytesRead), { stream: true }));
|
|
274
288
|
}
|
|
275
|
-
if (!stopped)
|
|
289
|
+
if (!stopped && BigInt(position) >= opened.size)
|
|
290
|
+
reachedEnd = true;
|
|
291
|
+
if (!stopped && reachedEnd)
|
|
276
292
|
consume(decoder.decode());
|
|
293
|
+
return {
|
|
294
|
+
text,
|
|
295
|
+
truncated,
|
|
296
|
+
scanCapped: !stopped && !reachedEnd && position >= MAX_READ_SCAN_BYTES,
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
const confirmed = await resolveExistingInRoot(root, target);
|
|
300
|
+
if (path.relative(target, confirmed) !== "") {
|
|
301
|
+
throw new Error("read file changed while it was being read");
|
|
277
302
|
}
|
|
278
|
-
|
|
279
|
-
await handle.close();
|
|
280
|
-
}
|
|
281
|
-
return { text, truncated };
|
|
303
|
+
return result;
|
|
282
304
|
}
|
|
283
305
|
function throwIfAborted(signal) {
|
|
284
306
|
if (signal?.aborted !== true)
|
package/dist/tools/search.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Bounded, read-only workspace discovery without borrowing a shell.
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { BoundedFileError, readBoundedFile } from "../bounded-file.js";
|
|
5
|
+
import { readStableDirectory, StableDirectoryError, } from "../stable-directory.js";
|
|
4
6
|
import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
|
|
5
7
|
import { displayPath, resolveExistingInRoot } from "./paths.js";
|
|
6
|
-
import { trySearchWithRipgrep } from "./ripgrep.js";
|
|
7
8
|
import { leadingText } from "./text-boundary.js";
|
|
8
9
|
const DEFAULT_RESULTS = 100;
|
|
9
10
|
const MAX_RESULTS = 500;
|
|
@@ -12,10 +13,6 @@ const MAX_FILE_BYTES = 1_000_000;
|
|
|
12
13
|
const MAX_MATCH_LINE = 500;
|
|
13
14
|
const MAX_GLOB_CHARS = 512;
|
|
14
15
|
const PORTABLE_SEARCH_CONCURRENCY = 8;
|
|
15
|
-
const RG_PREFIX_BYTES = 2_000_000;
|
|
16
|
-
const RG_PREFIX_FILES = 500;
|
|
17
|
-
const MIN_RG_TAIL_BYTES = 2_000_000;
|
|
18
|
-
const MIN_RG_TAIL_FILES = 500;
|
|
19
16
|
const SKIP = new Set([".git", ".hg", ".svn", "node_modules"]);
|
|
20
17
|
export const findFiles = {
|
|
21
18
|
name: "find_files",
|
|
@@ -84,80 +81,30 @@ export const searchText = {
|
|
|
84
81
|
const match = pattern === undefined || pattern === "" ? () => true : glob(pattern);
|
|
85
82
|
const limit = resultLimit(args);
|
|
86
83
|
const found = [];
|
|
87
|
-
const
|
|
88
|
-
const prefix = [];
|
|
89
|
-
const tail = [];
|
|
84
|
+
const pending = [];
|
|
90
85
|
let skipped = 0;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
let tailBytes = 0;
|
|
94
|
-
const flushPrefix = async () => {
|
|
95
|
-
if (prefix.length === 0)
|
|
86
|
+
const flushPending = async () => {
|
|
87
|
+
if (pending.length === 0)
|
|
96
88
|
return false;
|
|
97
|
-
const searched = await portableSearch(
|
|
89
|
+
const searched = await portableSearch(pending.splice(0), scoped, needle, sensitive, limit - found.length);
|
|
98
90
|
found.push(...searched.matches);
|
|
99
91
|
skipped += searched.skipped;
|
|
100
92
|
return found.length >= limit;
|
|
101
93
|
};
|
|
102
|
-
const
|
|
103
|
-
if (candidates.length === 0)
|
|
104
|
-
return false;
|
|
105
|
-
const inspected = await Promise.all(candidates.splice(0).map((lexical) => inspectCandidate(scoped.root, lexical)));
|
|
106
|
-
checkAbort(ctx.signal);
|
|
107
|
-
for (const result of inspected) {
|
|
108
|
-
if ("error" in result) {
|
|
109
|
-
if (!skippable(result.error))
|
|
110
|
-
throw result.error;
|
|
111
|
-
skipped++;
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
const candidate = result.file;
|
|
115
|
-
if (candidate.bytes > MAX_FILE_BYTES) {
|
|
116
|
-
skipped++;
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
if (prefixFiles + 1 > RG_PREFIX_FILES ||
|
|
120
|
-
prefixBytes + candidate.bytes > RG_PREFIX_BYTES) {
|
|
121
|
-
tail.push(candidate);
|
|
122
|
-
tailBytes += candidate.bytes;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
prefixFiles++;
|
|
126
|
-
prefixBytes += candidate.bytes;
|
|
127
|
-
prefix.push(candidate);
|
|
128
|
-
if (prefix.length >= PORTABLE_SEARCH_CONCURRENCY && await flushPrefix())
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
|
-
return false;
|
|
132
|
-
};
|
|
133
|
-
const walked = await walk(start, scoped, async (lexical) => {
|
|
94
|
+
const walked = await walk(start, scoped, async (lexical, expected) => {
|
|
134
95
|
const relative = displayPath(scoped.root, lexical);
|
|
135
96
|
if (!match(relative))
|
|
136
97
|
return false;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
98
|
+
const bytes = Number(expected.size);
|
|
99
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
100
|
+
skipped++;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
pending.push({ path: lexical, bytes, expected });
|
|
104
|
+
return pending.length >= PORTABLE_SEARCH_CONCURRENCY && await flushPending();
|
|
141
105
|
});
|
|
142
106
|
if (found.length < limit)
|
|
143
|
-
await
|
|
144
|
-
if (found.length < limit)
|
|
145
|
-
await flushPrefix();
|
|
146
|
-
const accelerated = found.length < limit && preferRipgrep(tail, tailBytes)
|
|
147
|
-
? await trySearchWithRipgrep({
|
|
148
|
-
root: scoped.root,
|
|
149
|
-
files: tail,
|
|
150
|
-
query,
|
|
151
|
-
caseSensitive: sensitive,
|
|
152
|
-
limit: limit - found.length,
|
|
153
|
-
signal: ctx.signal,
|
|
154
|
-
})
|
|
155
|
-
: undefined;
|
|
156
|
-
const portable = found.length < limit && accelerated === undefined && tail.length > 0
|
|
157
|
-
? await portableSearch(tail, scoped, needle, sensitive, limit - found.length)
|
|
158
|
-
: undefined;
|
|
159
|
-
found.push(...(portable?.matches ?? accelerated?.matches.map((match) => (`${displayPath(scoped.root, match.path)}:${match.line}:${clip(match.text)}`)) ?? []));
|
|
160
|
-
skipped += portable?.skipped ?? accelerated?.binaryPaths.length ?? 0;
|
|
107
|
+
await flushPending();
|
|
161
108
|
const extra = skipped === 0 ? "" : ` · skipped ${skipped} binary/large/unreadable`;
|
|
162
109
|
return {
|
|
163
110
|
output: found.length === 0 ? "[no matches]" : found.join("\n"),
|
|
@@ -165,7 +112,7 @@ export const searchText = {
|
|
|
165
112
|
};
|
|
166
113
|
},
|
|
167
114
|
};
|
|
168
|
-
export async function portableSearch(files, ctx, needle, sensitive, limit, read
|
|
115
|
+
export async function portableSearch(files, ctx, needle, sensitive, limit, read) {
|
|
169
116
|
const found = [];
|
|
170
117
|
let skipped = 0;
|
|
171
118
|
for (let start = 0; start < files.length; start += PORTABLE_SEARCH_CONCURRENCY) {
|
|
@@ -189,19 +136,12 @@ export async function portableSearch(files, ctx, needle, sensitive, limit, read
|
|
|
189
136
|
}
|
|
190
137
|
return { matches: found, skipped };
|
|
191
138
|
}
|
|
192
|
-
async function inspectCandidate(root, lexical) {
|
|
193
|
-
try {
|
|
194
|
-
const file = await resolveExistingInRoot(root, lexical);
|
|
195
|
-
return { file: { path: file, bytes: (await fs.stat(file)).size } };
|
|
196
|
-
}
|
|
197
|
-
catch (error) {
|
|
198
|
-
return { error };
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
139
|
async function searchFile(file, ctx, needle, sensitive, limit, read) {
|
|
202
140
|
let data;
|
|
203
141
|
try {
|
|
204
|
-
data =
|
|
142
|
+
data = read === undefined
|
|
143
|
+
? await readVerifiedSearchFile(file, ctx)
|
|
144
|
+
: await read(file.path, ctx.signal);
|
|
205
145
|
}
|
|
206
146
|
catch (error) {
|
|
207
147
|
checkAbort(ctx.signal);
|
|
@@ -225,55 +165,79 @@ async function searchFile(file, ctx, needle, sensitive, limit, read) {
|
|
|
225
165
|
}
|
|
226
166
|
return found;
|
|
227
167
|
}
|
|
228
|
-
function
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return
|
|
168
|
+
async function readVerifiedSearchFile(file, ctx) {
|
|
169
|
+
if (file.expected === undefined) {
|
|
170
|
+
throw new BoundedFileError("changed", "search file has no verified identity");
|
|
171
|
+
}
|
|
172
|
+
return readBoundedFile(file.path, MAX_FILE_BYTES, {
|
|
173
|
+
label: "search file",
|
|
174
|
+
signal: ctx.signal,
|
|
175
|
+
expected: file.expected,
|
|
176
|
+
validate: async () => {
|
|
177
|
+
const confirmed = await resolveExistingInRoot(ctx.root, file.path);
|
|
178
|
+
if (confirmed !== file.path) {
|
|
179
|
+
throw new BoundedFileError("changed", "search file left the workspace");
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});
|
|
233
183
|
}
|
|
234
184
|
async function walk(start, ctx, visit) {
|
|
235
185
|
const pending = [];
|
|
236
186
|
let seen = 0;
|
|
187
|
+
let buffered = 0;
|
|
188
|
+
let capped = false;
|
|
237
189
|
const enter = async (lexical) => {
|
|
238
190
|
checkAbort(ctx.signal);
|
|
239
191
|
const directory = await resolveExistingInRoot(ctx.root, lexical);
|
|
240
|
-
|
|
192
|
+
const room = MAX_VISITED - seen - buffered;
|
|
193
|
+
if (room <= 0) {
|
|
194
|
+
capped = true;
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
241
197
|
try {
|
|
242
|
-
|
|
198
|
+
const inspected = await readStableDirectory(ctx.root, directory, {
|
|
199
|
+
maxEntries: room,
|
|
200
|
+
signal: ctx.signal,
|
|
201
|
+
});
|
|
202
|
+
if (inspected.capped)
|
|
203
|
+
capped = true;
|
|
204
|
+
const entries = [...inspected.entries];
|
|
205
|
+
buffered += entries.length;
|
|
206
|
+
pending.push({ directory, entries, next: 0 });
|
|
243
207
|
}
|
|
244
208
|
catch (error) {
|
|
245
209
|
if (skippable(error))
|
|
246
|
-
return;
|
|
210
|
+
return true;
|
|
247
211
|
throw error;
|
|
248
212
|
}
|
|
249
|
-
|
|
250
|
-
pending.push({ directory, entries, next: 0 });
|
|
213
|
+
return true;
|
|
251
214
|
};
|
|
252
|
-
await enter(start)
|
|
215
|
+
if (!await enter(start))
|
|
216
|
+
return { capped: true };
|
|
253
217
|
while (pending.length > 0) {
|
|
254
218
|
checkAbort(ctx.signal);
|
|
255
219
|
const frame = pending[pending.length - 1];
|
|
256
220
|
if (frame === undefined)
|
|
257
221
|
break;
|
|
258
|
-
const
|
|
222
|
+
const index = frame.next++;
|
|
223
|
+
const entry = frame.entries[index];
|
|
259
224
|
if (entry === undefined) {
|
|
260
225
|
pending.pop();
|
|
261
226
|
continue;
|
|
262
227
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
continue;
|
|
228
|
+
frame.entries[index] = undefined;
|
|
229
|
+
buffered--;
|
|
230
|
+
seen++;
|
|
267
231
|
const target = path.join(frame.directory, entry.name);
|
|
268
|
-
if (entry.
|
|
232
|
+
if (entry.kind === "directory") {
|
|
269
233
|
if (!SKIP.has(entry.name))
|
|
270
234
|
await enter(target);
|
|
271
235
|
}
|
|
272
|
-
else if (
|
|
273
|
-
return { capped
|
|
236
|
+
else if (await visit(target, entry.expected)) {
|
|
237
|
+
return { capped };
|
|
274
238
|
}
|
|
275
239
|
}
|
|
276
|
-
return { capped
|
|
240
|
+
return { capped };
|
|
277
241
|
}
|
|
278
242
|
async function startAt(args, ctx) {
|
|
279
243
|
const candidate = optionalString(args, "path") ?? ".";
|
|
@@ -414,5 +378,6 @@ function checkAbort(signal) {
|
|
|
414
378
|
throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
|
|
415
379
|
}
|
|
416
380
|
function skippable(error) {
|
|
417
|
-
return
|
|
381
|
+
return error instanceof BoundedFileError || error instanceof StableDirectoryError ||
|
|
382
|
+
["EACCES", "EPERM", "ENOENT"].includes(error.code ?? "");
|
|
418
383
|
}
|
|
@@ -1,48 +1,22 @@
|
|
|
1
1
|
// Shared size and truncation boundaries for text handled by workspace tools.
|
|
2
|
-
import {
|
|
3
|
-
import { lstat, open } from "node:fs/promises";
|
|
2
|
+
import { BoundedFileError, readBoundedText } from "../bounded-file.js";
|
|
4
3
|
export { leadingText, trailingText } from "../text-boundary.js";
|
|
5
4
|
export const MAX_EDITABLE_BYTES = 4_000_000;
|
|
6
5
|
export const MAX_EDITABLE_CHARS = 1_000_000;
|
|
7
6
|
export const MAX_EDITABLE_LINES = 20_000;
|
|
8
|
-
const READ_CHUNK_BYTES = 64 * 1024;
|
|
9
7
|
/** Read a regular UTF-8 file without allowing an unbounded allocation. */
|
|
10
8
|
export async function readEditableText(file, options = {}) {
|
|
11
9
|
const label = options.label ?? "file";
|
|
12
|
-
const details = await lstat(file);
|
|
13
|
-
if (!details.isFile())
|
|
14
|
-
throw new Error(`${label} must be a regular file`);
|
|
15
|
-
const flags = process.platform === "win32"
|
|
16
|
-
? "r"
|
|
17
|
-
: constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
|
|
18
|
-
const handle = await open(file, flags);
|
|
19
10
|
try {
|
|
20
|
-
const
|
|
21
|
-
if (!stat.isFile())
|
|
22
|
-
throw new Error(`${label} must be a regular file`);
|
|
23
|
-
if (stat.size > MAX_EDITABLE_BYTES) {
|
|
24
|
-
throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
|
|
25
|
-
}
|
|
26
|
-
const chunks = [];
|
|
27
|
-
let total = 0;
|
|
28
|
-
while (total <= MAX_EDITABLE_BYTES) {
|
|
29
|
-
const room = MAX_EDITABLE_BYTES + 1 - total;
|
|
30
|
-
const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, room));
|
|
31
|
-
const { bytesRead } = await handle.read(buffer, 0, buffer.length, total);
|
|
32
|
-
if (bytesRead === 0)
|
|
33
|
-
break;
|
|
34
|
-
chunks.push(buffer.subarray(0, bytesRead));
|
|
35
|
-
total += bytesRead;
|
|
36
|
-
}
|
|
37
|
-
if (total > MAX_EDITABLE_BYTES) {
|
|
38
|
-
throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
|
|
39
|
-
}
|
|
40
|
-
const text = Buffer.concat(chunks, total).toString("utf8");
|
|
11
|
+
const text = await readBoundedText(file, MAX_EDITABLE_BYTES, { label });
|
|
41
12
|
assertEditableText(text, label);
|
|
42
13
|
return text;
|
|
43
14
|
}
|
|
44
|
-
|
|
45
|
-
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error instanceof BoundedFileError && error.kind === "too-large") {
|
|
17
|
+
throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
46
20
|
}
|
|
47
21
|
}
|
|
48
22
|
/** Reject whole-file writes that exceed Jecode's mutation budget. */
|