@iinm/plain-agent 1.10.7 → 1.10.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.10.7",
3
+ "version": "1.10.9",
4
4
  "description": "A lightweight CLI-based coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "scripts": {
32
32
  "check": "npm run lint && tsc && npm run test",
33
33
  "test": "node --test",
34
- "coverage": "node --experimental-test-coverage --test",
34
+ "coverage": "node --experimental-test-coverage --test-coverage-exclude='src/**/*.test.mjs' --test",
35
35
  "lint": "npx @biomejs/biome check",
36
36
  "fix": "npx @biomejs/biome check --fix"
37
37
  },
@@ -846,7 +846,7 @@ function renderPatchBlock(block, originalLines, nonce) {
846
846
  out.push(
847
847
  styleText(
848
848
  "cyan",
849
- `>>> ${nonce} ${block.start}:${block.startHash}-${block.end}:${block.endHash}`,
849
+ `@@@ ${nonce} ${block.start}:${block.startHash}-${block.end}:${block.endHash}`,
850
850
  ),
851
851
  );
852
852
  if (originalLines) {
@@ -874,12 +874,11 @@ function renderPatchBlock(block, originalLines, nonce) {
874
874
  }
875
875
  } else {
876
876
  const afterSuffix = block.afterHash ? `:${block.afterHash}` : "";
877
- out.push(styleText("cyan", `>>> ${nonce} ${block.after}${afterSuffix}+`));
877
+ out.push(styleText("cyan", `@@@ ${nonce} ${block.after}${afterSuffix}+`));
878
878
  for (const line of block.body) {
879
879
  out.push(styleText("green", `+ ${line}`));
880
880
  }
881
881
  }
882
- out.push(styleText("cyan", `<<< ${nonce}`));
883
882
  return out.join("\n");
884
883
  }
885
884
 
