@iinm/plain-agent 1.8.10 → 1.9.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.
package/README.md CHANGED
@@ -322,17 +322,13 @@ Files are loaded in the following order. Settings in later files override earlie
322
322
  └── .plain-agent/
323
323
  ├── (3) config.json # Project-specific configuration
324
324
  ├── (4) config.local.json # Project-specific local configuration (including secrets)
325
- ├── memory/ # Task-specific memory files (auto-approvable, writable in sandbox)
326
- ├── tmp/ # Agent scratch space (auto-approvable, writable in sandbox)
327
- ├── claude-code-plugins/ # Cached Claude Code plugins (auto-approvable, writable in sandbox)
328
- ├── prompts/ # Project-specific prompts
329
- ├── agents/ # Project-specific agent roles
330
- ├── sandbox/ # Sandbox runner scripts (run.sh, Dockerfile)
331
- └── setup.sh # Initial setup script
325
+ ├── prompts/ # Project-specific prompts
326
+ ├── agents/ # Project-specific agent roles
327
+ ├── memory/ # Task-specific memory files
328
+ ├── sandbox/ # Sandbox runner scripts
329
+ └── tmp/ # Agent scratch space
332
330
  ```
333
331
 
334
- Within `.plain-agent/`, only `memory/`, `tmp/`, and `claude-code-plugins/` are auto-approvable as tool input; everything else is executed on the host or changes agent behavior, so writes/reads require explicit approval. The sandbox runner mounts `.plain-agent/` read-only and re-overlays those three scratch directories as writable.
335
-
336
332
  ### Example
337
333
 
338
334
  <details>
@@ -563,6 +559,13 @@ You are a code simplifier. Your role is to refactor code while preserving its fu
563
559
 
564
560
  ## Claude Code Plugin Support
565
561
 
562
+ Plugins are installed under `.plain-agent/claude-code-plugins/` and must be
563
+ installed per project by running `plain install-claude-code-plugins` from
564
+ the project root. Global installation (e.g., under `~/.plain-agent`) is not
565
+ supported, because plugins may include skills that the agent invokes
566
+ autonomously, and scoping them to the project keeps approval rules and
567
+ permission management straightforward.
568
+
566
569
  Example:
567
570
 
568
571
  ```js
@@ -76,22 +76,18 @@ Generate `.plain-agent/sandbox/run.sh`. Use the following Node.js example as the
76
76
  set -eu -o pipefail
77
77
 
78
78
  # Mount .plain-agent/ as read-only over the writable project root, then
79
- # re-overlay the agent's scratch directories as writable. This prevents
80
- # in-sandbox modification of host-executed scripts (sandbox/run.sh,
81
- # setup.sh) and agent config (config.json, prompts/, agents/, ...).
79
+ # re-overlay only memory/ and tmp/ as writable scratch space.
82
80
  working_dir=$(pwd)
83
81
  metadata_dir="$working_dir/.plain-agent"
84
82
  mkdir -p \
85
83
  "$metadata_dir/memory" \
86
- "$metadata_dir/tmp" \
87
- "$metadata_dir/claude-code-plugins"
84
+ "$metadata_dir/tmp"
88
85
 
89
86
  options=(
90
87
  --allow-write
91
88
  --mount-readonly "$metadata_dir:$metadata_dir"
92
89
  --mount-writable "$metadata_dir/memory:$metadata_dir/memory"
93
90
  --mount-writable "$metadata_dir/tmp:$metadata_dir/tmp"
94
- --mount-writable "$metadata_dir/claude-code-plugins:$metadata_dir/claude-code-plugins"
95
91
  --volume plain-sandbox--global--home-npm:/home/sandbox/.npm
96
92
  --volume node_modules
97
93
  )
@@ -33,6 +33,10 @@
33
33
  },
34
34
  "action": "ask"
35
35
  },
