ucode-agent 1.62.0 → 1.62.2
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/LICENSE +21 -21
- package/README.md +1 -1
- package/THIRD_PARTY_NOTICES.md +34 -34
- package/package.json +8 -9
- package/src/core/doctor.js +111 -111
- package/src/core/login.js +59 -59
- package/src/core/loop.js +3263 -3259
- package/src/core/openable.js +174 -0
- package/src/core/window.js +225 -225
- package/src/tools/files.js +865 -865
- package/src/tools/fuzzy.js +295 -295
- package/src/tools/shared.js +476 -476
- package/src/tools/shell.js +907 -907
- package/src/ui/plain.js +358 -358
- package/src/ui/screen.js +18 -1
- package/src/ui/theme.js +15 -9
- package/templates/next-shadcn/_package-lock.json +2025 -488
- package/templates/next-shadcn/package.json +6 -6
package/src/tools/shared.js
CHANGED
|
@@ -1,476 +1,476 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* shared.js — the plumbing every tool sits on.
|
|
3
|
-
*
|
|
4
|
-
* Path resolution, the session root, confirmation, output caps, filesystem
|
|
5
|
-
* errors worth reading, directory walking, glob matching, and the line diff
|
|
6
|
-
* that makes an edit visible. Nothing here is a tool; everything here is what
|
|
7
|
-
* the tools are built out of.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { promises as fs } from 'node:fs';
|
|
11
|
-
import path from 'node:path';
|
|
12
|
-
import { ToolFailure, Declined } from '../core/failure.js';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* How much of a result the model may see.
|
|
16
|
-
*
|
|
17
|
-
* File reads get a larger budget than everything else on purpose. Starving the
|
|
18
|
-
* model of the file it is about to edit costs far more — in wrong edits and in
|
|
19
|
-
* extra round trips — than the tokens it saves. A runaway build log still
|
|
20
|
-
* needs a firm lid, which is what the smaller cap is for.
|
|
21
|
-
*/
|
|
22
|
-
export const MAX_OUTPUT = Number(process.env.UCODE_MAX_TOOL_OUTPUT) || 12_000;
|
|
23
|
-
export const MAX_FILE_OUTPUT = Number(process.env.UCODE_MAX_FILE_OUTPUT) || 48_000;
|
|
24
|
-
|
|
25
|
-
export const READ_LINES = 600;
|
|
26
|
-
export const MAX_GLOB_HITS = 200;
|
|
27
|
-
export const MAX_GREP_HITS = 100;
|
|
28
|
-
|
|
29
|
-
/** Directories nobody means to search. */
|
|
30
|
-
export const SKIP = new Set([
|
|
31
|
-
'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out',
|
|
32
|
-
'.next', '.nuxt', '.svelte-kit', '.cache', 'coverage', '__pycache__',
|
|
33
|
-
'.venv', 'venv', '.tox', '.pytest_cache', 'target', '.gradle', '.idea',
|
|
34
|
-
'vendor', 'Pods', '.terraform',
|
|
35
|
-
]);
|
|
36
|
-
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
// Session root
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
let root = process.cwd();
|
|
42
|
-
|
|
43
|
-
export function setRoot(dir) {
|
|
44
|
-
root = path.resolve(dir);
|
|
45
|
-
return root;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function getRoot() {
|
|
49
|
-
return root;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// ---------------------------------------------------------------------------
|
|
53
|
-
// Confirmation
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* The UI installs the real prompt here. Keeping it as an injection point means
|
|
58
|
-
* no tool owns a readline instance, and all of them stay testable.
|
|
59
|
-
*/
|
|
60
|
-
let asker = null;
|
|
61
|
-
|
|
62
|
-
export function setConfirm(fn) {
|
|
63
|
-
asker = fn;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export async function confirm(action, detail, risk = 'write') {
|
|
67
|
-
if (!asker) {
|
|
68
|
-
throw new ToolFailure({
|
|
69
|
-
kind: 'cannot_ask',
|
|
70
|
-
attempted: action,
|
|
71
|
-
failed: 'That needs the user to approve it, and there is no way to ask them from here.',
|
|
72
|
-
fix: 'Run ucode in a terminal so it can prompt before acting.',
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
if (!(await asker({ action, detail, risk }))) throw new Declined(action);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// The request behind the call
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* What the user asked for this turn, in their own words.
|
|
84
|
-
*
|
|
85
|
-
* A tool cannot normally see the request that led to it, and for almost
|
|
86
|
-
* everything that is right — a tool should act on its arguments. The starter
|
|
87
|
-
* is the exception. "Make me a plain HTML app" is not a preference to be
|
|
88
|
-
* weighed against the convenience of a framework, but the model weighs it
|
|
89
|
-
* anyway: it reads "app", reaches for the starter with the component library
|
|
90
|
-
* in it, and the user waits through an npm install they explicitly said they
|
|
91
|
-
* did not want. Keeping the request here lets create_app check the instruction
|
|
92
|
-
* rather than trust the argument it was handed.
|
|
93
|
-
*/
|
|
94
|
-
let request = '';
|
|
95
|
-
|
|
96
|
-
export function setRequest(text) {
|
|
97
|
-
request = String(text ?? '');
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** "plain html", "vanilla js", "simple static page". */
|
|
101
|
-
const WANTS_PLAIN = /\b(?:plain|pure|vanilla|static|simple|basic|raw|just)\s+(?:html|js|javascript|css)\b/i;
|
|
102
|
-
/** "an html app", "one html page". */
|
|
103
|
-
const HTML_THING = /\bhtml\s+(?:app|page|site|website|file|thing)\b/i;
|
|
104
|
-
/** "no framework", "without react", "don't use next". */
|
|
105
|
-
const NO_FRAMEWORK =
|
|
106
|
-
/\b(?:no|without|not?\s+use|don'?t\s+use|do\s+not\s+use|skip)\s+(?:a\s+|any\s+)?(?:framework|frameworks|react|next\.?js|next|npm|node|build\s+step|bundler)\b/i;
|
|
107
|
-
/** Naming one on purpose outranks every hint above. */
|
|
108
|
-
const NAMES_FRAMEWORK = /\b(?:next\.?js|nextjs|react|tailwind|shadcn|typescript|database|api\s+routes?|server\s+side|auth)\b/i;
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Did the user rule out a framework in so many words?
|
|
112
|
-
*
|
|
113
|
-
* Saying "no react" settles it on its own. Asking for "an html app" settles it
|
|
114
|
-
* only when the same breath does not also ask for Next.js — "export this
|
|
115
|
-
* Next.js app as static html" names the framework on purpose, and a request
|
|
116
|
-
* that specific is not one to overrule.
|
|
117
|
-
*/
|
|
118
|
-
export function askedForPlainHtml() {
|
|
119
|
-
if (NO_FRAMEWORK.test(request)) return true;
|
|
120
|
-
if (!WANTS_PLAIN.test(request) && !HTML_THING.test(request)) return false;
|
|
121
|
-
return !NAMES_FRAMEWORK.test(request);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
// Paths
|
|
126
|
-
// ---------------------------------------------------------------------------
|
|
127
|
-
|
|
128
|
-
export function resolveIn(input, tool, argName = 'path') {
|
|
129
|
-
if (typeof input !== 'string' || !input.trim()) {
|
|
130
|
-
throw new ToolFailure({
|
|
131
|
-
kind: 'bad_args',
|
|
132
|
-
attempted: `running ${tool}`,
|
|
133
|
-
failed: `The "${argName}" argument was missing or was not a string.`,
|
|
134
|
-
fix: `Call ${tool} again with ${argName} set to a path relative to the project root.`,
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
const abs = path.resolve(root, input.trim());
|
|
138
|
-
const rel = path.relative(root, abs);
|
|
139
|
-
const inside = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
140
|
-
return {
|
|
141
|
-
abs,
|
|
142
|
-
inside,
|
|
143
|
-
// Short when it is in the project, fully spelled out when it is not — the
|
|
144
|
-
// display string is also the warning.
|
|
145
|
-
show: inside ? (rel === '' ? '.' : rel.split(path.sep).join('/')) : abs,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Reaching outside the folder ucode was started in always needs a yes. */
|
|
150
|
-
export async function guard(target, action) {
|
|
151
|
-
if (target.inside) return;
|
|
152
|
-
await confirm(action, `${target.abs}\nThat is outside this session's root (${root}).`, 'outside');
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
// Results
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
|
|
159
|
-
export function cap(text, limit = MAX_OUTPUT) {
|
|
160
|
-
const s = String(text ?? '');
|
|
161
|
-
if (s.length <= limit) return s;
|
|
162
|
-
return `${s.slice(0, limit)}\n... [cut here — ${s.length - limit} more characters]`;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Every tool resolves to this shape: what the model reads, plus a one-liner. */
|
|
166
|
-
export function result(content, summary, limit = MAX_OUTPUT) {
|
|
167
|
-
return { content: cap(content, limit), summary };
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ---------------------------------------------------------------------------
|
|
171
|
-
// Filesystem errors, translated
|
|
172
|
-
// ---------------------------------------------------------------------------
|
|
173
|
-
|
|
174
|
-
export function fsFailure(err, attempted, target) {
|
|
175
|
-
const code = err?.code;
|
|
176
|
-
const common = { attempted, cause: err };
|
|
177
|
-
|
|
178
|
-
if (code === 'ENOENT') {
|
|
179
|
-
return new ToolFailure({
|
|
180
|
-
...common,
|
|
181
|
-
kind: 'not_found',
|
|
182
|
-
failed: `Nothing exists at ${target}.`,
|
|
183
|
-
fix: 'Check the path with list_dir or glob first. Paths are relative to the project root.',
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
if (code === 'EACCES' || code === 'EPERM') {
|
|
187
|
-
return new ToolFailure({
|
|
188
|
-
...common,
|
|
189
|
-
kind: 'permission_denied',
|
|
190
|
-
failed: `The operating system refused access to ${target} (${code}).`,
|
|
191
|
-
fix: 'Check the permissions, or whether another program has the file open and locked.',
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
if (code === 'EISDIR') {
|
|
195
|
-
return new ToolFailure({
|
|
196
|
-
...common,
|
|
197
|
-
kind: 'is_directory',
|
|
198
|
-
failed: `${target} is a directory, not a file.`,
|
|
199
|
-
fix: 'Use list_dir to see inside it.',
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
if (code === 'ENOTDIR') {
|
|
203
|
-
return new ToolFailure({
|
|
204
|
-
...common,
|
|
205
|
-
kind: 'not_directory',
|
|
206
|
-
failed: `Something along the path ${target} is a file, not a directory.`,
|
|
207
|
-
fix: 'Re-check each segment of the path with list_dir.',
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
return new ToolFailure({
|
|
211
|
-
...common,
|
|
212
|
-
kind: 'io_error',
|
|
213
|
-
failed: `${code ? `${code}: ` : ''}${err?.message ?? String(err)}`,
|
|
214
|
-
fix: 'Confirm the path exists and is readable, then try once more.',
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// ---------------------------------------------------------------------------
|
|
219
|
-
// Text
|
|
220
|
-
// ---------------------------------------------------------------------------
|
|
221
|
-
|
|
222
|
-
export const looksBinary = (buf) => buf.includes(0);
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Split into lines the way a person counts them: a file ending in a newline
|
|
226
|
-
* has that many lines, not one more empty one at the bottom.
|
|
227
|
-
*/
|
|
228
|
-
export function toLines(text) {
|
|
229
|
-
const lines = String(text).split(/\r?\n/);
|
|
230
|
-
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
|
|
231
|
-
return lines;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export function bytes(n) {
|
|
235
|
-
if (n < 1024) return `${n} B`;
|
|
236
|
-
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
237
|
-
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export function clip(text, n = 60) {
|
|
241
|
-
const s = String(text ?? '');
|
|
242
|
-
return s.length > n ? `${s.slice(0, n)}…` : s;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// ---------------------------------------------------------------------------
|
|
246
|
-
// Diffs
|
|
247
|
-
// ---------------------------------------------------------------------------
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* The changed region between two texts, with real line numbers on both sides.
|
|
251
|
-
*
|
|
252
|
-
* No diff algorithm is needed for what the tools actually do. Trimming the
|
|
253
|
-
* identical lines off the top and the bottom leaves exactly the block that
|
|
254
|
-
* changed, and the trimmed counts are the line numbers — removed lines
|
|
255
|
-
* numbered where they were in the old file, added lines numbered where they
|
|
256
|
-
* now are in the new one. Getting that right matters: a diff whose numbers
|
|
257
|
-
* are decorative is worse than a diff with no numbers, because it invites you
|
|
258
|
-
* to jump to a line that has nothing to do with the change.
|
|
259
|
-
*/
|
|
260
|
-
export function changedRegion(oldText, newText) {
|
|
261
|
-
const before = toLines(oldText);
|
|
262
|
-
const after = toLines(newText);
|
|
263
|
-
|
|
264
|
-
let head = 0;
|
|
265
|
-
while (head < before.length && head < after.length && before[head] === after[head]) head++;
|
|
266
|
-
|
|
267
|
-
let tail = 0;
|
|
268
|
-
while (
|
|
269
|
-
tail < before.length - head &&
|
|
270
|
-
tail < after.length - head &&
|
|
271
|
-
before[before.length - 1 - tail] === after[after.length - 1 - tail]
|
|
272
|
-
) tail++;
|
|
273
|
-
|
|
274
|
-
return {
|
|
275
|
-
removed: before.slice(head, before.length - tail).map((text, i) => ({ n: head + i + 1, text })),
|
|
276
|
-
added: after.slice(head, after.length - tail).map((text, i) => ({ n: head + i + 1, text })),
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Render a change for the screen as `-12| old` / `+12| new` rows.
|
|
282
|
-
*
|
|
283
|
-
* `offset` shifts both sides when the region being diffed is an excerpt rather
|
|
284
|
-
* than a whole file — an edit_file replacement knows the line it landed on, so
|
|
285
|
-
* the numbers shown are the file's numbers rather than the excerpt's.
|
|
286
|
-
* A row with no number is a note about what was left out, never part of the
|
|
287
|
-
* change itself.
|
|
288
|
-
*/
|
|
289
|
-
export function renderDiff({ removed, added }, { offset = 0, max = 16 } = {}) {
|
|
290
|
-
const out = [];
|
|
291
|
-
const room = Math.max(2, Math.floor(max / 2));
|
|
292
|
-
|
|
293
|
-
for (const line of removed.slice(0, room)) out.push(`-${line.n + offset}| ${line.text}`);
|
|
294
|
-
if (removed.length > room) out.push(`-… ${removed.length - room} more removed`);
|
|
295
|
-
|
|
296
|
-
for (const line of added.slice(0, room)) out.push(`+${line.n + offset}| ${line.text}`);
|
|
297
|
-
if (added.length > room) out.push(`+… ${added.length - room} more added`);
|
|
298
|
-
|
|
299
|
-
return out;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** The first lines of a brand-new file, so creating one shows something. */
|
|
303
|
-
export function renderNewFile(content, max = 16) {
|
|
304
|
-
const lines = toLines(content);
|
|
305
|
-
const out = lines.slice(0, max).map((text, i) => `+${i + 1}| ${text}`);
|
|
306
|
-
if (lines.length > max) out.push(`+… ${lines.length - max} more lines`);
|
|
307
|
-
return out;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// ---------------------------------------------------------------------------
|
|
311
|
-
// Walking and globbing
|
|
312
|
-
// ---------------------------------------------------------------------------
|
|
313
|
-
|
|
314
|
-
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
315
|
-
|
|
316
|
-
/** A small glob: `**`, `*`, `?` and `{a,b}`. */
|
|
317
|
-
export function globToRegExp(pattern) {
|
|
318
|
-
let re = '';
|
|
319
|
-
let i = 0;
|
|
320
|
-
while (i < pattern.length) {
|
|
321
|
-
const c = pattern[i];
|
|
322
|
-
if (c === '*') {
|
|
323
|
-
if (pattern[i + 1] === '*') {
|
|
324
|
-
if (pattern[i + 2] === '/') { re += '(?:[^/]*/)*'; i += 3; } // spans directories
|
|
325
|
-
else { re += '.*'; i += 2; }
|
|
326
|
-
} else { re += '[^/]*'; i += 1; }
|
|
327
|
-
} else if (c === '?') {
|
|
328
|
-
re += '[^/]'; i += 1;
|
|
329
|
-
} else if (c === '{') {
|
|
330
|
-
const end = pattern.indexOf('}', i);
|
|
331
|
-
if (end === -1) { re += '\\{'; i += 1; }
|
|
332
|
-
else {
|
|
333
|
-
const alts = pattern.slice(i + 1, end).split(',').map((a) => escapeRe(a.trim()));
|
|
334
|
-
re += `(?:${alts.join('|')})`;
|
|
335
|
-
i = end + 1;
|
|
336
|
-
}
|
|
337
|
-
} else {
|
|
338
|
-
re += escapeRe(c); i += 1;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
// Windows paths are case-insensitive, so matching should be too.
|
|
342
|
-
return new RegExp(`^${re}$`, process.platform === 'win32' ? 'i' : '');
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Every file under a directory, as posix-style relative paths.
|
|
347
|
-
* Build and vendor folders are skipped unless the caller says otherwise, and
|
|
348
|
-
* an unreadable directory is stepped over rather than aborting the walk.
|
|
349
|
-
*/
|
|
350
|
-
export async function walk(base, { includeSkipped = false, limit = 20_000 } = {}) {
|
|
351
|
-
const files = [];
|
|
352
|
-
let level = [''];
|
|
353
|
-
|
|
354
|
-
// A level of the tree at a time, with every directory on that level read at
|
|
355
|
-
// once. Reading them one after another spends most of a large walk waiting
|
|
356
|
-
// on the disk for directories that did not depend on each other.
|
|
357
|
-
while (level.length && files.length < limit) {
|
|
358
|
-
const next = [];
|
|
359
|
-
for (let i = 0; i < level.length && files.length < limit; i += WALK_WIDTH) {
|
|
360
|
-
const slice = level.slice(i, i + WALK_WIDTH);
|
|
361
|
-
const listed = await Promise.all(slice.map((relDir) =>
|
|
362
|
-
fs.readdir(path.join(base, relDir), { withFileTypes: true }).then(
|
|
363
|
-
(entries) => ({ relDir, entries }),
|
|
364
|
-
() => ({ relDir, entries: [] }) // unreadable: step over it
|
|
365
|
-
)
|
|
366
|
-
));
|
|
367
|
-
|
|
368
|
-
// Results are consumed in the order the directories were queued, so the
|
|
369
|
-
// walk comes out the same every time however the reads finished.
|
|
370
|
-
for (const { relDir, entries } of listed) {
|
|
371
|
-
for (const entry of entries) {
|
|
372
|
-
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
373
|
-
if (entry.isDirectory()) {
|
|
374
|
-
if (!includeSkipped && SKIP.has(entry.name)) continue;
|
|
375
|
-
next.push(rel);
|
|
376
|
-
} else if (entry.isFile()) {
|
|
377
|
-
files.push(rel);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
level = next;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
return files.slice(0, limit);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** How many directories, or files, are read at the same time. */
|
|
389
|
-
export const WALK_WIDTH = 32;
|
|
390
|
-
|
|
391
|
-
// ---------------------------------------------------------------------------
|
|
392
|
-
// Files as ucode last left them
|
|
393
|
-
// ---------------------------------------------------------------------------
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* abs path -> the modification time ucode last saw, after reading or writing it.
|
|
397
|
-
*
|
|
398
|
-
* An agent that rewrites a whole file is trusting that the file still says what
|
|
399
|
-
* it said when it was read. That holds right up until someone has the editor
|
|
400
|
-
* open beside the terminal, saves a change mid-turn, and has it overwritten
|
|
401
|
-
* without a word — the one failure here that costs work nobody can get back,
|
|
402
|
-
* since the undo only holds what the turn itself replaced.
|
|
403
|
-
*
|
|
404
|
-
* So every read and every write leaves a stamp, and a whole-file overwrite
|
|
405
|
-
* checks it first. Edits do not need the check: they read the file again a
|
|
406
|
-
* moment before they touch it, and match their old_string against what is
|
|
407
|
-
* actually there.
|
|
408
|
-
*/
|
|
409
|
-
const known = new Map();
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* One key per file. Windows and macOS do not care about case, so "App.js" and
|
|
413
|
-
* "app.js" are the same file there — and keyed apart, ucode's own write under
|
|
414
|
-
* one spelling read as somebody else's edit under the other.
|
|
415
|
-
*/
|
|
416
|
-
const stampKey = (abs) => (process.platform === 'win32' || process.platform === 'darwin' ? abs.toLowerCase() : abs);
|
|
417
|
-
|
|
418
|
-
/** Record a file as ucode now knows it. Never throws: a missing stamp only costs the check. */
|
|
419
|
-
export async function noteFile(abs) {
|
|
420
|
-
try {
|
|
421
|
-
known.set(stampKey(abs), (await fs.stat(abs)).mtimeMs);
|
|
422
|
-
} catch {
|
|
423
|
-
known.delete(stampKey(abs));
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Write a file and stamp it, so the next overwrite knows this change was ours.
|
|
429
|
-
*
|
|
430
|
-
* @param {string} abs
|
|
431
|
-
* @param {string} data
|
|
432
|
-
* @param {BufferEncoding} [encoding]
|
|
433
|
-
*/
|
|
434
|
-
export async function writeTracked(abs, data, encoding = 'utf8') {
|
|
435
|
-
await fs.writeFile(abs, data, encoding);
|
|
436
|
-
await noteFile(abs);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/** Forget a file, so the next write to it goes through unchallenged. */
|
|
440
|
-
export function forgetFile(abs) {
|
|
441
|
-
known.delete(stampKey(abs));
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Refuse to overwrite a file that somebody else has changed since ucode read it.
|
|
446
|
-
*
|
|
447
|
-
* Only ever fires once per file: the stamp is dropped on the way out, so the
|
|
448
|
-
* model reads the file again — which is what the message tells it to do — and
|
|
449
|
-
* the next attempt writes normally. A file ucode has never seen is not guarded,
|
|
450
|
-
* because there is nothing to compare it against and a first write is not a
|
|
451
|
-
* clobber.
|
|
452
|
-
*/
|
|
453
|
-
export async function assertUnchanged(abs, show) {
|
|
454
|
-
const seen = known.get(stampKey(abs));
|
|
455
|
-
if (seen === undefined) return;
|
|
456
|
-
|
|
457
|
-
let now;
|
|
458
|
-
try {
|
|
459
|
-
now = (await fs.stat(abs)).mtimeMs;
|
|
460
|
-
} catch {
|
|
461
|
-
return; // gone, or unreadable — the write itself will say so
|
|
462
|
-
}
|
|
463
|
-
// Filesystems report times at different resolutions; a millisecond of slack
|
|
464
|
-
// costs nothing and stops a same-second write reading as someone else's.
|
|
465
|
-
if (Math.abs(now - seen) < 1) return;
|
|
466
|
-
|
|
467
|
-
known.delete(stampKey(abs));
|
|
468
|
-
throw new ToolFailure({
|
|
469
|
-
kind: 'changed_on_disk',
|
|
470
|
-
attempted: `overwriting ${show}`,
|
|
471
|
-
failed: `${show} has changed on disk since you last read it — someone else has edited it.`,
|
|
472
|
-
fix:
|
|
473
|
-
`Read ${show} again, fold your change into what is there now, and write it once more. ` +
|
|
474
|
-
'Writing the version you had would throw their edit away.',
|
|
475
|
-
});
|
|
476
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* shared.js — the plumbing every tool sits on.
|
|
3
|
+
*
|
|
4
|
+
* Path resolution, the session root, confirmation, output caps, filesystem
|
|
5
|
+
* errors worth reading, directory walking, glob matching, and the line diff
|
|
6
|
+
* that makes an edit visible. Nothing here is a tool; everything here is what
|
|
7
|
+
* the tools are built out of.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { promises as fs } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { ToolFailure, Declined } from '../core/failure.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How much of a result the model may see.
|
|
16
|
+
*
|
|
17
|
+
* File reads get a larger budget than everything else on purpose. Starving the
|
|
18
|
+
* model of the file it is about to edit costs far more — in wrong edits and in
|
|
19
|
+
* extra round trips — than the tokens it saves. A runaway build log still
|
|
20
|
+
* needs a firm lid, which is what the smaller cap is for.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_OUTPUT = Number(process.env.UCODE_MAX_TOOL_OUTPUT) || 12_000;
|
|
23
|
+
export const MAX_FILE_OUTPUT = Number(process.env.UCODE_MAX_FILE_OUTPUT) || 48_000;
|
|
24
|
+
|
|
25
|
+
export const READ_LINES = 600;
|
|
26
|
+
export const MAX_GLOB_HITS = 200;
|
|
27
|
+
export const MAX_GREP_HITS = 100;
|
|
28
|
+
|
|
29
|
+
/** Directories nobody means to search. */
|
|
30
|
+
export const SKIP = new Set([
|
|
31
|
+
'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out',
|
|
32
|
+
'.next', '.nuxt', '.svelte-kit', '.cache', 'coverage', '__pycache__',
|
|
33
|
+
'.venv', 'venv', '.tox', '.pytest_cache', 'target', '.gradle', '.idea',
|
|
34
|
+
'vendor', 'Pods', '.terraform',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Session root
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
let root = process.cwd();
|
|
42
|
+
|
|
43
|
+
export function setRoot(dir) {
|
|
44
|
+
root = path.resolve(dir);
|
|
45
|
+
return root;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getRoot() {
|
|
49
|
+
return root;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Confirmation
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The UI installs the real prompt here. Keeping it as an injection point means
|
|
58
|
+
* no tool owns a readline instance, and all of them stay testable.
|
|
59
|
+
*/
|
|
60
|
+
let asker = null;
|
|
61
|
+
|
|
62
|
+
export function setConfirm(fn) {
|
|
63
|
+
asker = fn;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function confirm(action, detail, risk = 'write') {
|
|
67
|
+
if (!asker) {
|
|
68
|
+
throw new ToolFailure({
|
|
69
|
+
kind: 'cannot_ask',
|
|
70
|
+
attempted: action,
|
|
71
|
+
failed: 'That needs the user to approve it, and there is no way to ask them from here.',
|
|
72
|
+
fix: 'Run ucode in a terminal so it can prompt before acting.',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (!(await asker({ action, detail, risk }))) throw new Declined(action);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// The request behind the call
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What the user asked for this turn, in their own words.
|
|
84
|
+
*
|
|
85
|
+
* A tool cannot normally see the request that led to it, and for almost
|
|
86
|
+
* everything that is right — a tool should act on its arguments. The starter
|
|
87
|
+
* is the exception. "Make me a plain HTML app" is not a preference to be
|
|
88
|
+
* weighed against the convenience of a framework, but the model weighs it
|
|
89
|
+
* anyway: it reads "app", reaches for the starter with the component library
|
|
90
|
+
* in it, and the user waits through an npm install they explicitly said they
|
|
91
|
+
* did not want. Keeping the request here lets create_app check the instruction
|
|
92
|
+
* rather than trust the argument it was handed.
|
|
93
|
+
*/
|
|
94
|
+
let request = '';
|
|
95
|
+
|
|
96
|
+
export function setRequest(text) {
|
|
97
|
+
request = String(text ?? '');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** "plain html", "vanilla js", "simple static page". */
|
|
101
|
+
const WANTS_PLAIN = /\b(?:plain|pure|vanilla|static|simple|basic|raw|just)\s+(?:html|js|javascript|css)\b/i;
|
|
102
|
+
/** "an html app", "one html page". */
|
|
103
|
+
const HTML_THING = /\bhtml\s+(?:app|page|site|website|file|thing)\b/i;
|
|
104
|
+
/** "no framework", "without react", "don't use next". */
|
|
105
|
+
const NO_FRAMEWORK =
|
|
106
|
+
/\b(?:no|without|not?\s+use|don'?t\s+use|do\s+not\s+use|skip)\s+(?:a\s+|any\s+)?(?:framework|frameworks|react|next\.?js|next|npm|node|build\s+step|bundler)\b/i;
|
|
107
|
+
/** Naming one on purpose outranks every hint above. */
|
|
108
|
+
const NAMES_FRAMEWORK = /\b(?:next\.?js|nextjs|react|tailwind|shadcn|typescript|database|api\s+routes?|server\s+side|auth)\b/i;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Did the user rule out a framework in so many words?
|
|
112
|
+
*
|
|
113
|
+
* Saying "no react" settles it on its own. Asking for "an html app" settles it
|
|
114
|
+
* only when the same breath does not also ask for Next.js — "export this
|
|
115
|
+
* Next.js app as static html" names the framework on purpose, and a request
|
|
116
|
+
* that specific is not one to overrule.
|
|
117
|
+
*/
|
|
118
|
+
export function askedForPlainHtml() {
|
|
119
|
+
if (NO_FRAMEWORK.test(request)) return true;
|
|
120
|
+
if (!WANTS_PLAIN.test(request) && !HTML_THING.test(request)) return false;
|
|
121
|
+
return !NAMES_FRAMEWORK.test(request);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Paths
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
export function resolveIn(input, tool, argName = 'path') {
|
|
129
|
+
if (typeof input !== 'string' || !input.trim()) {
|
|
130
|
+
throw new ToolFailure({
|
|
131
|
+
kind: 'bad_args',
|
|
132
|
+
attempted: `running ${tool}`,
|
|
133
|
+
failed: `The "${argName}" argument was missing or was not a string.`,
|
|
134
|
+
fix: `Call ${tool} again with ${argName} set to a path relative to the project root.`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const abs = path.resolve(root, input.trim());
|
|
138
|
+
const rel = path.relative(root, abs);
|
|
139
|
+
const inside = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
140
|
+
return {
|
|
141
|
+
abs,
|
|
142
|
+
inside,
|
|
143
|
+
// Short when it is in the project, fully spelled out when it is not — the
|
|
144
|
+
// display string is also the warning.
|
|
145
|
+
show: inside ? (rel === '' ? '.' : rel.split(path.sep).join('/')) : abs,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Reaching outside the folder ucode was started in always needs a yes. */
|
|
150
|
+
export async function guard(target, action) {
|
|
151
|
+
if (target.inside) return;
|
|
152
|
+
await confirm(action, `${target.abs}\nThat is outside this session's root (${root}).`, 'outside');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Results
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export function cap(text, limit = MAX_OUTPUT) {
|
|
160
|
+
const s = String(text ?? '');
|
|
161
|
+
if (s.length <= limit) return s;
|
|
162
|
+
return `${s.slice(0, limit)}\n... [cut here — ${s.length - limit} more characters]`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Every tool resolves to this shape: what the model reads, plus a one-liner. */
|
|
166
|
+
export function result(content, summary, limit = MAX_OUTPUT) {
|
|
167
|
+
return { content: cap(content, limit), summary };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Filesystem errors, translated
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
export function fsFailure(err, attempted, target) {
|
|
175
|
+
const code = err?.code;
|
|
176
|
+
const common = { attempted, cause: err };
|
|
177
|
+
|
|
178
|
+
if (code === 'ENOENT') {
|
|
179
|
+
return new ToolFailure({
|
|
180
|
+
...common,
|
|
181
|
+
kind: 'not_found',
|
|
182
|
+
failed: `Nothing exists at ${target}.`,
|
|
183
|
+
fix: 'Check the path with list_dir or glob first. Paths are relative to the project root.',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
187
|
+
return new ToolFailure({
|
|
188
|
+
...common,
|
|
189
|
+
kind: 'permission_denied',
|
|
190
|
+
failed: `The operating system refused access to ${target} (${code}).`,
|
|
191
|
+
fix: 'Check the permissions, or whether another program has the file open and locked.',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (code === 'EISDIR') {
|
|
195
|
+
return new ToolFailure({
|
|
196
|
+
...common,
|
|
197
|
+
kind: 'is_directory',
|
|
198
|
+
failed: `${target} is a directory, not a file.`,
|
|
199
|
+
fix: 'Use list_dir to see inside it.',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (code === 'ENOTDIR') {
|
|
203
|
+
return new ToolFailure({
|
|
204
|
+
...common,
|
|
205
|
+
kind: 'not_directory',
|
|
206
|
+
failed: `Something along the path ${target} is a file, not a directory.`,
|
|
207
|
+
fix: 'Re-check each segment of the path with list_dir.',
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return new ToolFailure({
|
|
211
|
+
...common,
|
|
212
|
+
kind: 'io_error',
|
|
213
|
+
failed: `${code ? `${code}: ` : ''}${err?.message ?? String(err)}`,
|
|
214
|
+
fix: 'Confirm the path exists and is readable, then try once more.',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Text
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
export const looksBinary = (buf) => buf.includes(0);
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Split into lines the way a person counts them: a file ending in a newline
|
|
226
|
+
* has that many lines, not one more empty one at the bottom.
|
|
227
|
+
*/
|
|
228
|
+
export function toLines(text) {
|
|
229
|
+
const lines = String(text).split(/\r?\n/);
|
|
230
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
|
|
231
|
+
return lines;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function bytes(n) {
|
|
235
|
+
if (n < 1024) return `${n} B`;
|
|
236
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
237
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function clip(text, n = 60) {
|
|
241
|
+
const s = String(text ?? '');
|
|
242
|
+
return s.length > n ? `${s.slice(0, n)}…` : s;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Diffs
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The changed region between two texts, with real line numbers on both sides.
|
|
251
|
+
*
|
|
252
|
+
* No diff algorithm is needed for what the tools actually do. Trimming the
|
|
253
|
+
* identical lines off the top and the bottom leaves exactly the block that
|
|
254
|
+
* changed, and the trimmed counts are the line numbers — removed lines
|
|
255
|
+
* numbered where they were in the old file, added lines numbered where they
|
|
256
|
+
* now are in the new one. Getting that right matters: a diff whose numbers
|
|
257
|
+
* are decorative is worse than a diff with no numbers, because it invites you
|
|
258
|
+
* to jump to a line that has nothing to do with the change.
|
|
259
|
+
*/
|
|
260
|
+
export function changedRegion(oldText, newText) {
|
|
261
|
+
const before = toLines(oldText);
|
|
262
|
+
const after = toLines(newText);
|
|
263
|
+
|
|
264
|
+
let head = 0;
|
|
265
|
+
while (head < before.length && head < after.length && before[head] === after[head]) head++;
|
|
266
|
+
|
|
267
|
+
let tail = 0;
|
|
268
|
+
while (
|
|
269
|
+
tail < before.length - head &&
|
|
270
|
+
tail < after.length - head &&
|
|
271
|
+
before[before.length - 1 - tail] === after[after.length - 1 - tail]
|
|
272
|
+
) tail++;
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
removed: before.slice(head, before.length - tail).map((text, i) => ({ n: head + i + 1, text })),
|
|
276
|
+
added: after.slice(head, after.length - tail).map((text, i) => ({ n: head + i + 1, text })),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Render a change for the screen as `-12| old` / `+12| new` rows.
|
|
282
|
+
*
|
|
283
|
+
* `offset` shifts both sides when the region being diffed is an excerpt rather
|
|
284
|
+
* than a whole file — an edit_file replacement knows the line it landed on, so
|
|
285
|
+
* the numbers shown are the file's numbers rather than the excerpt's.
|
|
286
|
+
* A row with no number is a note about what was left out, never part of the
|
|
287
|
+
* change itself.
|
|
288
|
+
*/
|
|
289
|
+
export function renderDiff({ removed, added }, { offset = 0, max = 16 } = {}) {
|
|
290
|
+
const out = [];
|
|
291
|
+
const room = Math.max(2, Math.floor(max / 2));
|
|
292
|
+
|
|
293
|
+
for (const line of removed.slice(0, room)) out.push(`-${line.n + offset}| ${line.text}`);
|
|
294
|
+
if (removed.length > room) out.push(`-… ${removed.length - room} more removed`);
|
|
295
|
+
|
|
296
|
+
for (const line of added.slice(0, room)) out.push(`+${line.n + offset}| ${line.text}`);
|
|
297
|
+
if (added.length > room) out.push(`+… ${added.length - room} more added`);
|
|
298
|
+
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The first lines of a brand-new file, so creating one shows something. */
|
|
303
|
+
export function renderNewFile(content, max = 16) {
|
|
304
|
+
const lines = toLines(content);
|
|
305
|
+
const out = lines.slice(0, max).map((text, i) => `+${i + 1}| ${text}`);
|
|
306
|
+
if (lines.length > max) out.push(`+… ${lines.length - max} more lines`);
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Walking and globbing
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
315
|
+
|
|
316
|
+
/** A small glob: `**`, `*`, `?` and `{a,b}`. */
|
|
317
|
+
export function globToRegExp(pattern) {
|
|
318
|
+
let re = '';
|
|
319
|
+
let i = 0;
|
|
320
|
+
while (i < pattern.length) {
|
|
321
|
+
const c = pattern[i];
|
|
322
|
+
if (c === '*') {
|
|
323
|
+
if (pattern[i + 1] === '*') {
|
|
324
|
+
if (pattern[i + 2] === '/') { re += '(?:[^/]*/)*'; i += 3; } // spans directories
|
|
325
|
+
else { re += '.*'; i += 2; }
|
|
326
|
+
} else { re += '[^/]*'; i += 1; }
|
|
327
|
+
} else if (c === '?') {
|
|
328
|
+
re += '[^/]'; i += 1;
|
|
329
|
+
} else if (c === '{') {
|
|
330
|
+
const end = pattern.indexOf('}', i);
|
|
331
|
+
if (end === -1) { re += '\\{'; i += 1; }
|
|
332
|
+
else {
|
|
333
|
+
const alts = pattern.slice(i + 1, end).split(',').map((a) => escapeRe(a.trim()));
|
|
334
|
+
re += `(?:${alts.join('|')})`;
|
|
335
|
+
i = end + 1;
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
re += escapeRe(c); i += 1;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Windows paths are case-insensitive, so matching should be too.
|
|
342
|
+
return new RegExp(`^${re}$`, process.platform === 'win32' ? 'i' : '');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Every file under a directory, as posix-style relative paths.
|
|
347
|
+
* Build and vendor folders are skipped unless the caller says otherwise, and
|
|
348
|
+
* an unreadable directory is stepped over rather than aborting the walk.
|
|
349
|
+
*/
|
|
350
|
+
export async function walk(base, { includeSkipped = false, limit = 20_000 } = {}) {
|
|
351
|
+
const files = [];
|
|
352
|
+
let level = [''];
|
|
353
|
+
|
|
354
|
+
// A level of the tree at a time, with every directory on that level read at
|
|
355
|
+
// once. Reading them one after another spends most of a large walk waiting
|
|
356
|
+
// on the disk for directories that did not depend on each other.
|
|
357
|
+
while (level.length && files.length < limit) {
|
|
358
|
+
const next = [];
|
|
359
|
+
for (let i = 0; i < level.length && files.length < limit; i += WALK_WIDTH) {
|
|
360
|
+
const slice = level.slice(i, i + WALK_WIDTH);
|
|
361
|
+
const listed = await Promise.all(slice.map((relDir) =>
|
|
362
|
+
fs.readdir(path.join(base, relDir), { withFileTypes: true }).then(
|
|
363
|
+
(entries) => ({ relDir, entries }),
|
|
364
|
+
() => ({ relDir, entries: [] }) // unreadable: step over it
|
|
365
|
+
)
|
|
366
|
+
));
|
|
367
|
+
|
|
368
|
+
// Results are consumed in the order the directories were queued, so the
|
|
369
|
+
// walk comes out the same every time however the reads finished.
|
|
370
|
+
for (const { relDir, entries } of listed) {
|
|
371
|
+
for (const entry of entries) {
|
|
372
|
+
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
373
|
+
if (entry.isDirectory()) {
|
|
374
|
+
if (!includeSkipped && SKIP.has(entry.name)) continue;
|
|
375
|
+
next.push(rel);
|
|
376
|
+
} else if (entry.isFile()) {
|
|
377
|
+
files.push(rel);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
level = next;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return files.slice(0, limit);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** How many directories, or files, are read at the same time. */
|
|
389
|
+
export const WALK_WIDTH = 32;
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Files as ucode last left them
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* abs path -> the modification time ucode last saw, after reading or writing it.
|
|
397
|
+
*
|
|
398
|
+
* An agent that rewrites a whole file is trusting that the file still says what
|
|
399
|
+
* it said when it was read. That holds right up until someone has the editor
|
|
400
|
+
* open beside the terminal, saves a change mid-turn, and has it overwritten
|
|
401
|
+
* without a word — the one failure here that costs work nobody can get back,
|
|
402
|
+
* since the undo only holds what the turn itself replaced.
|
|
403
|
+
*
|
|
404
|
+
* So every read and every write leaves a stamp, and a whole-file overwrite
|
|
405
|
+
* checks it first. Edits do not need the check: they read the file again a
|
|
406
|
+
* moment before they touch it, and match their old_string against what is
|
|
407
|
+
* actually there.
|
|
408
|
+
*/
|
|
409
|
+
const known = new Map();
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* One key per file. Windows and macOS do not care about case, so "App.js" and
|
|
413
|
+
* "app.js" are the same file there — and keyed apart, ucode's own write under
|
|
414
|
+
* one spelling read as somebody else's edit under the other.
|
|
415
|
+
*/
|
|
416
|
+
const stampKey = (abs) => (process.platform === 'win32' || process.platform === 'darwin' ? abs.toLowerCase() : abs);
|
|
417
|
+
|
|
418
|
+
/** Record a file as ucode now knows it. Never throws: a missing stamp only costs the check. */
|
|
419
|
+
export async function noteFile(abs) {
|
|
420
|
+
try {
|
|
421
|
+
known.set(stampKey(abs), (await fs.stat(abs)).mtimeMs);
|
|
422
|
+
} catch {
|
|
423
|
+
known.delete(stampKey(abs));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Write a file and stamp it, so the next overwrite knows this change was ours.
|
|
429
|
+
*
|
|
430
|
+
* @param {string} abs
|
|
431
|
+
* @param {string} data
|
|
432
|
+
* @param {BufferEncoding} [encoding]
|
|
433
|
+
*/
|
|
434
|
+
export async function writeTracked(abs, data, encoding = 'utf8') {
|
|
435
|
+
await fs.writeFile(abs, data, encoding);
|
|
436
|
+
await noteFile(abs);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Forget a file, so the next write to it goes through unchallenged. */
|
|
440
|
+
export function forgetFile(abs) {
|
|
441
|
+
known.delete(stampKey(abs));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Refuse to overwrite a file that somebody else has changed since ucode read it.
|
|
446
|
+
*
|
|
447
|
+
* Only ever fires once per file: the stamp is dropped on the way out, so the
|
|
448
|
+
* model reads the file again — which is what the message tells it to do — and
|
|
449
|
+
* the next attempt writes normally. A file ucode has never seen is not guarded,
|
|
450
|
+
* because there is nothing to compare it against and a first write is not a
|
|
451
|
+
* clobber.
|
|
452
|
+
*/
|
|
453
|
+
export async function assertUnchanged(abs, show) {
|
|
454
|
+
const seen = known.get(stampKey(abs));
|
|
455
|
+
if (seen === undefined) return;
|
|
456
|
+
|
|
457
|
+
let now;
|
|
458
|
+
try {
|
|
459
|
+
now = (await fs.stat(abs)).mtimeMs;
|
|
460
|
+
} catch {
|
|
461
|
+
return; // gone, or unreadable — the write itself will say so
|
|
462
|
+
}
|
|
463
|
+
// Filesystems report times at different resolutions; a millisecond of slack
|
|
464
|
+
// costs nothing and stops a same-second write reading as someone else's.
|
|
465
|
+
if (Math.abs(now - seen) < 1) return;
|
|
466
|
+
|
|
467
|
+
known.delete(stampKey(abs));
|
|
468
|
+
throw new ToolFailure({
|
|
469
|
+
kind: 'changed_on_disk',
|
|
470
|
+
attempted: `overwriting ${show}`,
|
|
471
|
+
failed: `${show} has changed on disk since you last read it — someone else has edited it.`,
|
|
472
|
+
fix:
|
|
473
|
+
`Read ${show} again, fold your change into what is there now, and write it once more. ` +
|
|
474
|
+
'Writing the version you had would throw their edit away.',
|
|
475
|
+
});
|
|
476
|
+
}
|