@iinm/plain-agent 1.8.11 → 1.9.1

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
@@ -16,6 +16,8 @@ A lightweight CLI-based coding agent.
16
16
  - **Sandboxed execution** — Run the agent's shell commands inside a Docker
17
17
  container with network access restricted to allowlisted destinations
18
18
  (e.g., `registry.npmjs.org` only for `npm install`).
19
+ - **Plain-text memory** — Task state is persisted as Markdown files under
20
+ `.plain-agent/memory/`, easy to review.
19
21
  - **Extensible** — Define prompts and subagents in Markdown. Connect MCP servers.
20
22
  Supports Claude Code plugins and `.claude/` commands, subagents, and skills.
21
23
 
@@ -24,6 +26,7 @@ A lightweight CLI-based coding agent.
24
26
  - **Sequential subagent execution** — Subagents run one at a time rather than
25
27
  in parallel. The trade-off is full visibility: every step is streamed to
26
28
  your terminal so you can follow exactly what each subagent is doing.
29
+ - **No session persistence** — Sessions are not persisted. Start a fresh session and use memory files (`.plain-agent/memory/`) instead.
27
30
 
28
31
  ## Requirements
29
32
 
@@ -322,13 +325,11 @@ Files are loaded in the following order. Settings in later files override earlie
322
325
  └── .plain-agent/
323
326
  ├── (3) config.json # Project-specific configuration
324
327
  ├── (4) config.local.json # Project-specific local configuration (including secrets)
325
- ├── memory/ # Task-specific memory files (auto-approvable)
326
- ├── tmp/ # Agent scratch space (auto-approvable)
327
- ├── claude-code-plugins/ # Cached Claude Code plugins
328
- ├── prompts/ # Project-specific prompts
329
- ├── agents/ # Project-specific agent roles
330
- ├── sandbox/ # Sandbox runner scripts (run.sh, Dockerfile); always require approval
331
- └── setup.sh # Initial setup script
328
+ ├── prompts/ # Project-specific prompts
329
+ ├── agents/ # Project-specific agent roles
330
+ ├── memory/ # Task-specific memory files
331
+ ├── sandbox/ # Sandbox runner scripts
332
+ └── tmp/ # Agent scratch space
332
333
  ```
333
334
 
334
335
  ### Example
@@ -497,6 +498,7 @@ Files are loaded in the following order. Settings in later files override earlie
497
498
 
498
499
  The agent can use the following tools to assist with tasks:
499
500
 
501
+ - **read_file**: Read a file with line numbers (1-indexed). Supports `offset` and `limit` to read a specific range.
500
502
  - **write_file**: Write a file.
501
503
  - **patch_file**: Patch a file.
502
504
  - **exec_command**: Run a command without shell interpretation.
@@ -561,6 +563,13 @@ You are a code simplifier. Your role is to refactor code while preserving its fu
561
563
 
562
564
  ## Claude Code Plugin Support
563
565
 
566
+ Plugins are installed under `.plain-agent/claude-code-plugins/` and must be
567
+ installed per project by running `plain install-claude-code-plugins` from
568
+ the project root. Global installation (e.g., under `~/.plain-agent`) is not
569
+ supported, because plugins may include skills that the agent invokes
570
+ autonomously, and scoping them to the project keeps approval rules and
571
+ permission management straightforward.
572
+
564
573
  Example:
565
574
 
566
575
  ```js
@@ -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.11",
3
+ "version": "1.9.1",
4
4
  "description": "A lightweight CLI-based coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,7 @@ import { loadUserMessageContext } from "./context/loadUserMessageContext.mjs";
12
12
  import { CLAUDE_CODE_COMPATIBILITY_NOTES } from "./prompt.mjs";
13
13
  import { parseFileRange } from "./utils/parseFileRange.mjs";
14
14
  import { readFileRange } from "./utils/readFileRange.mjs";
15
+ import { toOneLine } from "./utils/toOneLine.mjs";
15
16
 
