start-command 0.41.0 → 0.43.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.
@@ -0,0 +1,797 @@
1
+ /**
2
+ * Structured `read`, `edit` and `write` tools of the experimental
3
+ * `--use-formal-ai` mode (issue #258).
4
+ *
5
+ * The `bash` tool runs only programs without shell syntax, and its write
6
+ * programs (cp, mkdir, mv, rm, rmdir, touch) cannot change the text of a
7
+ * file. These three tools are the guarded replacement primitive instead of an
8
+ * interpreter: start itself reads and writes the one named workspace file,
9
+ * byte for byte, so comments, whitespace and unrelated content survive.
10
+ *
11
+ * - read: the exact UTF-8 text of a regular file, bounded by
12
+ * START_FORMAL_AI_MAX_FILE_BYTES, as the numbered frame Formal AI decodes
13
+ * back to that text (#261); it observes the file for later changes.
14
+ * - edit: replaces one literal occurrence of old_string (or every one with
15
+ * replace_all) in a file read before, atomically, then reads it back.
16
+ * - write: replaces an observed file, or creates one in an observed
17
+ * directory, atomically, then reads it back.
18
+ *
19
+ * Paths follow the bash tool's workspace policy (no absolute, escaping or
20
+ * symlinked-out paths), edit and write need START_FORMAL_AI_EFFECTS=
21
+ * workspace-write and an earlier observation of the target, and a file that
22
+ * changed since start read it is not overwritten.
23
+ *
24
+ * Bounded typed source edits (issue #261): a change to an existing file is
25
+ * bound to the complete bytes the read tool returned for it in this task
26
+ * (listing it or `cat` is not enough), applied to exactly the matched regions
27
+ * of those bytes, guarded against a change of the target until the new file
28
+ * takes its place, and verified by reading it back: every matched region now
29
+ * holds new_string and every other byte is unchanged and in place. The
30
+ * mutation receipt (preimage, postimage and byte offsets of the matches) is
31
+ * kept in the evidence and returned to Formal AI.
32
+ */
33
+
34
+ const crypto = require('crypto');
35
+ const fs = require('fs');
36
+ const path = require('path');
37
+ const policy = require('./formal-ai-policy');
38
+
39
+ const { TextDecoder } = globalThis;
40
+
41
+ const PATH = {
42
+ type: 'string',
43
+ description: 'File path inside the working directory',
44
+ };
45
+ const FILE_TOOLS = Object.freeze({
46
+ read: Object.freeze({
47
+ type: 'function',
48
+ function: {
49
+ name: 'read',
50
+ description: 'Read the exact text of one file in the working directory.',
51
+ parameters: {
52
+ type: 'object',
53
+ properties: { path: PATH },
54
+ required: ['path'],
55
+ },
56
+ },
57
+ }),
58
+ edit: Object.freeze({
59
+ type: 'function',
60
+ function: {
61
+ name: 'edit',
62
+ description:
63
+ 'Replace the literal text old_string with new_string in one file read completely with read in this task, unchanged since; old_string must occur exactly once unless replace_all is true. Nothing else in the file changes.',
64
+ parameters: {
65
+ type: 'object',
66
+ properties: {
67
+ path: PATH,
68
+ old_string: { type: 'string' },
69
+ new_string: { type: 'string' },
70
+ replace_all: { type: 'boolean' },
71
+ },
72
+ required: ['path', 'old_string', 'new_string'],
73
+ },
74
+ },
75
+ }),
76
+ write: Object.freeze({
77
+ type: 'function',
78
+ function: {
79
+ name: 'write',
80
+ description:
81
+ 'Write the whole text of one file: a file read completely with read in this task, unchanged since, or a new file in a directory listed before.',
82
+ parameters: {
83
+ type: 'object',
84
+ properties: { path: PATH, content: { type: 'string' } },
85
+ required: ['path', 'content'],
86
+ },
87
+ },
88
+ }),
89
+ });
90
+
91
+ /** Argument names Formal AI and other agents use for the same field. */
92
+ const ALIASES = Object.freeze({
93
+ path: ['path', 'filePath', 'file_path'],
94
+ oldString: ['old_string', 'oldString', 'old_str', 'old'],
95
+ newString: ['new_string', 'newString', 'new_str', 'new'],
96
+ replaceAll: ['replace_all', 'replaceAll'],
97
+ content: ['content'],
98
+ });
99
+ const FIELDS = Object.freeze({
100
+ read: ['path'],
101
+ edit: ['path', 'oldString', 'newString', 'replaceAll'],
102
+ write: ['path', 'content'],
103
+ });
104
+ const REQUIRED = Object.freeze({
105
+ read: ['path'],
106
+ edit: ['path', 'oldString', 'newString'],
107
+ write: ['path', 'content'],
108
+ });
109
+
110
+ /**
111
+ * Normalize the JSON arguments of a file tool call.
112
+ * @param {string} tool - read, edit or write
113
+ * @param {object} args - Parsed arguments
114
+ * @returns {{args?: object, problem?: string}}
115
+ */
116
+ function normalizeFileArgs(tool, args) {
117
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
118
+ return { problem: `${tool} arguments are not a JSON object` };
119
+ }
120
+ const result = {};
121
+ for (const field of FIELDS[tool]) {
122
+ const type = field === 'replaceAll' ? 'boolean' : 'string';
123
+ const values = ALIASES[field]
124
+ .filter((key) => Object.prototype.hasOwnProperty.call(args, key))
125
+ .map((key) => args[key]);
126
+ if (values.some((value) => typeof value !== type)) {
127
+ return {
128
+ problem: `${tool} argument ${ALIASES[field][0]} must be a ${type}`,
129
+ };
130
+ }
131
+ if (values.some((value) => value !== values[0])) {
132
+ return {
133
+ problem: `${tool} received conflicting ${ALIASES[field][0]} values`,
134
+ };
135
+ }
136
+ if (values.length) {
137
+ result[field] = values[0];
138
+ }
139
+ }
140
+ const missing = REQUIRED[tool].filter((field) => !(field in result));
141
+ if (missing.length) {
142
+ return {
143
+ problem: `${tool} needs ${missing.map((f) => ALIASES[f][0]).join(', ')}`,
144
+ };
145
+ }
146
+ if (!result.path.trim() || result.path.includes('\0')) {
147
+ return { problem: `${tool} needs a non-empty path` };
148
+ }
149
+ return { args: { replaceAll: false, ...result } };
150
+ }
151
+
152
+ /** One-line rendering of a file call for notes and evidence. */
153
+ function describeFileCall(tool, args) {
154
+ return `${tool} ${args.path}`;
155
+ }
156
+
157
+ /**
158
+ * Decide whether a file tool call may run (the effect policy of #255).
159
+ * @param {string} tool - read, edit or write
160
+ * @param {object} args - normalizeFileArgs() result
161
+ * @param {object} context - {cwd, effects, observed, observedDirs}
162
+ * @returns {{allowed: boolean, reason?: string, kind?: string, target?: string}}
163
+ */
164
+ function evaluateFileCall(tool, args, context) {
165
+ const refuse = (reason) => ({ allowed: false, reason });
166
+ const kind = tool === 'read' ? 'read' : 'write';
167
+ if (kind === 'write' && context.effects !== 'workspace-write') {
168
+ return refuse(
169
+ `"${tool}" changes files; the effect mode is read-only (set START_FORMAL_AI_EFFECTS=workspace-write to allow workspace changes)`
170
+ );
171
+ }
172
+ if (args.path.startsWith('~')) {
173
+ return refuse(`"${args.path}" is outside the working directory`);
174
+ }
175
+ const target = policy.workspacePath(context.cwd, args.path);
176
+ if (target === null) {
177
+ return refuse(`"${args.path}" is outside the working directory`);
178
+ }
179
+ if (target === '.') {
180
+ return refuse(`"${args.path}" is the working directory, not a file`);
181
+ }
182
+ if (kind === 'write') {
183
+ const exists = fs.existsSync(
184
+ path.join(fs.realpathSync(context.cwd), target)
185
+ );
186
+ const observed =
187
+ context.observed.has(target) ||
188
+ (tool === 'write' &&
189
+ !exists &&
190
+ context.observedDirs.has(path.dirname(target)));
191
+ if (!observed) {
192
+ return refuse(
193
+ `target "${target}" was not ${tool === 'edit' ? 'read' : 'observed'} before the change`
194
+ );
195
+ }
196
+ }
197
+ return { allowed: true, kind, target };
198
+ }
199
+
200
+ const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
201
+ // ignoreBOM keeps a byte order mark in the text, so an edit writes it back.
202
+ const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
203
+ // The final path component is opened without following a symbolic link: the
204
+ // policy resolved the target, so a link there now is a swapped path (#261).
205
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
206
+
207
+ class FileToolError extends Error {}
208
+
209
+ /** lstat of `file`, or null when it does not exist. */
210
+ function lstatOrNull(file) {
211
+ try {
212
+ return fs.lstatSync(file);
213
+ } catch (error) {
214
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
215
+ return null;
216
+ }
217
+ throw new FileToolError(
218
+ `cannot inspect the file (${error.code || error.message})`
219
+ );
220
+ }
221
+ }
222
+
223
+ /**
224
+ * The exact bytes of a regular workspace file, read through one descriptor
225
+ * opened without following a symbolic link.
226
+ */
227
+ function readBytes(file, maxFileBytes) {
228
+ const link = lstatOrNull(file);
229
+ if (link && link.isSymbolicLink()) {
230
+ throw new FileToolError(
231
+ 'is a symbolic link that appeared after start resolved the path; nothing changed'
232
+ );
233
+ }
234
+ // Refused before opening, as the Rust port must on Windows (opening a
235
+ // directory as a file is denied there); the handle check covers a swap.
236
+ if (link && link.isDirectory()) {
237
+ throw new FileToolError('is a directory, not a file');
238
+ }
239
+ let handle;
240
+ try {
241
+ handle = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
242
+ } catch (error) {
243
+ throw new FileToolError(
244
+ error.code === 'ENOENT'
245
+ ? 'no such file'
246
+ : error.code === 'ELOOP'
247
+ ? 'is a symbolic link that appeared after start resolved the path; nothing changed'
248
+ : error.code === 'EISDIR'
249
+ ? 'is a directory, not a file'
250
+ : `cannot read the file (${error.code || error.message})`
251
+ );
252
+ }
253
+ try {
254
+ const stat = fs.fstatSync(handle);
255
+ if (stat.isDirectory()) {
256
+ throw new FileToolError('is a directory, not a file');
257
+ }
258
+ if (!stat.isFile()) {
259
+ throw new FileToolError('is not a regular file');
260
+ }
261
+ if (stat.size > maxFileBytes) {
262
+ throw new FileToolError(
263
+ `has ${stat.size} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
264
+ );
265
+ }
266
+ const bytes = fs.readFileSync(handle);
267
+ if (bytes.length > maxFileBytes) {
268
+ throw new FileToolError(
269
+ `has ${bytes.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
270
+ );
271
+ }
272
+ return { bytes, mode: stat.mode };
273
+ } finally {
274
+ fs.closeSync(handle);
275
+ }
276
+ }
277
+
278
+ /** Text of a regular workspace file, refusing what read could not return exactly. */
279
+ function readText(file, maxFileBytes) {
280
+ const { bytes, mode } = readBytes(file, maxFileBytes);
281
+ let text;
282
+ try {
283
+ text = decoder.decode(bytes);
284
+ } catch {
285
+ text = null;
286
+ }
287
+ if (text === null || text.includes('\0')) {
288
+ throw new FileToolError('is not UTF-8 text');
289
+ }
290
+ return { text, bytes, mode };
291
+ }
292
+
293
+ /** True when `text` has no lone UTF-16 surrogate (it encodes to UTF-8 exactly). */
294
+ function wellFormed(text) {
295
+ return typeof text.isWellFormed === 'function'
296
+ ? text.isWellFormed()
297
+ : !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(
298
+ text
299
+ );
300
+ }
301
+
302
+ /**
303
+ * Replace `file` by `content` through a temporary sibling. Just before the
304
+ * temporary file takes the target's place, `guard()` checks that the target
305
+ * still holds the preimage start bound the change to; a new file is linked in
306
+ * place, so a file that appeared meanwhile is never overwritten.
307
+ */
308
+ function writeAtomically(file, content, mode, guard, create) {
309
+ const temporary = path.join(
310
+ path.dirname(file),
311
+ `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.start-tmp`
312
+ );
313
+ try {
314
+ fs.writeFileSync(temporary, content, { flag: 'wx', mode: mode ?? 0o666 });
315
+ if (mode !== undefined) {
316
+ fs.chmodSync(temporary, mode & 0o7777);
317
+ }
318
+ const handle = fs.openSync(temporary, 'r+');
319
+ try {
320
+ fs.fsyncSync(handle);
321
+ } finally {
322
+ fs.closeSync(handle);
323
+ }
324
+ guard();
325
+ if (create) {
326
+ try {
327
+ fs.linkSync(temporary, file);
328
+ } catch (error) {
329
+ if (error.code === 'EEXIST') {
330
+ throw new FileToolError(
331
+ 'was created while start was writing it; nothing changed'
332
+ );
333
+ }
334
+ throw error;
335
+ }
336
+ } else {
337
+ fs.renameSync(temporary, file);
338
+ }
339
+ } finally {
340
+ fs.rmSync(temporary, { force: true });
341
+ }
342
+ }
343
+
344
+ /** Indices of the non-overlapping occurrences of `needle` in `text`. */
345
+ function occurrences(text, needle) {
346
+ const found = [];
347
+ for (
348
+ let at = text.indexOf(needle);
349
+ at >= 0;
350
+ at = text.indexOf(needle, at + needle.length)
351
+ ) {
352
+ found.push(at);
353
+ }
354
+ return found;
355
+ }
356
+
357
+ /** Byte-level splice of `preimage`: each match's old bytes become `newBytes`. */
358
+ function splice(preimage, matches, newBytes) {
359
+ const parts = [];
360
+ let cursor = 0;
361
+ for (const match of matches) {
362
+ parts.push(preimage.subarray(cursor, match.offset), newBytes);
363
+ cursor = match.offset + match.oldBytes;
364
+ }
365
+ parts.push(preimage.subarray(cursor));
366
+ return Buffer.concat(parts);
367
+ }
368
+
369
+ /**
370
+ * Check a readback against the mutation receipt, independently of how the
371
+ * new text was computed: every matched preimage region held exactly the old
372
+ * bytes, every other preimage byte is unchanged and in place, every replaced
373
+ * region holds exactly the new bytes, and nothing else was added.
374
+ * @param {Buffer} preimage - Bytes the change was bound to
375
+ * @param {Buffer} readback - Bytes read back after the change
376
+ * @param {object[]} matches - [{offset, oldBytes, newBytes}] in byte units
377
+ * @param {Buffer} oldBytes - UTF-8 bytes of old_string
378
+ * @param {Buffer} newBytes - UTF-8 bytes of new_string
379
+ * @returns {{verified: boolean, detail: string}}
380
+ */
381
+ function verifyMutation(preimage, readback, matches, oldBytes, newBytes) {
382
+ const bad = (detail) => ({ verified: false, detail });
383
+ if (!matches.length) {
384
+ return bad('the mutation receipt has no matched region');
385
+ }
386
+ const delta = matches.length * (newBytes.length - oldBytes.length);
387
+ if (readback.length !== preimage.length + delta) {
388
+ return bad(
389
+ `readback has ${readback.length} bytes, expected ${preimage.length + delta}`
390
+ );
391
+ }
392
+ let before = 0;
393
+ let after = 0;
394
+ for (const [index, match] of matches.entries()) {
395
+ if (
396
+ match.offset < before ||
397
+ match.oldBytes !== oldBytes.length ||
398
+ match.newBytes !== newBytes.length ||
399
+ match.offset + match.oldBytes > preimage.length
400
+ ) {
401
+ return bad(`match ${index + 1} of the mutation receipt is malformed`);
402
+ }
403
+ const keep = match.offset - before;
404
+ if (
405
+ !preimage
406
+ .subarray(before, match.offset)
407
+ .equals(readback.subarray(after, after + keep))
408
+ ) {
409
+ return bad(`bytes before match ${index + 1} changed`);
410
+ }
411
+ after += keep;
412
+ if (
413
+ !preimage
414
+ .subarray(match.offset, match.offset + match.oldBytes)
415
+ .equals(oldBytes)
416
+ ) {
417
+ return bad(`match ${index + 1} did not hold old_string in the preimage`);
418
+ }
419
+ if (!readback.subarray(after, after + newBytes.length).equals(newBytes)) {
420
+ return bad(
421
+ `match ${index + 1} does not hold new_string after the change`
422
+ );
423
+ }
424
+ before = match.offset + match.oldBytes;
425
+ after += newBytes.length;
426
+ }
427
+ if (!preimage.subarray(before).equals(readback.subarray(after))) {
428
+ return bad('bytes after the last match changed');
429
+ }
430
+ return {
431
+ verified: true,
432
+ detail: `${matches.length} replacement(s); every other byte unchanged`,
433
+ };
434
+ }
435
+
436
+ const digest = (bytes) => ({ sha256: sha256(bytes), bytes: bytes.length });
437
+
438
+ /**
439
+ * The complete text of a read as the numbered frame Formal AI binds as a
440
+ * whole-file Read (#261): one `NNNNN| line` per `\n`-separated line and a
441
+ * trailer with the line count. Formal AI decodes it back to exactly `text`
442
+ * (lines joined with `\n`, so a missing or present final newline, CR bytes
443
+ * and Unicode survive); its OpenAI-compatible request parser drops any other
444
+ * metadata on a tool message, so the frame is the only binding it accepts.
445
+ * @param {string} text - Complete file text
446
+ * @returns {string} Framed read result
447
+ */
448
+ function readFrame(text) {
449
+ const lines = text.split('\n');
450
+ const width = Math.max(5, String(lines.length).length);
451
+ const numbered = lines.map(
452
+ (line, index) => `${String(index + 1).padStart(width, '0')}| ${line}`
453
+ );
454
+ return `<file>\n${numbered.join('\n')}\n\n(End of file - total ${lines.length} lines)\n</file>`;
455
+ }
456
+
457
+ /**
458
+ * The exact text a readFrame() result frames, or null when it is not one.
459
+ * @param {string} frame - Read result
460
+ * @returns {string|null} Decoded text
461
+ */
462
+ function readFrameText(frame) {
463
+ const open = '<file>\n';
464
+ const marker = '\n\n(End of file - total ';
465
+ if (!frame.startsWith(open) || !frame.endsWith('</file>')) {
466
+ return null;
467
+ }
468
+ const at = frame.lastIndexOf(marker);
469
+ if (at < 0) {
470
+ return null;
471
+ }
472
+ const lines = frame.slice(open.length, at).split('\n');
473
+ const count = Number(frame.slice(at + marker.length).split(/\s+/u)[0]);
474
+ if (count !== lines.length) {
475
+ return null;
476
+ }
477
+ const out = [];
478
+ for (const [index, line] of lines.entries()) {
479
+ const split = line.indexOf('| ');
480
+ if (split < 0 || Number(line.slice(0, split)) !== index + 1) {
481
+ return null;
482
+ }
483
+ out.push(line.slice(split + 2));
484
+ }
485
+ return out.join('\n');
486
+ }
487
+
488
+ /**
489
+ * Whether a tool result of the conversation already contained `text`, as
490
+ * sent or as the file text of a read frame (whose lines carry numbers): an
491
+ * answer that only echoes file data back is not Formal AI's own (#261).
492
+ * @param {string} text - Trimmed answer
493
+ * @param {object[]} messages - Conversation so far
494
+ * @returns {boolean}
495
+ */
496
+ function echoedByTool(text, messages) {
497
+ return messages.some((m) => {
498
+ if (!m || m.role !== 'tool') {
499
+ return false;
500
+ }
501
+ const content = String(m.content ?? '');
502
+ return (
503
+ content.includes(text) || (readFrameText(content) ?? '').includes(text)
504
+ );
505
+ });
506
+ }
507
+
508
+ /**
509
+ * Run an approved file tool call.
510
+ *
511
+ * A change is bound to the complete bytes of the target that this task last
512
+ * read with the read tool (or wrote and verified): the edit is applied to
513
+ * those bytes only, the target is checked to still hold them just before the
514
+ * new file takes its place, and the readback is verified byte for byte
515
+ * against the mutation receipt (edit) or the whole intended text (write).
516
+ * @param {string} tool - read, edit or write
517
+ * @param {object} args - normalizeFileArgs() result
518
+ * @param {object} decision - evaluateFileCall() result
519
+ * @param {object} context - Loop context; `contents` maps paths to the sha256
520
+ * of the complete bytes start last read or verified; optional
521
+ * `beforeReplace(file)` and `afterReplace(file)` hooks run just before the
522
+ * guard and just before the readback (tests)
523
+ * @param {object} bounds - {maxFileBytes}
524
+ * @returns {object} Receipt {exitCode, result, error?, bytes?, sha256?,
525
+ * previousSha256?, replacements?, mutation?, verification?}
526
+ */
527
+ function runFileCall(tool, args, decision, context, bounds) {
528
+ const file = path.join(fs.realpathSync(context.cwd), decision.target);
529
+ const { target } = decision;
530
+ const fail = (message, extra = {}) => ({
531
+ exitCode: 1,
532
+ error: `${target}: ${message}`,
533
+ result: JSON.stringify({
534
+ ok: false,
535
+ is_error: true,
536
+ error: `${target}: ${message}`,
537
+ }),
538
+ ...extra,
539
+ });
540
+ if (tool === 'read') {
541
+ try {
542
+ const { text, bytes } = readText(file, bounds.maxFileBytes);
543
+ context.observed.add(target);
544
+ context.contents.set(target, sha256(bytes));
545
+ return {
546
+ exitCode: 0,
547
+ result: readFrame(text),
548
+ text,
549
+ bytes: bytes.length,
550
+ sha256: sha256(bytes),
551
+ };
552
+ } catch (error) {
553
+ return fail(
554
+ error instanceof FileToolError
555
+ ? error.message
556
+ : `failed (${error.code || error.message})`
557
+ );
558
+ }
559
+ }
560
+ let current = null;
561
+ let mutation;
562
+ let expected;
563
+ try {
564
+ const existing = lstatOrNull(file);
565
+ if (existing && existing.isSymbolicLink()) {
566
+ return fail(
567
+ 'is a symbolic link start cannot resolve inside the working directory; nothing changed'
568
+ );
569
+ }
570
+ if (tool === 'edit' || existing) {
571
+ const seen = context.contents.get(target);
572
+ if (seen === undefined) {
573
+ return fail(
574
+ 'was not read completely with the read tool in this task; read it before changing it; nothing changed'
575
+ );
576
+ }
577
+ current = readText(file, bounds.maxFileBytes);
578
+ if (seen !== sha256(current.bytes)) {
579
+ return fail(
580
+ 'changed since start read it; read it again before changing it; nothing changed'
581
+ );
582
+ }
583
+ } else if (
584
+ !fs.statSync(path.dirname(file), { throwIfNoEntry: false })?.isDirectory()
585
+ ) {
586
+ return fail('its directory does not exist');
587
+ }
588
+ let matches = null;
589
+ if (tool === 'edit') {
590
+ if (args.oldString === '') {
591
+ return fail('old_string is empty; nothing changed');
592
+ }
593
+ if (args.oldString === args.newString) {
594
+ return fail('old_string and new_string are the same; nothing changed');
595
+ }
596
+ if (!wellFormed(args.oldString) || !wellFormed(args.newString)) {
597
+ return fail(
598
+ 'old_string or new_string is not valid Unicode text; nothing changed'
599
+ );
600
+ }
601
+ const found = occurrences(current.text, args.oldString);
602
+ if (!found.length) {
603
+ return fail('old_string was not found; nothing changed');
604
+ }
605
+ if (found.length > 1 && !args.replaceAll) {
606
+ return fail(
607
+ `old_string occurs ${found.length} times; give more surrounding text or set replace_all; nothing changed`
608
+ );
609
+ }
610
+ const oldBytes = Buffer.from(args.oldString, 'utf8');
611
+ const newBytes = Buffer.from(args.newString, 'utf8');
612
+ matches = (args.replaceAll ? found : found.slice(0, 1)).map((at) => ({
613
+ offset: Buffer.byteLength(current.text.slice(0, at), 'utf8'),
614
+ oldBytes: oldBytes.length,
615
+ newBytes: newBytes.length,
616
+ }));
617
+ expected = splice(current.bytes, matches, newBytes);
618
+ // The byte splice and the text replacement must agree exactly.
619
+ const text = args.replaceAll
620
+ ? current.text.split(args.oldString).join(args.newString)
621
+ : `${current.text.slice(0, found[0])}${args.newString}${current.text.slice(found[0] + args.oldString.length)}`;
622
+ if (!expected.equals(Buffer.from(text, 'utf8'))) {
623
+ return fail(
624
+ 'the replacement does not map to whole UTF-8 characters; nothing changed'
625
+ );
626
+ }
627
+ mutation = { oldBytes, newBytes };
628
+ } else {
629
+ if (!wellFormed(args.content)) {
630
+ return fail('content is not valid Unicode text; nothing changed');
631
+ }
632
+ expected = Buffer.from(args.content, 'utf8');
633
+ }
634
+ if (expected.length > bounds.maxFileBytes) {
635
+ return fail(
636
+ `the new text has ${expected.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${bounds.maxFileBytes}; nothing changed`
637
+ );
638
+ }
639
+ mutation = {
640
+ tool,
641
+ target,
642
+ preimage: current ? digest(current.bytes) : null,
643
+ postimage: digest(expected),
644
+ ...(matches ? { matches } : {}),
645
+ ...(mutation ? { _old: mutation.oldBytes, _new: mutation.newBytes } : {}),
646
+ };
647
+ const guard = () => {
648
+ if (context.beforeReplace) {
649
+ context.beforeReplace(file);
650
+ }
651
+ const now = lstatOrNull(file);
652
+ const unchanged = current
653
+ ? now &&
654
+ !now.isSymbolicLink() &&
655
+ readBytes(file, bounds.maxFileBytes).bytes.equals(current.bytes)
656
+ : now === null;
657
+ if (!unchanged) {
658
+ throw new FileToolError(
659
+ current
660
+ ? 'changed while start was changing it; read it again; nothing changed'
661
+ : 'was created while start was writing it; nothing changed'
662
+ );
663
+ }
664
+ };
665
+ writeAtomically(
666
+ file,
667
+ expected,
668
+ current ? current.mode : undefined,
669
+ guard,
670
+ !current
671
+ );
672
+ } catch (error) {
673
+ const message =
674
+ error instanceof FileToolError
675
+ ? error.message
676
+ : `failed (${error.code || error.message})`;
677
+ if (!mutation) {
678
+ return fail(message);
679
+ }
680
+ // The change did not complete: say truthfully whether the target still
681
+ // holds its preimage, and forget a preimage that no longer holds.
682
+ const intact = stillHolds(file, current, bounds);
683
+ if (!intact) {
684
+ context.contents.delete(target);
685
+ }
686
+ return fail(
687
+ intact || /nothing changed$/.test(message)
688
+ ? `${message.replace(/; nothing changed$/, '')}; nothing changed`
689
+ : `${message}; the file may have changed; read it again`,
690
+ { mutation: publicMutation(mutation) }
691
+ );
692
+ }
693
+ context.observed.add(target);
694
+ const receipt = publicMutation(mutation);
695
+ let readback;
696
+ try {
697
+ if (context.afterReplace) {
698
+ context.afterReplace(file);
699
+ }
700
+ readback = readBytes(file, bounds.maxFileBytes).bytes;
701
+ } catch (error) {
702
+ context.contents.delete(target);
703
+ return {
704
+ exitCode: 0,
705
+ result: changeResult(receipt, false),
706
+ bytes: expected.length,
707
+ previousSha256: current ? sha256(current.bytes) : null,
708
+ replacements: receipt.matches ? receipt.matches.length : null,
709
+ mutation: receipt,
710
+ verification: {
711
+ verified: false,
712
+ detail: `readback of ${target} failed (${error.message})`,
713
+ },
714
+ };
715
+ }
716
+ const check =
717
+ tool === 'edit'
718
+ ? verifyMutation(
719
+ current.bytes,
720
+ readback,
721
+ receipt.matches,
722
+ mutation._old,
723
+ mutation._new
724
+ )
725
+ : { verified: true, detail: 'whole text' };
726
+ const verified = check.verified && readback.equals(expected);
727
+ receipt.readback = digest(readback);
728
+ if (verified) {
729
+ context.contents.set(target, sha256(readback));
730
+ } else {
731
+ context.contents.delete(target);
732
+ }
733
+ return {
734
+ exitCode: 0,
735
+ result: changeResult(receipt, verified),
736
+ bytes: expected.length,
737
+ sha256: sha256(readback),
738
+ previousSha256: current ? sha256(current.bytes) : null,
739
+ replacements: receipt.matches ? receipt.matches.length : null,
740
+ mutation: receipt,
741
+ verification: {
742
+ verified,
743
+ detail: verified
744
+ ? `readback confirmed ${tool} ${target} (${expected.length} bytes; ${check.detail})`
745
+ : `readback of ${target} differs from the intended text${check.verified ? '' : ` (${check.detail})`}`,
746
+ },
747
+ };
748
+ }
749
+
750
+ /** Whether `file` still holds the bound preimage (or is still absent). */
751
+ function stillHolds(file, current, bounds) {
752
+ try {
753
+ const now = lstatOrNull(file);
754
+ if (!current) {
755
+ return now === null;
756
+ }
757
+ return (
758
+ now !== null &&
759
+ !now.isSymbolicLink() &&
760
+ readBytes(file, bounds.maxFileBytes).bytes.equals(current.bytes)
761
+ );
762
+ } catch {
763
+ return false;
764
+ }
765
+ }
766
+
767
+ /** The mutation receipt without its internal byte buffers. */
768
+ function publicMutation(mutation) {
769
+ return Object.fromEntries(
770
+ Object.entries(mutation).filter(([key]) => key !== '_old' && key !== '_new')
771
+ );
772
+ }
773
+
774
+ /** The tool result Formal AI receives for a change: its receipt, as JSON. */
775
+ function changeResult(receipt, verified) {
776
+ return JSON.stringify({
777
+ ok: verified,
778
+ path: receipt.target,
779
+ tool: receipt.tool,
780
+ preimage: receipt.preimage,
781
+ postimage: receipt.postimage,
782
+ ...(receipt.matches ? { replacements: receipt.matches.length } : {}),
783
+ verified,
784
+ });
785
+ }
786
+
787
+ module.exports = {
788
+ FILE_TOOLS,
789
+ describeFileCall,
790
+ echoedByTool,
791
+ evaluateFileCall,
792
+ normalizeFileArgs,
793
+ readFrame,
794
+ readFrameText,
795
+ runFileCall,
796
+ verifyMutation,
797
+ };