@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 +16 -7
- package/config/config.predefined.json +4 -0
- package/package.json +1 -1
- package/src/cliCommands.mjs +5 -2
- package/src/cliCompleter.mjs +2 -1
- package/src/cliFormatter.mjs +156 -108
- package/src/main.mjs +2 -0
- package/src/mcpIntegration.mjs +1 -2
- package/src/prompt.mjs +7 -17
- package/src/tools/patchFile.d.ts +17 -1
- package/src/tools/patchFile.mjs +267 -72
- package/src/tools/readFile.d.ts +9 -0
- package/src/tools/readFile.mjs +139 -0
- package/src/utils/diffLines.mjs +91 -0
- package/src/utils/lineHash.mjs +15 -0
- package/src/utils/parseFrontmatter.mjs +65 -2
- package/src/utils/toOneLine.mjs +11 -0
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
|
-
├──
|
|
326
|
-
├──
|
|
327
|
-
├──
|
|
328
|
-
├──
|
|
329
|
-
|
|
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
|
package/package.json
CHANGED
package/src/cliCommands.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
);
|
package/src/cliCompleter.mjs
CHANGED
|
@@ -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 =
|
package/src/cliFormatter.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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: ${
|
|
127
|
-
`
|
|
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
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
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
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
453
|
-
|
|
442
|
+
const nonce = extractPatchNonce(patch);
|
|
443
|
+
if (!nonce) {
|
|
444
|
+
return fallback;
|
|
445
|
+
}
|
|
454
446
|
|
|
447
|
+
/** @type {PatchBlock[]} */
|
|
448
|
+
let blocks;
|
|
455
449
|
try {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
);
|
|
461
|
-
return null;
|
|
462
|
-
}
|
|
450
|
+
blocks = parseBlocks(patch, nonce);
|
|
451
|
+
} catch {
|
|
452
|
+
return fallback;
|
|
453
|
+
}
|
|
463
454
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
);
|
|
477
|
-
return null;
|
|
478
|
-
}
|
|
463
|
+
return blocks
|
|
464
|
+
.map((block) => renderPatchBlock(block, originalLines, nonce))
|
|
465
|
+
.join("\n\n");
|
|
466
|
+
}
|
|
479
467
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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 (
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
*
|
|
495
|
-
*
|
|
496
|
-
* @param {string}
|
|
497
|
-
* @returns {
|
|
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
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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 }),
|
package/src/mcpIntegration.mjs
CHANGED
|
@@ -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
|
|
32
|
-
|
|
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
|
|
42
|
-
|
|
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>"]
|
package/src/tools/patchFile.d.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
export type PatchFileInput = {
|
|
2
2
|
filePath: string;
|
|
3
|
-
|
|
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
|
+
};
|
package/src/tools/patchFile.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
28
|
+
patch: {
|
|
28
29
|
description: `
|
|
29
30
|
Format:
|
|
30
|
-
|
|
31
|
-
old content
|
|
32
|
-
=== ${nonce} ===
|
|
31
|
+
@@@ ${nonce} {start}:{startHash}-{end}:{endHash}
|
|
33
32
|
new content
|
|
34
|
-
|
|
33
|
+
@@@ ${nonce}
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
other new content
|
|
40
|
-
>>> ${nonce} >>> REPLACE
|
|
35
|
+
@@@ ${nonce} {N}:{afterHash}+
|
|
36
|
+
inserted content
|
|
37
|
+
@@@ ${nonce}
|
|
41
38
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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", "
|
|
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,
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
87
|
-
const
|
|
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,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
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|