16
17
  /**
17
18
  * @typedef {"prompt" | "continue"} CommandResult
@@ -172,7 +173,8 @@ export function createCommandHandler({
172
173
  } else {
173
174
  for (const role of agentRoles.values()) {
174
175
  const maxLength = process.stdout.columns ?? 100;
175
- const line = ` ${styleText("cyan", role.id.padEnd(20))} - ${role.description}`;
176
+ const desc = toOneLine(role.description);
177
+ const line = ` ${styleText("cyan", role.id.padEnd(20))} - ${desc}`;
176
178
  console.log(
177
179
  line.length > maxLength ? `${line.slice(0, maxLength)}...` : line,
178
180
  );
@@ -201,7 +203,8 @@ export function createCommandHandler({
201
203
  } else {
202
204
  for (const prompt of prompts.values()) {
203
205
  const maxLength = process.stdout.columns ?? 100;
204
- const line = ` ${styleText("cyan", prompt.id.padEnd(20))} - ${prompt.description}`;
206
+ const desc = toOneLine(prompt.description);
207
+ const line = ` ${styleText("cyan", prompt.id.padEnd(20))} - ${desc}`;
205
208
  console.log(
206
209
  line.length > maxLength ? `${line.slice(0, maxLength)}...` : line,
207
210
  );
@@ -5,6 +5,7 @@
5
5
  import { styleText } from "node:util";
6
6
  import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
7
7
  import { loadPrompts } from "./context/loadPrompts.mjs";
8
+ import { toOneLine } from "./utils/toOneLine.mjs";
8
9
 
9
10
  // Define available slash commands for tab completion
10
11
  export const SLASH_COMMANDS = [
@@ -129,7 +130,7 @@ function showCompletions(rl, candidates, line, callback) {
129
130
  if (typeof c === "string") return c;
130
131
  const nameText = c.name.padEnd(25);
131
132
  const separator = " - ";
132
- const descText = c.description;
133
+ const descText = toOneLine(c.description);
133
134
 
134
135
  // 画面幅に合わせて説明文をカット(色を付ける前に計算)
135
136
  const availableWidth =
@@ -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
@@ -1,3 +1,5 @@
1
+ import { toOneLine } from "./utils/toOneLine.mjs";
2
+
1
3
  /**
2
4
  * @typedef {object} PromptConfig
3
5
  * @property {string} username
@@ -28,37 +30,27 @@ export function createPrompt({
28
30
  }) {
29
31
  const agentRoleDescriptions = Array.from(agentRoles.entries())
30
32
  .map(([id, role]) => {
31
- const desc =
32
- role.description.length > 100
33
- ? `${role.description.substring(0, 100)}...`
34
- : role.description;
33
+ const flat = toOneLine(role.description);
34
+ const desc = flat.length > 100 ? `${flat.substring(0, 100)}...` : flat;
35
35
  return `- ${id}: ${desc}`;
36
36
  })
37
37
  .join("\n");
38
38
 
39
39
  const skillDescriptions = skills
40
40
  .map((skill) => {
41
- const desc =
42
- skill.description.length > 100
43
- ? `${skill.description.substring(0, 100)}...`
44
- : skill.description;
41
+ const flat = toOneLine(skill.description);
42
+ const desc = flat.length > 100 ? `${flat.substring(0, 100)}...` : flat;
45
43
  return `- ${skill.filePath}\n ${desc}`;
46
44
  })
47
45
  .join("\n");
48
46
 
49
47
  return `
50
- # Communication Style
51
-
52
- - Respond in the user's language.
53
- - Address the user by their name, rather than "user".
54
- - Use emojis sparingly to highlight key points.
55
-
56
48
  # Memory Files
57
49
 
58
50
  - Create/Update memory files after creating/updating a plan, completing milestones, encountering issues, or making decisions.
59
51
  - Update existing task memory when continuing the same task.
60
- - Write the memory content in the user's language.
61
52
  - Ensure self-containment: The file must be standalone. A reader should fully understand the task context, logic and progress without any other references.
53
+ - Write the memory content in the user's language.
62
54
 
63
55
  Memory files should include:
64
56
  - Task overview: What the task is, why it's being done, requirements and constraints
@@ -80,7 +72,6 @@ Call multiple tools at once when they don't depend on each other's results.
80
72
  Examples:
81
73
  - List directories or find files: fd [".", "./", "--max-depth", "3", "--type", "d", "--hidden"]
82
74
  - Search for strings: rg ["--heading", "--line-number", "pattern", "./"]
83
- - Read specific line ranges (max 200 lines): sed ["-n", "1,200p", "file.txt"]
84
75
  - Manage GitHub issues and PRs:
85
76
  Get PR details: gh ["pr", "view", "123", "--json", "title,body,url"]
86
77
  Get PR comment: gh ["api", "--method", "GET", "repos/<owner>/<repo>/pulls/comments/<id>", "--jq", "{user: .user.login, path: .path, line: .line, body: .body}"]
@@ -89,7 +80,6 @@ Examples:
89
80
 
90
81
  - Only use when the user explicitly requests it.
91
82
  - Create a new session with the given tmux session id.
92
- - Use relative paths.
93
83
 
94
84
  Examples:
95
85
  - Start session: new-session ["-d", "-s", "<tmux-session-id>"]
@@ -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
+ }
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Parse simple key-value frontmatter using regex.
3
- * Only supports `key: value` format. No multiline strings.
3
+ * Supports `key: value` and YAML block scalars (`key: |` literal,
4
+ * `key: >` folded), with optional chomping indicators (`-`, `+`).
5
+ * Block scalar lines are read while indented further than column 0,
6
+ * using the first non-empty block line's indentation as the base.
4
7
  * @param {string} frontmatter - The YAML frontmatter content (without --- delimiters)
5
8
  * @returns {Record<string, string>} Parsed key-value pairs
6
9
  */