@@ -893,8 +892,8 @@ function highlightPatchPlain(patch) {
893
892
  if (!patch) {
894
893
  return "";
895
894
  }
896
- // Patch open/close markers look like ">>> <nonce> ..." or "<<< <nonce>".
897
- const headerRegex = /^(>>>|<<<)\s+\S+(\s.*)?$/;
895
+ // Patch headers look like "@@@ <nonce> ...".
896
+ const headerRegex = /^@@@\s+\S+(\s.*)?$/;
898
897
  return patch
899
898
  .split("\n")
900
899
  .map((line) => {
@@ -915,7 +914,7 @@ function highlightPatchPlain(patch) {
915
914
  * @returns {string | null}
916
915
  */
917
916
  function extractPatchNonce(patch) {
918
- const match = patch.match(/^>>>\s+(\S+)/m);
917
+ const match = patch.match(/^@@@\s+(\S+)/m);
919
918
  return match ? match[1] : null;
920
919
  }
921
920
 
@@ -17,8 +17,9 @@ export function createPatchFileTool(
17
17
  return {
18
18
  def: {
19
19
  name: "patch_file",
20
- description:
21
- "Modify a file by replacing or inserting content addressed by line numbers (1-indexed).",
20
+ description: `Modify a file by replacing or inserting content.
21
+ When editing multiple locations in the same file, include all blocks in a single patch string rather than making multiple separate calls.
22
+ `.trim(),
22
23
  inputSchema: {
23
24
  type: "object",
24
25
  properties: {
@@ -28,29 +29,20 @@ export function createPatchFileTool(
28
29
  patch: {
29
30
  description: `
30
31
  Format — a single patch string may contain multiple blocks:
31
- >>> ${nonce} {start}:{startHash}-{end}:{endHash}
32
- new content
33
- <<< ${nonce}
34
-
35
- >>> ${nonce} {start}:{startHash}-{end}:{endHash}
36
- another new content
37
- <<< ${nonce}
38
-
39
- >>> ${nonce} {N}:{afterHash}+
32
+ @@@ ${nonce} {start}:{startHash}-{end}:{endHash}
33
+ replacement for lines {start}-{end}
34
+ @@@ ${nonce} {N}:{afterHash}+
40
35
  appended content after line N
41
- <<< ${nonce}
42
-
43
- >>> ${nonce} 0+
44
- prepended content
45
- <<< ${nonce}
46
-
47
- >>> ${nonce} 10:ab-15:cd
36
+ @@@ ${nonce} 0+
37
+ prepended content at beginning of file
38
+ @@@ ${nonce} {N}:{hash}
39
+ replace just that one line
40
+ @@@ ${nonce} 10:ab-15:cd
48
41
  (empty body deletes the range)
49
- <<< ${nonce}
50
42
 
43
+ - Each block's content starts right after its @@@ header line and ends at the next @@@ or the end of the string. Any blank lines between the header and the content become part of the replacement.
51
44
  - The nonce "${nonce}" is constant; always use the exact value shown above.
52
- - Line numbers are 1-indexed and refer to the original file; "{start}-{end}" is inclusive.
53
- - Hashes are 2-character hex hashes of each line's full content as shown by read_file (e.g. "a3").
45
+ - Hashes are 2-character hex hashes of each line's full content as shown by read_file.
54
46
  `.trim(),
55
47
  type: "string",
56
48
  },
@@ -69,7 +61,7 @@ prepended content
69
61
  const blocks = parseBlocks(patch, nonce);
70
62
  if (blocks.length === 0) {
71
63
  throw new Error(
72
- `No patch blocks found. Each block must start with ">>> ${nonce} ..." and end with "<<< ${nonce}".`,
64
+ `No patch blocks found. Each block must start with "@@@ ${nonce} ...".`,
73
65
  );
74
66
  }
75
67
 
@@ -99,52 +91,47 @@ prepended content
99
91
  * @returns {PatchBlock[]}
100
92
  */
101
93
  export function parseBlocks(patch, nonce) {
102
- const openPrefix = `>>> ${nonce} `;
103
- const closeMarker = `<<< ${nonce}`;
94
+ const openPrefix = `@@@ ${nonce} `;
104
95
  const lines = patch.split("\n");
105
-
106
- /** @type {PatchBlock[]} */
107
- const blocks = [];
96
+ // Drop trailing empty element produced by split() when patch ends with \n.
97
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
98
+ lines.pop();
99
+ }
100
+ // Find all header line indices
101
+ /** @type {number[]} */
102
+ const headerIndices = [];
108
103
  for (let i = 0; i < lines.length; i++) {
109
- const line = lines[i];
110
- if (line === "") {
111
- continue;
112
- }
113
- if (line === closeMarker) {
114
- throw new Error(
115
- `Unexpected close marker "${closeMarker}" with no matching open block (line ${i + 1} of patch).`,
116
- );
117
- }
118
- if (!line.startsWith(openPrefix)) {
119
- throw new Error(
120
- `Expected block header starting with "${openPrefix}" but got: ${JSON.stringify(line)} (line ${i + 1} of patch).`,
121
- );
104
+ if (lines[i].startsWith(openPrefix)) {
105
+ headerIndices.push(i);
122
106
  }
107
+ }
123
108
 
124
- const headerArgs = line.slice(openPrefix.length);
109
+ if (headerIndices.length === 0) {
110
+ throw new Error(
111
+ `No patch blocks found. Each block must start with "@@@ ${nonce} ...".`,
112
+ );
113
+ }
114
+
115
+ /** @type {PatchBlock[]} */
116
+ const blocks = [];
117
+ for (let i = 0; i < headerIndices.length; i++) {
118
+ const headerLineIdx = headerIndices[i];
119
+ const headerLine = lines[headerLineIdx];
120
+ const headerArgs = headerLine.slice(openPrefix.length);
125
121
  const header = parseHeaderArgs(headerArgs);
126
- const closeIdx = lines.indexOf(closeMarker, i + 1);
127
- if (closeIdx === -1) {
128
- throw new Error(
129
- `Missing close marker "${closeMarker}" for block "${openPrefix}${headerArgs}".`,
130
- );
131
- }
132
- const body = lines.slice(i + 1, closeIdx);
133
- const nestedOpen = body.findIndex((l) => l.startsWith(openPrefix));
134
- if (nestedOpen !== -1) {
135
- throw new Error(
136
- `Unclosed block "${openPrefix}${headerArgs}": found another open marker "${body[nestedOpen]}" ` +
137
- `at line ${i + 1 + nestedOpen + 1} of patch before the close marker. ` +
138
- `Did you forget "${closeMarker}" to close the previous block?`,
139
- );
140
- }
122
+
123
+ // Body: from the line after the header to the line before the next header (or EOF)
124
+ const bodyStart = headerLineIdx + 1;
125
+ const bodyEnd =
126
+ i + 1 < headerIndices.length ? headerIndices[i + 1] : lines.length;
127
+ const body = lines.slice(bodyStart, bodyEnd);
128
+
141
129
  if (header.op === "insert" && body.length === 0) {
142
130
  throw new Error(
143
- `Insert block "${openPrefix}${headerArgs}" has empty body. Use a replace block to delete content.`,
131
+ `Insert block "@@@ ${nonce} ${headerArgs}" has empty body. Use a replace block to delete content.`,
144
132
  );
145
133
  }
146
134
  blocks.push({ ...header, body });
147
- i = closeIdx;
148
135
  }
149
136
  return blocks;
150
137
  }
@@ -191,6 +178,7 @@ export function applyBlocks(original, blocks) {
191
178
 
192
179
  for (const { block } of indexed) {
193
180
  if (block.op === "replace") {
181
+ const end = block.end;
194
182
  const actualStart = lines[block.start - 1];
195
183
  const expectedStartHash = block.startHash;
196
184
  const actualStartHash = lineHash(actualStart ?? "");
@@ -199,15 +187,15 @@ export function applyBlocks(original, blocks) {
199
187
  `Hash verification failed at line ${block.start}: expected hash ${expectedStartHash} but got ${actualStartHash} for line ${JSON.stringify(actualStart)}. The line numbers may be stale; re-read the file with read_file.`,
200
188
  );
201
189
  }
202
- const actualEnd = lines[block.end - 1];
190
+ const actualEnd = lines[end - 1];
203
191
  const expectedEndHash = block.endHash;
204
192
  const actualEndHash = lineHash(actualEnd ?? "");
205
193
  if (actualEndHash !== expectedEndHash) {
206
194
  throw new Error(
207
- `Hash verification failed at line ${block.end}: expected hash ${expectedEndHash} but got ${actualEndHash} for line ${JSON.stringify(actualEnd)}. The line numbers may be stale; re-read the file with read_file.`,
195
+ `Hash verification failed at line ${end}: expected hash ${expectedEndHash} but got ${actualEndHash} for line ${JSON.stringify(actualEnd)}. The line numbers may be stale; re-read the file with read_file.`,
208
196
  );
209
197
  }
210
- const removeCount = block.end - block.start + 1;
198
+ const removeCount = end - block.start + 1;
211
199
  lines.splice(block.start - 1, removeCount, ...block.body);
212
200
  } else {
213
201
  if (block.after > 0) {
@@ -240,6 +228,7 @@ function parseHeaderArgs(headerArgs) {
240
228
  const replaceMatch = headerArgs.match(
241
229
  /^(\d+):([a-f0-9]{2})-(\d+):([a-f0-9]{2})\s*$/,
242
230
  );
231
+
243
232
  if (replaceMatch) {
244
233
  const start = Number(replaceMatch[1]);
245
234
  const end = Number(replaceMatch[3]);
@@ -261,12 +250,33 @@ function parseHeaderArgs(headerArgs) {
261
250
  endHash: replaceMatch[4],
262
251
  };
263
252
  }
253
+
254
+ // Replace form: "{N}:{hash}" (single line replace — shorthand for N:hash-N:hash)
255
+ const singleReplaceMatch = headerArgs.match(/^(\d+):([a-f0-9]{2})\s*$/);
256
+ if (singleReplaceMatch) {
257
+ const start = Number(singleReplaceMatch[1]);
258
+ if (start < 1) {
259
+ throw new Error(
260
+ `Invalid replace range "${headerArgs}": start must be >= 1.`,
261
+ );
262
+ }
263
+ return {
264
+ op: "replace",
265
+ start,
266
+ end: start,
267
+ startHash: singleReplaceMatch[2],
268
+ endHash: singleReplaceMatch[2],
269
+ };
270
+ }
271
+
264
272
  // Insert form: "0+" (no hash — there is no line 0 to verify)
265
273
  if (/^0\+\s*$/.test(headerArgs)) {
266
274
  return { op: "insert", after: 0, afterHash: "" };
267
275
  }
276
+
268
277
  // Insert form: "{N}:{afterHash}+"
269
278
  const insertMatch = headerArgs.match(/^(\d+):([a-f0-9]{2})\+\s*$/);
279
+
270
280
  if (insertMatch) {
271
281
  return {
272
282
  op: "insert",
@@ -274,8 +284,9 @@ function parseHeaderArgs(headerArgs) {
274
284
  afterHash: insertMatch[2],
275
285
  };
276
286
  }
287
+
277
288
  throw new Error(
278
- `Invalid block header arguments: ${JSON.stringify(headerArgs)}. Expected "{start}:{startHash}-{end}:{endHash}" or "{N}:{afterHash}+" or "0+".`,
289
+ `Invalid block header arguments: ${JSON.stringify(headerArgs)}. Expected "{start}:{startHash}-{end}:{endHash}" or "{N}:{hash}" or "{N}:{afterHash}+" or "0+".`,
279
290
  );
280
291
  }
281
292
 
@@ -294,7 +305,10 @@ function spliceIndexOf(block) {
294
305
  function validateBlocks(blocks, totalLines) {
295
306
  for (const block of blocks) {
296
307
  if (block.op === "replace") {
297
- if (totalLines < block.end) {
308
+ // Both bounds must be within [1, totalLines]. The two checks are NOT
309
+ // redundant: totalLines < end is false even if start > totalLines
310
+ // (e.g. start=1 on an empty file).
311
+ if (block.start > totalLines || totalLines < block.end) {
298
312
  throw new Error(
299
313
  `Replace range ${block.start}-${block.end} extends past end of file (${totalLines} lines).`,
300
314
  );