36
+ {
37
+ "toolName": "read_file",
38
+ "action": "allow"
39
+ },
36
40
  {
37
41
  "toolName": { "$regex": "^(switch_to_subagent|switch_to_main_agent)$" },
38
42
  "action": "allow"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.8.10",
3
+ "version": "1.9.0",
4
4
  "description": "A lightweight CLI-based coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,17 +2,17 @@
2
2
  * @import { Message, MessageContentToolUse, MessageContentToolResult, ProviderTokenUsage } from "./model"
3
3
  * @import { CompactContextInput } from "./tools/compactContext"
4
4
  * @import { ExecCommandInput } from "./tools/execCommand"
5
- * @import { PatchFileInput } from "./tools/patchFile"
5
+ * @import { PatchBlock, PatchFileInput } from "./tools/patchFile"
6
+ * @import { ReadFileInput } from "./tools/readFile"
6
7
  * @import { WriteFileInput } from "./tools/writeFile"
7
8
  * @import { TmuxCommandInput } from "./tools/tmuxCommand"
8
9
  * @import { SwitchToSubagentInput } from "./tools/switchToSubagent"
9
10
  */
10
11
 
11
- import { execFile } from "node:child_process";
12
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
13
- import os from "node:os";
14
- import path from "node:path";
12
+ import fs from "node:fs/promises";
15
13
  import { styleText } from "node:util";
14
+ import { parseBlocks } from "./tools/patchFile.mjs";
15
+ import { diffLines } from "./utils/diffLines.mjs";
16
16
  import { noThrow } from "./utils/noThrow.mjs";
17
17
 
18
18
  /** Length above which a single-line arg forces block-form rendering. */
@@ -61,11 +61,9 @@ export function formatArgs(args) {
61
61
  /**
62
62
  * Format tool use for display.
63
63
  * @param {MessageContentToolUse} toolUse
64
- * @param {{ createDiff?: (oldContent: string, newContent: string) => Promise<string | null> }} [options]
65
64
  * @returns {Promise<string>}
66
65
  */
67
- export async function formatToolUse(toolUse, options = {}) {
68
- const { createDiff = tryGitDiff } = options;
66
+ export async function formatToolUse(toolUse) {
69
67
  const { toolName, input } = toolUse;
70
68
 
71
69
  if (toolName === "exec_command") {
@@ -91,43 +89,30 @@ export async function formatToolUse(toolUse, options = {}) {
91
89
  if (toolName === "patch_file") {
92
90
  /** @type {Partial<PatchFileInput>} */
93
91
  const patchFileInput = input;
94
- const diff = patchFileInput.diff || "";
95
-
96
- /** @type {{search:string; replace:string}[]} */
97
- const diffs = [];
98
- const matches = Array.from(
99
- diff.matchAll(
100
- /<<< [0-9a-z]{3} <<< SEARCH\n(.*?)\n=== [0-9a-z]{3} ===\n(.*?)\n?>>> [0-9a-z]{3} >>> REPLACE/gs,
101
- ),
102
- );
103
- for (const match of matches) {
104
- const [_, search, replace] = match;
105
- diffs.push({ search, replace });
106
- }
107
-
108
- const highlightedDiff = await Promise.all(
109
- diffs.map(async ({ search, replace }) => {
110
- const gitDiffOutput = await createDiff(search, replace);
111
- if (gitDiffOutput) {
112
- return `${gitDiffOutput}\n-------\n${replace}`;
113
- }
114
- return [
115
- `${styleText("yellow", "(git diff unavailable, showing plain diff)")}`,
116
- "--- old",
117
- `${search}`,
118
- "+++ new",
119
- `${replace}`,
120
- ].join("\n");
121
- }),
122
- );
123
-
92
+ const filePath = patchFileInput.filePath ?? "";
93
+ const patch = patchFileInput.patch || "";
94
+ const rendered = await renderPatch(filePath, patch);
124
95
  return [
125
96
  `tool: ${toolName}`,
126
- `path: ${patchFileInput.filePath}`,
127
- `diff:\n${highlightedDiff.join("\n\n")}`,
97
+ `path: ${filePath}`,
98
+ `patch:\n${rendered}`,
128
99
  ].join("\n");
129
100
  }
130
101
 
102
+ if (toolName === "read_file") {
103
+ /** @type {Partial<ReadFileInput>} */
104
+ const readFileInput = input;
105
+ /** @type {string[]} */
106
+ const lines = [`tool: ${toolName}`, `filePath: ${readFileInput.filePath}`];
107
+ if (readFileInput.offset !== undefined) {
108
+ lines.push(`offset: ${readFileInput.offset}`);
109
+ }
110
+ if (readFileInput.limit !== undefined) {
111
+ lines.push(`limit: ${readFileInput.limit}`);
112
+ }
113
+ return lines.join("\n");
114
+ }
115
+
131
116
  if (toolName === "tmux_command") {
132
117
  /** @type {Partial<TmuxCommandInput>} */
133
118
  const tmuxCommandInput = input;
@@ -234,6 +219,13 @@ export function formatToolResult(toolResult) {
234
219
  .replace(/(^<error>|<\/error>$)/gm, styleText("red", "$1"));
235
220
  }
236
221
 
222
+ if (toolResult.toolName === "read_file") {
223
+ return contentString.replace(
224
+ /^(\s*\d+:[0-9a-f]{2}\|)/gm,
225
+ styleText("gray", "$1"),
226
+ );
227
+ }
228
+
237
229
  if (toolResult.toolName === "tmux_command") {
238
230
  return contentString
239
231
  .replace(/(^<stdout>|<\/stdout>$)/gm, styleText("blue", "$1"))
@@ -431,88 +423,144 @@ export async function printMessage(message) {
431
423
  }
432
424
 
433
425
  /**
434
- * Generate a colored unified diff using `git diff --color`.
435
- * Falls back to `null` if git is unavailable or if any step fails
436
- * (temp directory creation, file writing, git execution, or cleanup).
437
- * @param {string} oldContent
438
- * @param {string} newContent
439
- * @returns {Promise<string | null>}
426
+ * Render a patch_file `patch` string for terminal display.
427
+ *
428
+ * Attempts to show a side-by-side diff (- removed, + added, unchanged)
429
+ * by parsing the patch and reading the target file. Falls back to plain
430
+ * syntax highlighting on any failure.
431
+ *
432
+ * @param {string} filePath
433
+ * @param {string} patch
434
+ * @returns {Promise<string>}
440
435
  */
441
- async function tryGitDiff(oldContent, newContent) {
442
- const tmpDir = await noThrow(() =>
443
- mkdtemp(path.join(os.tmpdir(), "git-diff-")),
444
- );
445
- if (tmpDir instanceof Error) {
446
- console.error(
447
- styleText("yellow", `git diff: mkdtemp failed: ${tmpDir.message}`),
448
- );
449
- return null;
436
+ async function renderPatch(filePath, patch) {
437
+ if (!patch) {
438
+ return "";
450
439
  }
440
+ const fallback = highlightPatchPlain(patch);
451
441
 
452
- const oldPath = path.join(tmpDir, "old");
453
- const newPath = path.join(tmpDir, "new");
442
+ const nonce = extractPatchNonce(patch);
443
+ if (!nonce) {
444
+ return fallback;
445
+ }
454
446
 
447
+ /** @type {PatchBlock[]} */
448
+ let blocks;
455
449
  try {
456
- const w1 = await noThrow(() => writeFile(oldPath, oldContent, "utf8"));
457
- if (w1 instanceof Error) {
458
- console.error(
459
- styleText("yellow", `git diff: writeFile(old) failed: ${w1.message}`),
460
- );
461
- return null;
462
- }
450
+ blocks = parseBlocks(patch, nonce);
451
+ } catch {
452
+ return fallback;
453
+ }
463
454
 
464
- const w2 = await noThrow(() => writeFile(newPath, newContent, "utf8"));
465
- if (w2 instanceof Error) {
466
- console.error(
467
- styleText("yellow", `git diff: writeFile(new) failed: ${w2.message}`),
468
- );
469
- return null;
455
+ let originalLines = null;
456
+ if (filePath) {
457
+ const original = await noThrow(() => fs.readFile(filePath, "utf8"));
458
+ if (!(original instanceof Error)) {
459
+ originalLines = splitContentLines(original);
470
460
  }
461
+ }
471
462
 
472
- const diffResult = await noThrow(() => execGitDiff(oldPath, newPath));
473
- if (diffResult instanceof Error) {
474
- console.error(
475
- styleText("yellow", `git diff: exec failed: ${diffResult.message}`),
476
- );
477
- return null;
478
- }
463
+ return blocks
464
+ .map((block) => renderPatchBlock(block, originalLines, nonce))
465
+ .join("\n\n");
466
+ }
479
467
 
480
- return diffResult;
481
- } finally {
482
- const cleanup = await noThrow(() =>
483
- rm(tmpDir, { recursive: true, force: true }),
468
+ /**
469
+ * @param {PatchBlock} block
470
+ * @param {string[] | null} originalLines
471
+ * @param {string} nonce
472
+ * @returns {string}
473
+ */
474
+ function renderPatchBlock(block, originalLines, nonce) {
475
+ /** @type {string[]} */
476
+ const out = [];
477
+ if (block.op === "replace") {
478
+ out.push(
479
+ styleText(
480
+ "cyan",
481
+ `@@@ ${nonce} ${block.start}:${block.startHash}-${block.end}:${block.endHash}`,
482
+ ),
484
483
  );
485
- if (cleanup instanceof Error) {
486
- console.error(
487
- styleText("yellow", `git diff: cleanup failed: ${cleanup.message}`),
488
- );
484
+ if (originalLines) {
485
+ const safeStart = Math.max(1, block.start);
486
+ const safeEnd = Math.min(originalLines.length, block.end);
487
+ const oldSlice = originalLines.slice(safeStart - 1, safeEnd);
488
+ // Use a real line diff so unchanged lines render as context
489
+ // (no color, " " prefix) instead of being shown as both "- " and
490
+ // "+ ".
491
+ for (const op of diffLines(oldSlice, block.body)) {
492
+ if (op.type === "-") {
493
+ out.push(styleText("red", `- ${op.line}`));
494
+ } else if (op.type === "+") {
495
+ out.push(styleText("green", `+ ${op.line}`));
496
+ } else {
497
+ out.push(` ${op.line}`);
498
+ }
499
+ }
500
+ } else {
501
+ // No file context available — fall back to listing the body as
502
+ // additions so the user can still see the new content.
503
+ for (const line of block.body) {
504
+ out.push(styleText("green", `+ ${line}`));
505
+ }
506
+ }
507
+ } else {
508
+ const afterSuffix = block.afterHash ? `:${block.afterHash}` : "";
509
+ out.push(styleText("cyan", `@@@ ${nonce} ${block.after}${afterSuffix}+`));
510
+ for (const line of block.body) {
511
+ out.push(styleText("green", `+ ${line}`));
489
512
  }
490
513
  }
514
+ out.push(styleText("cyan", `@@@ ${nonce}`));
515
+ return out.join("\n");
491
516
  }
492
517
 
493
518
  /**
494
- * Execute git diff accepting exit code 1 as success (differences found).
495
- * @param {string} oldPath
496
- * @param {string} newPath
497
- * @returns {Promise<string>}
519
+ * Verbatim highlighter used as fallback when block-aware rendering is not
520
+ * possible (parse error, missing nonce, etc.).
521
+ * @param {string} patch
522
+ * @returns {string}
498
523
  */
499
- function execGitDiff(oldPath, newPath) {
500
- return new Promise((resolve, reject) => {
501
- execFile(
502
- "git",
503
- ["--no-pager", "diff", "--color", "--no-index", "--", oldPath, newPath],
504
- { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 },
505
- (error, stdout, stderr) => {
506
- if (stderr) {
507
- console.error(styleText("yellow", `git diff stderr: ${stderr}`));
508
- }
509
- // git diff returns exit code 1 when there are differences, which is expected
510
- if (error && error.code !== 1) {
511
- reject(error);
512
- } else {
513
- resolve(stdout);
514
- }
515
- },
516
- );
517
- });
524
+ function highlightPatchPlain(patch) {
525
+ if (!patch) {
526
+ return "";
527
+ }
528
+ // Patch headers/closes look like "@@@ <nonce> ..." or "@@@ <nonce>".
529
+ const headerRegex = /^@@@\s+\S+(\s.*)?$/;
530
+ return patch
531
+ .split("\n")
532
+ .map((line) => {
533
+ if (headerRegex.test(line)) {
534
+ return styleText("cyan", line);
535
+ }
536
+ if (line === "") {
537
+ return line;
538
+ }
539
+ return styleText("green", line);
540
+ })
541
+ .join("\n");
542
+ }
543
+
544
+ /**
545
+ * Extract the nonce from the first open marker in a patch_file patch.
546
+ * @param {string} patch
547
+ * @returns {string | null}
548
+ */
549
+ function extractPatchNonce(patch) {
550
+ const match = patch.match(/^@@@\s+(\S+)/m);
551
+ return match ? match[1] : null;
552
+ }
553
+
554
+ /**
555
+ * Split file content into lines, dropping the trailing empty element when
556
+ * the file ends with a newline (matches patch_file's own line indexing).
557
+ * @param {string} content
558
+ * @returns {string[]}
559
+ */
560
+ function splitContentLines(content) {
561
+ const lines = content.split("\n");
562
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
563
+ lines.pop();
564
+ }
565
+ return lines;
518
566
  }
package/src/main.mjs CHANGED
@@ -24,6 +24,7 @@ import { createAskWebTool } from "./tools/askWeb.mjs";
24
24
  import { createCompactContextTool } from "./tools/compactContext.mjs";
25
25
  import { createExecCommandTool } from "./tools/execCommand.mjs";
26
26
  import { createPatchFileTool } from "./tools/patchFile.mjs";
27
+ import { readFileTool } from "./tools/readFile.mjs";
27
28
  import { createSwitchToMainAgentTool } from "./tools/switchToMainAgent.mjs";
28
29
  import { createSwitchToSubagentTool } from "./tools/switchToSubagent.mjs";
29
30
  import { createTmuxCommandTool } from "./tools/tmuxCommand.mjs";
@@ -177,6 +178,7 @@ if (cliArgs.subcommand.type === "cost") {
177
178
 
178
179
  const builtinTools = [
179
180
  createExecCommandTool({ sandbox: appConfig.sandbox }),
181
+ readFileTool,
180
182
  writeFileTool,
181
183
  createPatchFileTool(),
182
184
  createTmuxCommandTool({ sandbox: appConfig.sandbox }),
@@ -149,8 +149,7 @@ async function createMCPTools(serverName, client) {
149
149
  const lineCount = formmatted.split("\n").length;
150
150
 
151
151
  return [
152
- `Content is large (${resultString.length} characters, ${lineCount} lines) and saved to ${filePath}`,
153
- "Use exec_command tool to find relevant parts.",
152
+ `Content is large (${resultString.length} characters, ${lineCount} lines) and saved to ${filePath}.`,
154
153
  ].join("\n");
155
154
  }),
156
155
  };
package/src/prompt.mjs CHANGED
@@ -80,7 +80,6 @@ Call multiple tools at once when they don't depend on each other's results.
80
80
  Examples:
81
81
  - List directories or find files: fd [".", "./", "--max-depth", "3", "--type", "d", "--hidden"]
82
82
  - Search for strings: rg ["--heading", "--line-number", "pattern", "./"]
83
- - Read specific line ranges (max 200 lines): sed ["-n", "1,200p", "file.txt"]
84
83
  - Manage GitHub issues and PRs:
85
84
  Get PR details: gh ["pr", "view", "123", "--json", "title,body,url"]
86
85
  Get PR comment: gh ["api", "--method", "GET", "repos/<owner>/<repo>/pulls/comments/<id>", "--jq", "{user: .user.login, path: .path, line: .line, body: .body}"]
@@ -9,6 +9,17 @@ import {
9
9
  } from "./env.mjs";
10
10
  import { noThrowSync } from "./utils/noThrow.mjs";
11
11
 
12
+ // Paths that must never be auto-approvable as tool input, even when
13
+ // git-managed. Sandbox scripts run on the host and the project config files
14
+ // drive auto-approval policy itself, so silent in-sandbox modification of
15
+ // either could lead to host code execution or self-granted privilege
16
+ // escalation.
17
+ const UNSAFE_PROJECT_PATHS = [
18
+ path.join(AGENT_PROJECT_METADATA_DIR, "sandbox"),
19
+ path.join(AGENT_PROJECT_METADATA_DIR, "config.json"),
20
+ path.join(AGENT_PROJECT_METADATA_DIR, "config.local.json"),
21
+ ];
22
+
12
23
  /**
13
24
  * @param {unknown} input
14
25
  * @returns {boolean}
@@ -65,15 +76,14 @@ export function isSafeToolInputItem(arg) {
65
76
  return false;
66
77
  }
67
78
 
68
- // Inside the agent metadata directory, only the agent's known scratch
69
- // directories (memory, tmp, claude-code-plugins) are auto-approvable
70
- // (and that exception applies even when those subdirectories are
71
- // git-ignored). Other entries (sandbox/, setup.sh, config.json,
72
- // prompts/, agents/, ...) are executed on the host or change agent
73
- // behavior across sessions, so tool uses targeting them must require
74
- // explicit approval even if the file is git-managed.
75
- if (isInsideAgentMetadataDir(realPath)) {
76
- return isSafePath(realPath);
79
+ // Always require approval for these, even if git-managed.
80
+ if (isUnsafeProjectPath(realPath)) {
81
+ return false;
82
+ }
83
+
84
+ // Always allow these even if git-ignored.
85
+ if (isSafePath(realPath)) {
86
+ return true;
77
87
  }
78
88
 
79
89
  // Deny git ignored files (which may contain sensitive information or should not be accessed)
@@ -166,12 +176,18 @@ function isSafePath(targetPath) {
166
176
  * @param {string} targetPath
167
177
  * @returns {boolean}
168
178
  */
169
- function isInsideAgentMetadataDir(targetPath) {
170
- const metadataAbsPath = path.resolve(AGENT_PROJECT_METADATA_DIR);
171
- return (
172
- targetPath === metadataAbsPath ||
173
- targetPath.startsWith(`${metadataAbsPath}${path.sep}`)
174
- );
179
+ function isUnsafeProjectPath(targetPath) {
180
+ for (const unsafePath of UNSAFE_PROJECT_PATHS) {
181
+ const unsafeAbsPath = path.resolve(unsafePath);
182
+ if (
183
+ targetPath === unsafeAbsPath ||
184
+ targetPath.startsWith(`${unsafeAbsPath}${path.sep}`)
185
+ ) {
186
+ return true;
187
+ }
188
+ }
189
+
190
+ return false;
175
191
  }
176
192
 
177
193
  /**
@@ -1,4 +1,20 @@
1
1
  export type PatchFileInput = {
2
2
  filePath: string;
3
- diff: string;
3
+ patch: string;
4
4
  };
5
+
6
+ export type PatchBlock =
7
+ | {
8
+ op: "replace";
9
+ start: number;
10
+ end: number;
11
+ startHash: string;
12
+ endHash: string;
13
+ body: string[];
14
+ }
15
+ | {
16
+ op: "insert";
17
+ after: number;
18
+ afterHash: string;
19
+ body: string[];
20
+ };
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * @import { Tool } from '../tool'
3
- * @import { PatchFileInput } from './patchFile'
3
+ * @import { PatchBlock, PatchFileInput } from './patchFile'
4
4
  */
5
5
 
6
6
  import fs from "node:fs/promises";
7
+ import { lineHash } from "../utils/lineHash.mjs";
7
8
  import { noThrow } from "../utils/noThrow.mjs";
8
9
 
9
10
  /**
@@ -17,35 +18,41 @@ export function createPatchFileTool(
17
18
  def: {
18
19
  name: "patch_file",
19
20
  description:
20
- "Modify a file by replacing specific content with new content.",
21
+ "Modify a file by replacing or inserting content addressed by line numbers (1-indexed).",
21
22
  inputSchema: {
22
23
  type: "object",
23
24
  properties: {
24
25
  filePath: {
25
26
  type: "string",
26
27
  },
27
- diff: {
28
+ patch: {
28
29
  description: `
29
30
  Format:
30
- <<< ${nonce} <<< SEARCH
31
- old content
32
- === ${nonce} ===
31
+ @@@ ${nonce} {start}:{startHash}-{end}:{endHash}
33
32
  new content
34
- >>> ${nonce} >>> REPLACE
33
+ @@@ ${nonce}
35
34
 
36
- <<< ${nonce} <<< SEARCH
37
- other old content
38
- === ${nonce} ===
39
- other new content
40
- >>> ${nonce} >>> REPLACE
35
+ @@@ ${nonce} {N}:{afterHash}+
36
+ inserted content
37
+ @@@ ${nonce}
41
38
 
42
- - Content is searched as an exact match including indentation and line breaks.
43
- - The first match found will be replaced if there are multiple matches.
44
- `.trim(),
39
+ @@@ ${nonce} 0+
40
+ prepended content
41
+ @@@ ${nonce}
42
+
43
+ - Line numbers are 1-indexed and refer to the original file;
44
+ "{start}-{end}" is inclusive.
45
+ - Hashes are 2-hex-char digests of each line's full content as shown
46
+ by read_file (e.g. "a3"). They verify the LLM is targeting the
47
+ correct lines; on mismatch, re-read the file with read_file.
48
+ - "{N}:{afterHash}+" inserts after line N; "0+" prepends (no hash
49
+ needed for line 0). "{lastLine}:{hash}+" appends.
50
+ - Empty body deletes the range.
51
+ `.trim(),
45
52
  type: "string",
46
53
  },
47
54
  },
48
- required: ["filePath", "diff"],
55
+ required: ["filePath", "patch"],
49
56
  },
50
57
  },
51
58
 
@@ -55,66 +62,16 @@ other new content
55
62
  */
56
63
  impl: async (input) =>
57
64
  await noThrow(async () => {
58
- const { filePath, diff } = input;
59
-
60
- // Validate marker counts: each block needs exactly one of each marker.
61
- // Since nonce is random, duplicate markers mean the user accidentally
62
- // included a marker line in their search/replace content (copy-paste error).
63
- const searchMarker = `<<< ${nonce} <<< SEARCH`;
64
- const sepMarker = `=== ${nonce} ===`;
65
- const replaceMarker = `>>> ${nonce} >>> REPLACE`;
66
- /** @type {(s: string, sub: string) => number} */
67
- const count = (s, sub) => s.split(sub).length - 1;
68
- const nSearch = count(diff, searchMarker);
69
- const nSep = count(diff, sepMarker);
70
- const nReplace = count(diff, replaceMarker);
71
-
72
- if (nSearch !== nReplace) {
73
- throw new Error(
74
- `Mismatched block markers: found ${nSearch} "${searchMarker}" but ${nReplace} "${replaceMarker}". ` +
75
- "Did you accidentally include a marker in your search/replace content?",
76
- );
77
- }
78
- if (nSep !== nSearch) {
65
+ const { filePath, patch } = input;
66
+ const blocks = parseBlocks(patch, nonce);
67
+ if (blocks.length === 0) {
79
68
  throw new Error(
80
- `Each diff block needs exactly one "${sepMarker}" separator, ` +
81
- `but found ${nSep} separators for ${nSearch} block(s). ` +
82
- "Did you accidentally include the separator marker in your search/replace content?",
69
+ `No patch blocks found. Each block must start with "@@@ ${nonce} ..." and end with "@@@ ${nonce}".`,
83
70
  );
84
71
  }
85
72
 
86
- const content = await fs.readFile(filePath, "utf8");
87
- const matches = Array.from(
88
- diff.matchAll(
89
- new RegExp(
90
- `<<< ${nonce} <<< SEARCH\\n(.*?)\\n=== ${nonce} ===\\n(.*?)\\n?>>> ${nonce} >>> REPLACE`,
91
- "gs",
92
- ),
93
- ),
94
- );
95
- if (matches.length === 0) {
96
- throw new Error(
97
- `Invalid diff format. Each markers must include the nonce: <<< ${nonce} <<< SEARCH, === ${nonce} ===, >>> ${nonce} >>> REPLACE`,
98
- );
99
- }
100
- let newContent = content;
101
- for (const match of matches) {
102
- const [_, search, replace] = match;
103
- if (!newContent.includes(search)) {
104
- throw new Error(
105
- JSON.stringify(`Search content not found: ${search}`),
106
- );
107
- }
108
- // Escape $ characters in replacement string to prevent interpretation of $& $1 $$ patterns
109
- const escapedReplace = replace.replace(/\$/g, "$$$$");
110
- if (replace === "" && newContent.includes(`${search}\n`)) {
111
- newContent = newContent.replace(`${search}\n`, "");
112
- } else if (replace === "" && newContent.includes(`\n${search}`)) {
113
- newContent = newContent.replace(`\n${search}`, "");
114
- } else {
115
- newContent = newContent.replace(search, escapedReplace);
116
- }
117
- }
73
+ const original = await fs.readFile(filePath, "utf8");
74
+ const newContent = applyBlocks(original, blocks);
118
75
  await fs.writeFile(filePath, newContent);
119
76
  return `Patched file: ${filePath}`;
120
77
  }),
@@ -131,3 +88,241 @@ other new content
131
88
  },
132
89
  };
133
90
  }
91
+
92
+ /**
93
+ * Parse a patch string into a list of patch blocks.
94
+ * @param {string} patch
95
+ * @param {string} nonce
96
+ * @returns {PatchBlock[]}
97
+ */
98
+ export function parseBlocks(patch, nonce) {
99
+ const openPrefix = `@@@ ${nonce} `;
100
+ const closeMarker = `@@@ ${nonce}`;
101
+ const lines = patch.split("\n");
102
+
103
+ /** @type {PatchBlock[]} */
104
+ const blocks = [];
105
+ for (let i = 0; i < lines.length; i++) {
106
+ const line = lines[i];
107
+ if (line === "") {
108
+ continue;
109
+ }
110
+ if (line === closeMarker) {
111
+ throw new Error(
112
+ `Unexpected close marker "${closeMarker}" with no matching open block (line ${i + 1} of patch).`,
113
+ );
114
+ }
115
+ if (!line.startsWith(openPrefix)) {
116
+ throw new Error(
117
+ `Expected block header starting with "${openPrefix}" but got: ${JSON.stringify(line)} (line ${i + 1} of patch).`,
118
+ );
119
+ }
120
+
121
+ const headerArgs = line.slice(openPrefix.length);
122
+ const header = parseHeaderArgs(headerArgs);
123
+ const closeIdx = lines.indexOf(closeMarker, i + 1);
124
+ if (closeIdx === -1) {
125
+ throw new Error(
126
+ `Missing close marker "${closeMarker}" for block "${openPrefix}${headerArgs}".`,
127
+ );
128
+ }
129
+ const body = lines.slice(i + 1, closeIdx);
130
+ if (header.op === "insert" && body.length === 0) {
131
+ throw new Error(
132
+ `Insert block "${openPrefix}${headerArgs}" has empty body. Use a replace block to delete content.`,
133
+ );
134
+ }
135
+ blocks.push({ ...header, body });
136
+ i = closeIdx;
137
+ }
138
+ return blocks;
139
+ }
140
+
141
+ /**
142
+ * @param {string} original
143
+ * @param {PatchBlock[]} blocks
144
+ * @returns {string}
145
+ */
146
+ export function applyBlocks(original, blocks) {
147
+ const hasTrailingNewline = original.endsWith("\n");
148
+ const lines = original.split("\n");
149
+ // Drop the trailing empty element produced by split() for both
150
+ // newline-terminated content and an empty input. This keeps line counts
151
+ // consistent with read_file (an empty file reports 0 lines).
152
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
153
+ lines.pop();
154
+ }
155
+ const totalLines = lines.length;
156
+
157
+ validateBlocks(blocks, totalLines);
158
+ detectConflicts(blocks);
159
+
160
+ // Sort for bottom-up application.
161
+ // - Higher splice index first.
162
+ // - Tie: replace before insert (replace must run first so insert can
163
+ // land at the same splice position post-replace).
164
+ // - Tie among inserts at the same point: later-in-source first, so the
165
+ // first-in-source block ends up topmost in the inserted stack.
166
+ const indexed = blocks.map((block, sourceIdx) => ({
167
+ block,
168
+ sourceIdx,
169
+ spliceIndex: spliceIndexOf(block),
170
+ }));
171
+ indexed.sort((a, b) => {
172
+ if (a.spliceIndex !== b.spliceIndex) {
173
+ return b.spliceIndex - a.spliceIndex;
174
+ }
175
+ if (a.block.op !== b.block.op) {
176
+ return a.block.op === "replace" ? -1 : 1;
177
+ }
178
+ return b.sourceIdx - a.sourceIdx;
179
+ });
180
+
181
+ for (const { block } of indexed) {
182
+ if (block.op === "replace") {
183
+ const actualStart = lines[block.start - 1];
184
+ const expectedStartHash = block.startHash;
185
+ const actualStartHash = lineHash(actualStart ?? "");
186
+ if (actualStartHash !== expectedStartHash) {
187
+ throw new Error(
188
+ `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.`,
189
+ );
190
+ }
191
+ const actualEnd = lines[block.end - 1];
192
+ const expectedEndHash = block.endHash;
193
+ const actualEndHash = lineHash(actualEnd ?? "");
194
+ if (actualEndHash !== expectedEndHash) {
195
+ throw new Error(
196
+ `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.`,
197
+ );
198
+ }
199
+ const removeCount = block.end - block.start + 1;
200
+ lines.splice(block.start - 1, removeCount, ...block.body);
201
+ } else {
202
+ if (block.after > 0) {
203
+ const actualAfter = lines[block.after - 1];
204
+ const expectedAfterHash = block.afterHash;
205
+ const actualAfterHash = lineHash(actualAfter ?? "");
206
+ if (actualAfterHash !== expectedAfterHash) {
207
+ throw new Error(
208
+ `Hash verification failed at line ${block.after}: expected hash ${expectedAfterHash} but got ${actualAfterHash} for line ${JSON.stringify(actualAfter)}. The line numbers may be stale; re-read the file with read_file.`,
209
+ );
210
+ }
211
+ }
212
+ lines.splice(block.after, 0, ...block.body);
213
+ }
214
+ }
215
+
216
+ let result = lines.join("\n");
217
+ if (hasTrailingNewline) {
218
+ result += "\n";
219
+ }
220
+ return result;
221
+ }
222
+
223
+ /**
224
+ * @param {string} headerArgs
225
+ * @returns {{ op: "replace"; start: number; end: number; startHash: string; endHash: string } | { op: "insert"; after: number; afterHash: string }}
226
+ */
227
+ function parseHeaderArgs(headerArgs) {
228
+ // Replace form: "{start}:{startHash}-{end}:{endHash}"
229
+ const replaceMatch = headerArgs.match(
230
+ /^(\d+):([a-f0-9]{2})-(\d+):([a-f0-9]{2})\s*$/,
231
+ );
232
+ if (replaceMatch) {
233
+ const start = Number(replaceMatch[1]);
234
+ const end = Number(replaceMatch[3]);
235
+ if (start < 1) {
236
+ throw new Error(
237
+ `Invalid replace range "${headerArgs}": start must be >= 1.`,
238
+ );
239
+ }
240
+ if (end < start) {
241
+ throw new Error(
242
+ `Invalid replace range "${headerArgs}": end (${end}) must be >= start (${start}).`,
243
+ );
244
+ }
245
+ return {
246
+ op: "replace",
247
+ start,
248
+ end,
249
+ startHash: replaceMatch[2],
250
+ endHash: replaceMatch[4],
251
+ };
252
+ }
253
+ // Insert form: "0+" (no hash — there is no line 0 to verify)
254
+ if (/^0\+\s*$/.test(headerArgs)) {
255
+ return { op: "insert", after: 0, afterHash: "" };
256
+ }
257
+ // Insert form: "{N}:{afterHash}+"
258
+ const insertMatch = headerArgs.match(/^(\d+):([a-f0-9]{2})\+\s*$/);
259
+ if (insertMatch) {
260
+ return {
261
+ op: "insert",
262
+ after: Number(insertMatch[1]),
263
+ afterHash: insertMatch[2],
264
+ };
265
+ }
266
+ throw new Error(
267
+ `Invalid block header arguments: ${JSON.stringify(headerArgs)}. Expected "{start}:{startHash}-{end}:{endHash}" or "{N}:{afterHash}+" or "0+".`,
268
+ );
269
+ }
270
+
271
+ /**
272
+ * @param {PatchBlock} block
273
+ * @returns {number}
274
+ */
275
+ function spliceIndexOf(block) {
276
+ return block.op === "replace" ? block.start - 1 : block.after;
277
+ }
278
+
279
+ /**
280
+ * @param {PatchBlock[]} blocks
281
+ * @param {number} totalLines
282
+ */
283
+ function validateBlocks(blocks, totalLines) {
284
+ for (const block of blocks) {
285
+ if (block.op === "replace") {
286
+ if (totalLines < block.end) {
287
+ throw new Error(
288
+ `Replace range ${block.start}-${block.end} extends past end of file (${totalLines} lines).`,
289
+ );
290
+ }
291
+ } else if (block.after < 0 || totalLines < block.after) {
292
+ throw new Error(
293
+ `Insert position ${block.after}+ is outside [0, ${totalLines}].`,
294
+ );
295
+ }
296
+ }
297
+ }
298
+
299
+ /**
300
+ * @param {PatchBlock[]} blocks
301
+ */
302
+ function detectConflicts(blocks) {
303
+ for (let i = 0; i < blocks.length; i++) {
304
+ for (let j = i + 1; j < blocks.length; j++) {
305
+ const a = blocks[i];
306
+ const b = blocks[j];
307
+ if (a.op === "replace" && b.op === "replace") {
308
+ if (a.start <= b.end && b.start <= a.end) {
309
+ throw new Error(
310
+ `Replace ranges overlap: ${a.start}-${a.end} and ${b.start}-${b.end}.`,
311
+ );
312
+ }
313
+ } else if (a.op === "replace" && b.op === "insert") {
314
+ if (a.start <= b.after && b.after < a.end) {
315
+ throw new Error(
316
+ `Insert at ${b.after}+ falls inside replace range ${a.start}-${a.end}.`,
317
+ );
318
+ }
319
+ } else if (a.op === "insert" && b.op === "replace") {
320
+ if (b.start <= a.after && a.after < b.end) {
321
+ throw new Error(
322
+ `Insert at ${a.after}+ falls inside replace range ${b.start}-${b.end}.`,
323
+ );
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
@@ -0,0 +1,9 @@
1
+ export type ReadFileInput = {
2
+ filePath: string;
3
+ offset?: number;
4
+ limit?: number;
5
+ };
6
+
7
+ export type ReadFileConfig = {
8
+ outputMaxLength?: number;
9
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @import { Tool } from '../tool'
3
+ * @import { ReadFileInput } from './readFile'
4
+ */
5
+
6
+ import fs from "node:fs";
7
+ import readline from "node:readline";
8
+ import { lineHash } from "../utils/lineHash.mjs";
9
+ import { noThrow } from "../utils/noThrow.mjs";
10
+
11
+ const OUTPUT_MAX_LENGTH = 1024 * 8;
12
+
13
+ /** @type {Tool} */
14
+ export const readFileTool = {
15
+ def: {
16
+ name: "read_file",
17
+ description:
18
+ "Read a file with line numbers (1-indexed). Each line is prefixed with its number and a short content hash: `{no}:{hash}|{content}` (e.g. `1:a3|function hello() {`).",
19
+ inputSchema: {
20
+ type: "object",
21
+ properties: {
22
+ filePath: {
23
+ type: "string",
24
+ },
25
+ offset: {
26
+ description: "1-indexed start line. Defaults to 1.",
27
+ type: "number",
28
+ },
29
+ limit: {
30
+ description: "Maximum number of lines to return.",
31
+ type: "number",
32
+ },
33
+ },
34
+ required: ["filePath"],
35
+ },
36
+ },
37
+
38
+ /**
39
+ * @param {ReadFileInput} input
40
+ * @returns {Promise<string | Error>}
41
+ */
42
+ impl: async (input) =>
43
+ await noThrow(async () => {
44
+ const { filePath } = input;
45
+ const offset = input.offset ?? 1;
46
+ const limit = input.limit;
47
+
48
+ if (!Number.isInteger(offset) || offset < 1) {
49
+ throw new Error("offset must be a positive integer (1-indexed)");
50
+ }
51
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
52
+ throw new Error("limit must be a positive integer");
53
+ }
54
+
55
+ const lines = await readLineRange(filePath, offset, limit);
56
+ return formatNumberedLines(lines, offset);
57
+ }),
58
+ };
59
+
60
+ /**
61
+ * @param {string} filePath
62
+ * @param {number} offset
63
+ * @param {number | undefined} limit
64
+ * @returns {Promise<string[]>}
65
+ */
66
+ async function readLineRange(filePath, offset, limit) {
67
+ const stream = fs.createReadStream(filePath, { encoding: "utf8" });
68
+ const rl = readline.createInterface({
69
+ input: stream,
70
+ crlfDelay: Number.POSITIVE_INFINITY,
71
+ });
72
+
73
+ /** @type {string[]} */
74
+ const lines = [];
75
+ let lineNo = 0;
76
+ // Line-number padding and tab separator are not counted toward the cap.
77
+ let acceptedLength = 0;
78
+
79
+ try {
80
+ for await (const line of rl) {
81
+ lineNo++;
82
+ if (lineNo < offset) {
83
+ continue;
84
+ }
85
+
86
+ const lineCost = line.length + 1;
87
+
88
+ if (acceptedLength + lineCost > OUTPUT_MAX_LENGTH) {
89
+ if (lines.length === 0) {
90
+ throw new Error(
91
+ `Output would exceed ${OUTPUT_MAX_LENGTH} characters at line ${lineNo}: ` +
92
+ "that line alone is too large to include. Consider reading the file with a different tool.",
93
+ );
94
+ }
95
+ const lastFitting = offset + lines.length - 1;
96
+ throw new Error(
97
+ `Output would exceed ${OUTPUT_MAX_LENGTH} characters at line ${lineNo}. ` +
98
+ `Lines ${offset}-${lastFitting} fit; read them with limit=${lines.length}, ` +
99
+ `then continue from offset=${lastFitting + 1}.`,
100
+ );
101
+ }
102
+
103
+ acceptedLength += lineCost;
104
+ lines.push(line);
105
+
106
+ if (limit !== undefined && lines.length >= limit) {
107
+ break;
108
+ }
109
+ }
110
+ } finally {
111
+ rl.close();
112
+ if (!stream.destroyed) {
113
+ stream.destroy();
114
+ }
115
+ }
116
+
117
+ return lines;
118
+ }
119
+
120
+ /**
121
+ * @param {string[]} lines
122
+ * @param {number} startLine 1-indexed line number of `lines[0]`.
123
+ * @returns {string}
124
+ */
125
+ function formatNumberedLines(lines, startLine) {
126
+ if (lines.length === 0) {
127
+ return "";
128
+ }
129
+ const lastLineNo = startLine + lines.length - 1;
130
+ const width = String(lastLineNo).length;
131
+
132
+ const out = [];
133
+ for (let i = 0; i < lines.length; i++) {
134
+ const lineNo = String(startLine + i).padStart(width, " ");
135
+ const hash = lineHash(lines[i]);
136
+ out.push(`${lineNo}:${hash}|${lines[i]}`);
137
+ }
138
+ return out.join("\n");
139
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @typedef {{ type: " " | "-" | "+"; line: string }} DiffOp
3
+ */
4
+
5
+ /**
6
+ * Compute a unified-style line diff between two arrays.
7
+ *
8
+ * Returns an edit script that transforms `oldLines` into `newLines`,
9
+ * with three op kinds:
10
+ * - " " : line is in both (context)
11
+ * - "-" : line is only in old (removed)
12
+ * - "+" : line is only in new (added)
13
+ *
14
+ * Within a hunk (a run of changes between context lines), all `-` ops
15
+ * appear before all `+` ops to match the conventional unified-diff
16
+ * presentation produced by `git diff` and friends.
17
+ *
18
+ * Implementation: standard O(N*M) longest-common-subsequence DP plus
19
+ * a backtrack pass. This is fine for the patch_file block sizes we
20
+ * expect (typically a few dozen lines per block); we avoid pulling in
21
+ * a Myers-diff dependency.
22
+ *
23
+ * @param {string[]} oldLines
24
+ * @param {string[]} newLines
25
+ * @returns {DiffOp[]}
26
+ */
27
+ export function diffLines(oldLines, newLines) {
28
+ const n = oldLines.length;
29
+ const m = newLines.length;
30
+
31
+ // dp[i][j] = LCS length of oldLines[0..i) and newLines[0..j).
32
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
33
+ for (let i = 1; i <= n; i++) {
34
+ for (let j = 1; j <= m; j++) {
35
+ if (oldLines[i - 1] === newLines[j - 1]) {
36
+ dp[i][j] = dp[i - 1][j - 1] + 1;
37
+ } else {
38
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
39
+ }
40
+ }
41
+ }
42
+
43
+ // Backtrack from (n, m) to (0, 0). We walk in reverse, accumulating
44
+ // pending deletes/adds until we hit a context line; then we flush
45
+ // them so that, after the final reverse, deletes appear before adds
46
+ // within each hunk.
47
+ /** @type {DiffOp[]} */
48
+ const ops = [];
49
+ /** @type {string[]} */
50
+ let pendingDel = [];
51
+ /** @type {string[]} */
52
+ let pendingAdd = [];
53
+
54
+ // While walking back, we push ops in reverse order. For each hunk we
55
+ // want the final order (after reverse()) to be: deletes-in-source-order
56
+ // then adds-in-source-order. So during the reverse walk we must push
57
+ // adds first, then deletes.
58
+ const flush = () => {
59
+ for (const line of pendingAdd) {
60
+ ops.push({ type: "+", line });
61
+ }
62
+ for (const line of pendingDel) {
63
+ ops.push({ type: "-", line });
64
+ }
65
+ pendingAdd = [];
66
+ pendingDel = [];
67
+ };
68
+
69
+ let i = n;
70
+ let j = m;
71
+ while (i > 0 || j > 0) {
72
+ if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
73
+ flush();
74
+ ops.push({ type: " ", line: oldLines[i - 1] });
75
+ i--;
76
+ j--;
77
+ continue;
78
+ }
79
+ if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
80
+ pendingAdd.push(newLines[j - 1]);
81
+ j--;
82
+ } else {
83
+ pendingDel.push(oldLines[i - 1]);
84
+ i--;
85
+ }
86
+ }
87
+ flush();
88
+
89
+ ops.reverse();
90
+ return ops;
91
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Compute a short hash of a line's full content (including whitespace).
3
+ * Uses the DJB2 hash algorithm, producing a 2-hex-char digest (256 values).
4
+ * @param {string} line
5
+ * @returns {string} 2-character lowercase hex string
6
+ */
7
+ export function lineHash(line) {
8
+ let hash = 0;
9
+ for (let i = 0; i < line.length; i++) {
10
+ const char = line.charCodeAt(i);
11
+ hash = (hash << 5) - hash + char;
12
+ hash = hash & hash;
13
+ }
14
+ return Math.abs(hash).toString(16).padStart(2, "0").slice(0, 2);
15
+ }