@@ -8,12 +11,72 @@ export function parseFrontmatter(frontmatter) {
8
11
  /** @type {Record<string, string>} */
9
12
  const result = {};
10
13
 
11
- for (const line of frontmatter.split(/\r?\n/)) {
14
+ const lines = frontmatter.split(/\r?\n/);
15
+ let i = 0;
16
+ while (i < lines.length) {
17
+ const line = lines[i];
18
+
19
+ const blockMatch = line.match(/^(\w[\w-]*):\s*([|>])[+-]?\s*$/);
20
+ if (blockMatch) {
21
+ const key = blockMatch[1];
22
+ const style = blockMatch[2];
23
+ i++;
24
+
25
+ /** @type {string[]} */
26
+ const blockLines = [];
27
+ let indent = -1;
28
+ while (i < lines.length) {
29
+ const blockLine = lines[i];
30
+ if (blockLine.trim() === "") {
31
+ blockLines.push("");
32
+ i++;
33
+ continue;
34
+ }
35
+ const leadingSpaces = (blockLine.match(/^( *)/)?.[1] ?? "").length;
36
+ if (indent === -1) {
37
+ if (leadingSpaces === 0) break;
38
+ indent = leadingSpaces;
39
+ } else if (leadingSpaces < indent) {
40
+ break;
41
+ }
42
+ blockLines.push(blockLine.slice(indent));
43
+ i++;
44
+ }
45
+
46
+ while (blockLines.at(-1) === "") {
47
+ blockLines.pop();
48
+ }
49
+
50
+ result[key] =
51
+ style === "|" ? blockLines.join("\n") : foldFolded(blockLines);
52
+ continue;
53
+ }
54
+
12
55
  const match = line.match(/^(\w[\w-]*):\s?(.*)$/);
13
56
  if (match) {
14
57
  result[match[1]] = match[2].trimEnd();
15
58
  }
59
+ i++;
16
60
  }
17
61
 
18
62
  return result;
19
63
  }
64
+
65
+ /**
66
+ * @param {string[]} blockLines
67
+ * @returns {string}
68
+ */
69
+ function foldFolded(blockLines) {
70
+ const paragraphs = [];
71
+ let current = [];
72
+ for (const line of blockLines) {
73
+ if (line === "") {
74
+ paragraphs.push(current.join(" "));
75
+ current = [];
76
+ } else {
77
+ current.push(line);
78
+ }
79
+ }
80
+ paragraphs.push(current.join(" "));
81
+ return paragraphs.join("\n");
82
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Collapse newlines (and any whitespace adjacent to them) into a single space
3
+ * and trim. Used for rendering frontmatter values such as `description` in
4
+ * single-line UI contexts (bullet lists, completer rows, console output).
5
+ *
6
+ * @param {string} s
7
+ * @returns {string}
8
+ */
9
+ export function toOneLine(s) {
10
+ return s.replace(/\s*\n\s*/g, " ").trim();
11
+ }