@iinm/plain-agent 1.10.6 → 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/README.md +1 -1
- package/bin/plain +6 -1
- package/package.json +2 -1
- package/src/cli/formatter.mjs +5 -6
- package/src/main.mjs +56 -53
- package/src/prompt.mjs +4 -0
- package/src/tools/patchFile.mjs +77 -63
package/README.md
CHANGED
package/bin/plain
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iinm/plain-agent",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.9",
|
|
4
4
|
"description": "A lightweight CLI-based coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +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-coverage-exclude='src/**/*.test.mjs' --test",
|
|
34
35
|
"lint": "npx @biomejs/biome check",
|
|
35
36
|
"fix": "npx @biomejs/biome check --fix"
|
|
36
37
|
},
|
package/src/cli/formatter.mjs
CHANGED
|
@@ -846,7 +846,7 @@ function renderPatchBlock(block, originalLines, nonce) {
|
|
|
846
846
|
out.push(
|
|
847
847
|
styleText(
|
|
848
848
|
"cyan",
|
|
849
|
-
|
|
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",
|
|
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
|
|
897
|
-
const headerRegex =
|
|
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(
|
|
917
|
+
const match = patch.match(/^@@@\s+(\S+)/m);
|
|
919
918
|
return match ? match[1] : null;
|
|
920
919
|
}
|
|
921
920
|
|
package/src/main.mjs
CHANGED
|
@@ -34,64 +34,70 @@ import { createWebSearchTool } from "./tools/webSearch.mjs";
|
|
|
34
34
|
import { writeFileTool } from "./tools/writeFile.mjs";
|
|
35
35
|
import { createToolUseApprover } from "./toolUseApprover.mjs";
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
for (const model of appConfig.models) {
|
|
49
|
-
const platform = model.platform;
|
|
50
|
-
console.log(
|
|
51
|
-
`${model.name}+${model.variant} (platform: ${platform.name}+${platform.variant})`,
|
|
52
|
-
);
|
|
37
|
+
/**
|
|
38
|
+
* CLI entry point. Separated from top-level so that importing this module
|
|
39
|
+
* does not start the application — required for code-coverage smoke tests.
|
|
40
|
+
*
|
|
41
|
+
* @param {string[]} argv - Typically `process.argv`.
|
|
42
|
+
*/
|
|
43
|
+
export async function main(argv = process.argv) {
|
|
44
|
+
const cliArgs = parseCliArgs(argv);
|
|
45
|
+
if (cliArgs.subcommand.type === "help") {
|
|
46
|
+
printHelp();
|
|
53
47
|
}
|
|
54
|
-
process.exit(0);
|
|
55
|
-
}
|
|
56
48
|
|
|
57
|
-
if (cliArgs.subcommand.type === "
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
71
|
-
console.error(message);
|
|
72
|
-
process.exit(1);
|
|
49
|
+
if (cliArgs.subcommand.type === "list-models") {
|
|
50
|
+
const { appConfig } = await loadAppConfig({ skipTrustCheck: true });
|
|
51
|
+
if (!appConfig.models || appConfig.models.length === 0) {
|
|
52
|
+
console.error("No models found in configuration.");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
for (const model of appConfig.models) {
|
|
56
|
+
const platform = model.platform;
|
|
57
|
+
console.log(
|
|
58
|
+
`${model.name}+${model.variant} (platform: ${platform.name}+${platform.variant})`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
process.exit(0);
|
|
73
62
|
}
|
|
74
|
-
}
|
|
75
63
|
|
|
76
|
-
if (cliArgs.subcommand.type === "
|
|
77
|
-
|
|
78
|
-
if (sessions.length === 0) {
|
|
79
|
-
console.log("No resumable sessions in .plain-agent/sessions/.");
|
|
64
|
+
if (cliArgs.subcommand.type === "install-claude-code-plugins") {
|
|
65
|
+
await installClaudeCodePlugins();
|
|
80
66
|
process.exit(0);
|
|
81
67
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
68
|
+
|
|
69
|
+
if (cliArgs.subcommand.type === "cost") {
|
|
70
|
+
try {
|
|
71
|
+
const exitCode = await runCostCommand({
|
|
72
|
+
from: cliArgs.subcommand.from,
|
|
73
|
+
to: cliArgs.subcommand.to,
|
|
74
|
+
});
|
|
75
|
+
process.exit(exitCode);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
78
|
+
console.error(message);
|
|
79
|
+
process.exit(1);
|
|
89
80
|
}
|
|
90
81
|
}
|
|
91
|
-
process.exit(0);
|
|
92
|
-
}
|
|
93
82
|
|
|
94
|
-
(
|
|
83
|
+
if (cliArgs.subcommand.type === "resume" && cliArgs.subcommand.list) {
|
|
84
|
+
const sessions = await listSessions();
|
|
85
|
+
if (sessions.length === 0) {
|
|
86
|
+
console.log("No resumable sessions in .plain-agent/sessions/.");
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
console.log("Resumable sessions (most recently updated first):\n");
|
|
90
|
+
for (const s of sessions) {
|
|
91
|
+
console.log(
|
|
92
|
+
` ${s.sessionId} ${s.modelName} (updated ${formatLocalDateTime(s.lastUpdatedAt)}, ${s.messageCount} messages)`,
|
|
93
|
+
);
|
|
94
|
+
if (s.workingDir !== process.cwd()) {
|
|
95
|
+
console.log(` workingDir: ${s.workingDir}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
95
101
|
/** @type {SessionState | null} */
|
|
96
102
|
let resumedState = null;
|
|
97
103
|
|
|
@@ -430,10 +436,7 @@ if (cliArgs.subcommand.type === "resume" && cliArgs.subcommand.list) {
|
|
|
430
436
|
voiceInput: appConfig.voiceInput,
|
|
431
437
|
});
|
|
432
438
|
}
|
|
433
|
-
}
|
|
434
|
-
console.error(err);
|
|
435
|
-
process.exit(1);
|
|
436
|
-
});
|
|
439
|
+
}
|
|
437
440
|
|
|
438
441
|
/**
|
|
439
442
|
* Generate a session id of the form `YYYY-MM-DD-HHMM-<3 random base36 chars>`.
|
package/src/prompt.mjs
CHANGED
|
@@ -70,6 +70,10 @@ Memory files should include:
|
|
|
70
70
|
|
|
71
71
|
Call multiple tools at once when they don't depend on each other's results.
|
|
72
72
|
|
|
73
|
+
## patch_file
|
|
74
|
+
|
|
75
|
+
Always read the target lines with \`read_file\` first to verify line numbers and their 2-char hashes before calling \`patch_file\`.
|
|
76
|
+
|
|
73
77
|
## exec_command
|
|
74
78
|
|
|
75
79
|
- Use relative paths.
|
package/src/tools/patchFile.mjs
CHANGED
|
@@ -17,8 +17,9 @@ export function createPatchFileTool(
|
|
|
17
17
|
return {
|
|
18
18
|
def: {
|
|
19
19
|
name: "patch_file",
|
|
20
|
-
description:
|
|
21
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
-
|
|
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 "
|
|
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 =
|
|
103
|
-
const closeMarker = `<<< ${nonce}`;
|
|
94
|
+
const openPrefix = `@@@ ${nonce} `;
|
|
104
95
|
const lines = patch.split("\n");
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 "${
|
|
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[
|
|
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 ${
|
|
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 =
|
|
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
|
-
|
|
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
|
);
|