mono-pilot 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/src/cli.js +85 -0
- package/dist/src/extensions/mono-pilot.js +19 -0
- package/dist/tools/README.md +31 -0
- package/dist/tools/apply-patch.js +337 -0
- package/dist/tools/apply-patch.md +93 -0
- package/dist/tools/delete.js +166 -0
- package/dist/tools/delete.md +5 -0
- package/dist/tools/glob.js +146 -0
- package/dist/tools/glob.md +18 -0
- package/dist/tools/read-file.js +156 -0
- package/dist/tools/read-file.md +23 -0
- package/dist/tools/rg.js +342 -0
- package/dist/tools/rg.md +35 -0
- package/dist/tools/shell.js +478 -0
- package/dist/tools/shell.md +152 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 qianwan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# MonoPilot
|
|
2
|
+
|
|
3
|
+
> Cursor-compatible coding agent profile powered by pi-mono.
|
|
4
|
+
|
|
5
|
+
**⚠️ Disclaimer:** This project is an independent compatibility implementation and is **not affiliated with Cursor** or Anysphere Inc.
|
|
6
|
+
|
|
7
|
+
## MVP Scope (Current)
|
|
8
|
+
|
|
9
|
+
This repository is intentionally in a **minimal MVP** stage:
|
|
10
|
+
|
|
11
|
+
- ✅ Core toolset is wired through `mono-pilot` extensions (`read`, `rg`, `glob`, `shell`, `delete`, `apply_patch`).
|
|
12
|
+
- ✅ Built-in `edit` / `write` are removed from default exposed tools.
|
|
13
|
+
- ✅ File mutation is funneled through `apply_patch` (which internally delegates to robust pi-mono primitives).
|
|
14
|
+
- ✅ Additional tools can be migrated incrementally without changing the launcher architecture.
|
|
15
|
+
|
|
16
|
+
## What ships now
|
|
17
|
+
|
|
18
|
+
- `src/cli.ts` – `mono-pilot` launcher that wraps `pi`
|
|
19
|
+
- `src/extensions/mono-pilot.ts` – extension entrypoint (wires the current toolset)
|
|
20
|
+
- `tools/*.ts` – tool implementations (`read`, `rg`, `glob`, `shell`, `delete`, `apply_patch`)
|
|
21
|
+
- `tools/*.md` – tool prompt specs injected at runtime
|
|
22
|
+
|
|
23
|
+
## Local development setup
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/qianwan/mono-pilot.git
|
|
27
|
+
cd mono-pilot
|
|
28
|
+
npm install
|
|
29
|
+
npm run build
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Use in any repository
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Run directly without global install
|
|
36
|
+
cd /path/to/your/project
|
|
37
|
+
npx mono-pilot
|
|
38
|
+
|
|
39
|
+
# Or install globally
|
|
40
|
+
npm install -g mono-pilot
|
|
41
|
+
mono-pilot
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Interactive
|
|
48
|
+
mono-pilot
|
|
49
|
+
|
|
50
|
+
# One-shot prompt
|
|
51
|
+
mono-pilot -p "Refactor this module"
|
|
52
|
+
|
|
53
|
+
# Continue previous session
|
|
54
|
+
mono-pilot --continue
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
By default, `mono-pilot` launches pi with:
|
|
58
|
+
|
|
59
|
+
- `--no-extensions`
|
|
60
|
+
- `--extension <mono-pilot extension>`
|
|
61
|
+
- `--tools read,bash,grep,find,ls`
|
|
62
|
+
|
|
63
|
+
So the exposed write path is `apply_patch`, not built-in `edit` / `write`.
|
|
64
|
+
If you pass `--tools` manually and include `edit`/`write`, MonoPilot strips them automatically.
|
|
65
|
+
|
|
66
|
+
## Publishing
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# 1) Validate and build
|
|
70
|
+
npm run check
|
|
71
|
+
npm run build
|
|
72
|
+
|
|
73
|
+
# 2) Verify package contents
|
|
74
|
+
npm pack --dry-run
|
|
75
|
+
|
|
76
|
+
# 3) Publish
|
|
77
|
+
npm publish
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Roadmap
|
|
81
|
+
|
|
82
|
+
- Gradually migrate other Cursor-style tools from `tools/`
|
|
83
|
+
- Keep compatibility behavior focused and testable, one tool at a time
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
*Not affiliated with Cursor or Anysphere Inc. Cursor is a trademark of Anysphere Inc.*
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const DEFAULT_TOOLS = "ls";
|
|
7
|
+
const TOOL_BLACKLIST = new Set(["edit", "write", "grep", "read", "glob", "bash"]);
|
|
8
|
+
function hasFlag(args, names) {
|
|
9
|
+
for (let i = 0; i < args.length; i++) {
|
|
10
|
+
const arg = args[i];
|
|
11
|
+
if (names.includes(arg))
|
|
12
|
+
return true;
|
|
13
|
+
for (const name of names) {
|
|
14
|
+
if (arg.startsWith(`${name}=`))
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
function sanitizeToolList(rawValue) {
|
|
21
|
+
const tools = rawValue
|
|
22
|
+
.split(",")
|
|
23
|
+
.map((tool) => tool.trim())
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.filter((tool) => !TOOL_BLACKLIST.has(tool));
|
|
26
|
+
if (tools.length === 0) {
|
|
27
|
+
return DEFAULT_TOOLS;
|
|
28
|
+
}
|
|
29
|
+
return Array.from(new Set(tools)).join(",");
|
|
30
|
+
}
|
|
31
|
+
function sanitizeToolsArgs(args) {
|
|
32
|
+
const sanitized = [];
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
const arg = args[i];
|
|
35
|
+
if (arg === "--tools") {
|
|
36
|
+
const raw = args[i + 1] ?? "";
|
|
37
|
+
sanitized.push("--tools", sanitizeToolList(raw));
|
|
38
|
+
i++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (arg.startsWith("--tools=")) {
|
|
42
|
+
const raw = arg.slice("--tools=".length);
|
|
43
|
+
sanitized.push(`--tools=${sanitizeToolList(raw)}`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
sanitized.push(arg);
|
|
47
|
+
}
|
|
48
|
+
return sanitized;
|
|
49
|
+
}
|
|
50
|
+
function buildPiArgs(userArgs) {
|
|
51
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const extensionPath = resolve(here, "extensions", "mono-pilot.js");
|
|
53
|
+
const sanitizedUserArgs = sanitizeToolsArgs(userArgs);
|
|
54
|
+
const args = ["--no-extensions", "--extension", extensionPath];
|
|
55
|
+
if (!hasFlag(sanitizedUserArgs, ["--tools", "--no-tools"])) {
|
|
56
|
+
args.push("--tools", DEFAULT_TOOLS);
|
|
57
|
+
}
|
|
58
|
+
return [...args, ...sanitizedUserArgs];
|
|
59
|
+
}
|
|
60
|
+
function resolvePiCliPath() {
|
|
61
|
+
const codingAgentEntryUrl = import.meta.resolve("@mariozechner/pi-coding-agent");
|
|
62
|
+
const codingAgentEntryPath = fileURLToPath(codingAgentEntryUrl);
|
|
63
|
+
return resolve(dirname(codingAgentEntryPath), "cli.js");
|
|
64
|
+
}
|
|
65
|
+
function main() {
|
|
66
|
+
const userArgs = process.argv.slice(2);
|
|
67
|
+
const piArgs = buildPiArgs(userArgs);
|
|
68
|
+
const piCliPath = resolvePiCliPath();
|
|
69
|
+
const child = spawn(process.execPath, [piCliPath, ...piArgs], {
|
|
70
|
+
stdio: "inherit",
|
|
71
|
+
env: process.env,
|
|
72
|
+
});
|
|
73
|
+
child.on("exit", (code, signal) => {
|
|
74
|
+
if (signal) {
|
|
75
|
+
process.kill(process.pid, signal);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
process.exit(code ?? 1);
|
|
79
|
+
});
|
|
80
|
+
child.on("error", (error) => {
|
|
81
|
+
console.error(`[mono-pilot] Failed to launch pi: ${error.message}`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
main();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import shellExtension from "../../tools/shell.js";
|
|
2
|
+
import globExtension from "../../tools/glob.js";
|
|
3
|
+
import rgExtension from "../../tools/rg.js";
|
|
4
|
+
import readFileExtension from "../../tools/read-file.js";
|
|
5
|
+
import deleteExtension from "../../tools/delete.js";
|
|
6
|
+
import applyPatchExtension from "../../tools/apply-patch.js";
|
|
7
|
+
const toolExtensions = [
|
|
8
|
+
shellExtension,
|
|
9
|
+
globExtension,
|
|
10
|
+
rgExtension,
|
|
11
|
+
readFileExtension,
|
|
12
|
+
deleteExtension,
|
|
13
|
+
applyPatchExtension,
|
|
14
|
+
];
|
|
15
|
+
export default function monoPilotExtension(pi) {
|
|
16
|
+
for (const register of toolExtensions) {
|
|
17
|
+
register(pi);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Tools
|
|
2
|
+
|
|
3
|
+
This directory stores the tool layer for `mono-pilot`.
|
|
4
|
+
|
|
5
|
+
## Structure
|
|
6
|
+
|
|
7
|
+
Each tool is split into two files:
|
|
8
|
+
|
|
9
|
+
- `*.ts`: tool implementation (registration, validation, execution)
|
|
10
|
+
- `*.md`: tool spec text injected into the runtime prompt
|
|
11
|
+
|
|
12
|
+
## Current tools
|
|
13
|
+
|
|
14
|
+
- `apply-patch.ts` / `apply-patch.md` (`apply_patch`)
|
|
15
|
+
- Apply single-file patches in `*** Begin Patch` format.
|
|
16
|
+
- `read-file.ts` / `read-file.md` (`read`)
|
|
17
|
+
- Read file content with truncation and pagination behavior.
|
|
18
|
+
- `rg.ts` / `rg.md` (`rg`)
|
|
19
|
+
- Search file content with ripgrep.
|
|
20
|
+
- `glob.ts` / `glob.md` (`Glob`)
|
|
21
|
+
- Find paths by glob pattern.
|
|
22
|
+
- `shell.ts` / `shell.md` (`shell`)
|
|
23
|
+
- Execute shell commands in the workspace.
|
|
24
|
+
- `delete.ts` / `delete.md` (`Delete`)
|
|
25
|
+
- Delete files or directories.
|
|
26
|
+
|
|
27
|
+
## Maintenance rules
|
|
28
|
+
|
|
29
|
+
- Keep each `*.ts` and `*.md` pair aligned.
|
|
30
|
+
- When adding/removing tools, update this file in the same commit.
|
|
31
|
+
- Keep wording concrete and implementation-focused.
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply Patch Tool Example
|
|
3
|
+
*
|
|
4
|
+
* Adds an `apply_patch` custom tool that applies a single-file patch document
|
|
5
|
+
* in the familiar "*** Begin Patch" format.
|
|
6
|
+
*
|
|
7
|
+
* This is intentionally low-intrusion:
|
|
8
|
+
* - it does NOT modify built-in tools
|
|
9
|
+
* - it delegates actual file mutations to built-in `write` and `edit` tools
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, readFile, rename } from "node:fs/promises";
|
|
12
|
+
import { dirname, isAbsolute } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { createEditTool, createWriteTool } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
16
|
+
import { Type } from "@sinclair/typebox";
|
|
17
|
+
const BEGIN_PATCH = "*** Begin Patch";
|
|
18
|
+
const END_PATCH = "*** End Patch";
|
|
19
|
+
const ADD_FILE = "*** Add File: ";
|
|
20
|
+
const UPDATE_FILE = "*** Update File: ";
|
|
21
|
+
const MOVE_TO = "*** Move to: ";
|
|
22
|
+
const END_OF_FILE = "*** End of File";
|
|
23
|
+
const APPLY_PATCH_PROMPT_MARKER = "## ApplyPatch";
|
|
24
|
+
const APPLY_PATCH_DOC_PATH = fileURLToPath(new URL("./apply-patch.md", import.meta.url));
|
|
25
|
+
const MAX_RENDER_CALL_LINES = 24;
|
|
26
|
+
const MAX_RENDER_CALL_LINE_CHARS = 180;
|
|
27
|
+
const applyPatchSchema = Type.Object({
|
|
28
|
+
patch: Type.String({
|
|
29
|
+
description: "Single-file patch document in ApplyPatch format. Must start with *** Begin Patch and end with *** End Patch.",
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
function normalizeLineEndings(text) {
|
|
33
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
34
|
+
}
|
|
35
|
+
function buildPatchPreview(patch) {
|
|
36
|
+
const normalized = normalizeLineEndings(patch).trimEnd();
|
|
37
|
+
if (!normalized)
|
|
38
|
+
return "(empty patch)";
|
|
39
|
+
const lines = normalized.split("\n");
|
|
40
|
+
const previewLines = lines.slice(0, MAX_RENDER_CALL_LINES).map((line) => {
|
|
41
|
+
if (line.length <= MAX_RENDER_CALL_LINE_CHARS)
|
|
42
|
+
return line;
|
|
43
|
+
return `${line.slice(0, MAX_RENDER_CALL_LINE_CHARS - 1)}…`;
|
|
44
|
+
});
|
|
45
|
+
if (lines.length > MAX_RENDER_CALL_LINES) {
|
|
46
|
+
previewLines.push(`... (${lines.length - MAX_RENDER_CALL_LINES} more line(s))`);
|
|
47
|
+
}
|
|
48
|
+
return previewLines.join("\n");
|
|
49
|
+
}
|
|
50
|
+
function normalizeAddFileLines(lines) {
|
|
51
|
+
const nonEmpty = lines.filter((line) => line.length > 0);
|
|
52
|
+
if (nonEmpty.length === 0)
|
|
53
|
+
return lines;
|
|
54
|
+
// Some models emit "+ " as a visual separator in Add File lines.
|
|
55
|
+
// If all non-empty lines share this one-space prefix, strip it once.
|
|
56
|
+
const allNonEmptyStartWithSpace = nonEmpty.every((line) => line.startsWith(" "));
|
|
57
|
+
if (!allNonEmptyStartWithSpace)
|
|
58
|
+
return lines;
|
|
59
|
+
return lines.map((line) => (line.startsWith(" ") ? line.slice(1) : line));
|
|
60
|
+
}
|
|
61
|
+
function normalizePatchPath(rawPath) {
|
|
62
|
+
const trimmed = rawPath.trim();
|
|
63
|
+
const withoutAt = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
64
|
+
if (!withoutAt) {
|
|
65
|
+
throw new Error("Patch file path cannot be empty");
|
|
66
|
+
}
|
|
67
|
+
if (!isAbsolute(withoutAt)) {
|
|
68
|
+
throw new Error(`Patch path must be absolute: ${rawPath}`);
|
|
69
|
+
}
|
|
70
|
+
return withoutAt;
|
|
71
|
+
}
|
|
72
|
+
function isFileHeader(line) {
|
|
73
|
+
return line.startsWith(ADD_FILE) || line.startsWith(UPDATE_FILE);
|
|
74
|
+
}
|
|
75
|
+
function parseAddFile(lines, startIndex) {
|
|
76
|
+
const header = lines[startIndex];
|
|
77
|
+
const rawPath = header.slice(ADD_FILE.length);
|
|
78
|
+
const path = normalizePatchPath(rawPath);
|
|
79
|
+
const addLines = [];
|
|
80
|
+
let i = startIndex + 1;
|
|
81
|
+
while (i < lines.length && lines[i] !== END_PATCH) {
|
|
82
|
+
const line = lines[i];
|
|
83
|
+
if (!line.startsWith("+")) {
|
|
84
|
+
throw new Error(`Add File only allows '+' lines, got: ${line}`);
|
|
85
|
+
}
|
|
86
|
+
addLines.push(line.slice(1));
|
|
87
|
+
i++;
|
|
88
|
+
}
|
|
89
|
+
if (addLines.length === 0) {
|
|
90
|
+
throw new Error("Add File operation requires at least one '+' content line");
|
|
91
|
+
}
|
|
92
|
+
const normalizedLines = normalizeAddFileLines(addLines);
|
|
93
|
+
return {
|
|
94
|
+
operation: { kind: "add", path, lines: normalizedLines },
|
|
95
|
+
nextIndex: i,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function parseUpdateFile(lines, startIndex) {
|
|
99
|
+
const header = lines[startIndex];
|
|
100
|
+
const rawPath = header.slice(UPDATE_FILE.length);
|
|
101
|
+
const path = normalizePatchPath(rawPath);
|
|
102
|
+
let moveTo;
|
|
103
|
+
let i = startIndex + 1;
|
|
104
|
+
if (i < lines.length && lines[i].startsWith(MOVE_TO)) {
|
|
105
|
+
moveTo = normalizePatchPath(lines[i].slice(MOVE_TO.length));
|
|
106
|
+
i++;
|
|
107
|
+
}
|
|
108
|
+
const hunks = [];
|
|
109
|
+
let currentHeaders;
|
|
110
|
+
let currentLines = [];
|
|
111
|
+
const flushCurrentHunk = () => {
|
|
112
|
+
if (!currentHeaders)
|
|
113
|
+
return;
|
|
114
|
+
hunks.push({
|
|
115
|
+
headers: currentHeaders,
|
|
116
|
+
lines: currentLines,
|
|
117
|
+
});
|
|
118
|
+
currentHeaders = undefined;
|
|
119
|
+
currentLines = [];
|
|
120
|
+
};
|
|
121
|
+
while (i < lines.length && lines[i] !== END_PATCH) {
|
|
122
|
+
const line = lines[i];
|
|
123
|
+
if (line === END_OF_FILE) {
|
|
124
|
+
i++;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
if (line.startsWith("@@")) {
|
|
128
|
+
if (!currentHeaders) {
|
|
129
|
+
currentHeaders = [line];
|
|
130
|
+
}
|
|
131
|
+
else if (currentLines.length === 0) {
|
|
132
|
+
// Support multiple consecutive @@ headers to narrow context.
|
|
133
|
+
currentHeaders.push(line);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
flushCurrentHunk();
|
|
137
|
+
currentHeaders = [line];
|
|
138
|
+
}
|
|
139
|
+
i++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const marker = line[0];
|
|
143
|
+
if (marker === " " || marker === "+" || marker === "-") {
|
|
144
|
+
if (!currentHeaders) {
|
|
145
|
+
throw new Error(`Update File change line encountered before hunk header @@: ${line}`);
|
|
146
|
+
}
|
|
147
|
+
currentLines.push(line);
|
|
148
|
+
i++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (isFileHeader(line)) {
|
|
152
|
+
throw new Error("Patch must contain exactly one file operation");
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`Invalid update patch line: ${line}`);
|
|
155
|
+
}
|
|
156
|
+
flushCurrentHunk();
|
|
157
|
+
return {
|
|
158
|
+
operation: { kind: "update", path, moveTo, hunks },
|
|
159
|
+
nextIndex: i,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function parsePatchDocument(patchText) {
|
|
163
|
+
const normalized = normalizeLineEndings(patchText);
|
|
164
|
+
const lines = normalized.split("\n");
|
|
165
|
+
if (lines.length === 0 || lines[0] !== BEGIN_PATCH) {
|
|
166
|
+
throw new Error(`Patch must start with "${BEGIN_PATCH}"`);
|
|
167
|
+
}
|
|
168
|
+
let index = 1;
|
|
169
|
+
if (index >= lines.length) {
|
|
170
|
+
throw new Error("Patch is incomplete");
|
|
171
|
+
}
|
|
172
|
+
const opHeader = lines[index] ?? "";
|
|
173
|
+
if (!isFileHeader(opHeader)) {
|
|
174
|
+
throw new Error(`Expected "${ADD_FILE}" or "${UPDATE_FILE}", got: ${opHeader}`);
|
|
175
|
+
}
|
|
176
|
+
const parsed = opHeader.startsWith(ADD_FILE) ? parseAddFile(lines, index) : parseUpdateFile(lines, index);
|
|
177
|
+
index = parsed.nextIndex;
|
|
178
|
+
if (index >= lines.length || lines[index] !== END_PATCH) {
|
|
179
|
+
throw new Error(`Patch must end with "${END_PATCH}"`);
|
|
180
|
+
}
|
|
181
|
+
for (let i = index + 1; i < lines.length; i++) {
|
|
182
|
+
if (lines[i] !== "") {
|
|
183
|
+
throw new Error(`Unexpected trailing content after "${END_PATCH}"`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { operation: parsed.operation };
|
|
187
|
+
}
|
|
188
|
+
function buildReplacementTexts(hunk) {
|
|
189
|
+
const oldLines = [];
|
|
190
|
+
const newLines = [];
|
|
191
|
+
for (const line of hunk.lines) {
|
|
192
|
+
const marker = line[0];
|
|
193
|
+
const text = line.slice(1);
|
|
194
|
+
if (marker === " " || marker === "-")
|
|
195
|
+
oldLines.push(text);
|
|
196
|
+
if (marker === " " || marker === "+")
|
|
197
|
+
newLines.push(text);
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
oldText: oldLines.join("\n"),
|
|
201
|
+
newText: newLines.join("\n"),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function hunkHasChanges(hunk) {
|
|
205
|
+
return hunk.lines.some((line) => {
|
|
206
|
+
const marker = line[0];
|
|
207
|
+
return marker === "+" || marker === "-";
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
function parseFirstChangedLine(details) {
|
|
211
|
+
if (typeof details !== "object" || details === null)
|
|
212
|
+
return undefined;
|
|
213
|
+
const record = details;
|
|
214
|
+
const value = record.firstChangedLine;
|
|
215
|
+
return typeof value === "number" && Number.isInteger(value) ? value : undefined;
|
|
216
|
+
}
|
|
217
|
+
export default function (pi) {
|
|
218
|
+
let applyPatchSpecCache;
|
|
219
|
+
const getApplyPatchSpec = async () => {
|
|
220
|
+
if (applyPatchSpecCache !== undefined) {
|
|
221
|
+
return applyPatchSpecCache ?? undefined;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const doc = await readFile(APPLY_PATCH_DOC_PATH, "utf-8");
|
|
225
|
+
applyPatchSpecCache = doc.trim();
|
|
226
|
+
return applyPatchSpecCache;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Best effort. The tool still works without prompt injection.
|
|
230
|
+
applyPatchSpecCache = null;
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
pi.on("before_agent_start", async (event) => {
|
|
235
|
+
if (event.systemPrompt.includes(APPLY_PATCH_PROMPT_MARKER)) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const spec = await getApplyPatchSpec();
|
|
239
|
+
if (!spec)
|
|
240
|
+
return;
|
|
241
|
+
return {
|
|
242
|
+
systemPrompt: `${event.systemPrompt}\n\n${APPLY_PATCH_PROMPT_MARKER}\n\n${spec}`,
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
pi.registerTool({
|
|
246
|
+
name: "apply_patch",
|
|
247
|
+
label: "Apply Patch",
|
|
248
|
+
description: "Apply a single-file patch in *** Begin Patch format. Supports *** Add File and *** Update File operations.",
|
|
249
|
+
parameters: applyPatchSchema,
|
|
250
|
+
renderCall(args, theme) {
|
|
251
|
+
const patch = typeof args.patch === "string" ? args.patch : "";
|
|
252
|
+
const preview = buildPatchPreview(patch);
|
|
253
|
+
let text = theme.fg("toolTitle", theme.bold("apply_patch"));
|
|
254
|
+
text += ` ${theme.fg("muted", "(patch input)")}`;
|
|
255
|
+
text += `\n${theme.fg("toolOutput", preview)}`;
|
|
256
|
+
return new Text(text, 0, 0);
|
|
257
|
+
},
|
|
258
|
+
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
259
|
+
const parsed = parsePatchDocument(params.patch);
|
|
260
|
+
const writeTool = createWriteTool(ctx.cwd);
|
|
261
|
+
const editTool = createEditTool(ctx.cwd);
|
|
262
|
+
if (parsed.operation.kind === "add") {
|
|
263
|
+
const content = parsed.operation.lines.join("\n");
|
|
264
|
+
await writeTool.execute(`${toolCallId}:add`, {
|
|
265
|
+
path: parsed.operation.path,
|
|
266
|
+
content,
|
|
267
|
+
}, signal);
|
|
268
|
+
const details = {
|
|
269
|
+
operation: "add",
|
|
270
|
+
path: parsed.operation.path,
|
|
271
|
+
bytesWritten: Buffer.byteLength(content, "utf-8"),
|
|
272
|
+
};
|
|
273
|
+
return {
|
|
274
|
+
content: [{ type: "text", text: `Applied patch: added ${parsed.operation.path}` }],
|
|
275
|
+
details,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const firstChangedLines = [];
|
|
279
|
+
let appliedHunks = 0;
|
|
280
|
+
let noopHunks = 0;
|
|
281
|
+
for (let i = 0; i < parsed.operation.hunks.length; i++) {
|
|
282
|
+
const hunk = parsed.operation.hunks[i];
|
|
283
|
+
if (!hunkHasChanges(hunk)) {
|
|
284
|
+
noopHunks++;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const replacement = buildReplacementTexts(hunk);
|
|
288
|
+
if (replacement.oldText === replacement.newText) {
|
|
289
|
+
noopHunks++;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const result = await editTool.execute(`${toolCallId}:hunk:${i + 1}`, {
|
|
293
|
+
path: parsed.operation.path,
|
|
294
|
+
oldText: replacement.oldText,
|
|
295
|
+
newText: replacement.newText,
|
|
296
|
+
}, signal);
|
|
297
|
+
appliedHunks++;
|
|
298
|
+
const firstChangedLine = parseFirstChangedLine(result.details);
|
|
299
|
+
if (firstChangedLine !== undefined) {
|
|
300
|
+
firstChangedLines.push(firstChangedLine);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
let movedTo;
|
|
304
|
+
if (parsed.operation.moveTo && parsed.operation.moveTo !== parsed.operation.path) {
|
|
305
|
+
await mkdir(dirname(parsed.operation.moveTo), { recursive: true });
|
|
306
|
+
await rename(parsed.operation.path, parsed.operation.moveTo);
|
|
307
|
+
movedTo = parsed.operation.moveTo;
|
|
308
|
+
}
|
|
309
|
+
const details = {
|
|
310
|
+
operation: "update",
|
|
311
|
+
path: parsed.operation.path,
|
|
312
|
+
moveTo: movedTo,
|
|
313
|
+
hunkCount: parsed.operation.hunks.length,
|
|
314
|
+
appliedHunks,
|
|
315
|
+
noopHunks,
|
|
316
|
+
firstChangedLines,
|
|
317
|
+
};
|
|
318
|
+
const suffix = [];
|
|
319
|
+
if (noopHunks > 0) {
|
|
320
|
+
suffix.push(`skipped ${noopHunks} no-op hunk(s)`);
|
|
321
|
+
}
|
|
322
|
+
if (movedTo) {
|
|
323
|
+
suffix.push(`moved to ${movedTo}`);
|
|
324
|
+
}
|
|
325
|
+
const suffixText = suffix.length > 0 ? ` (${suffix.join(", ")})` : "";
|
|
326
|
+
return {
|
|
327
|
+
content: [
|
|
328
|
+
{
|
|
329
|
+
type: "text",
|
|
330
|
+
text: `Applied patch: updated ${parsed.operation.path} with ${appliedHunks} hunk(s)${suffixText}.`,
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
details,
|
|
334
|
+
};
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Use this tool to edit files.
|
|
2
|
+
// Your patch language is a stripped-down, file-oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high-level envelope:
|
|
3
|
+
//
|
|
4
|
+
// *** Begin Patch
|
|
5
|
+
// [ one file section ]
|
|
6
|
+
// *** End Patch
|
|
7
|
+
//
|
|
8
|
+
// Within that envelope, you get one file operation.
|
|
9
|
+
// You MUST include a header to specify the action you are taking.
|
|
10
|
+
// Each operation starts with one of two headers:
|
|
11
|
+
//
|
|
12
|
+
// *** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
|
|
13
|
+
// *** Update File: <path> - patch an existing file in place (optionally with a rename).
|
|
14
|
+
//
|
|
15
|
+
// Then one or more "hunks", each introduced by @@ (optionally followed by a hunk header).
|
|
16
|
+
// Within a hunk each line starts with:
|
|
17
|
+
//
|
|
18
|
+
// For instructions on [context_before] and [context_after]:
|
|
19
|
+
// - By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change's [context_after] lines in the second change's [context_before] lines.
|
|
20
|
+
// - If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
|
|
21
|
+
// @@ class BaseClass
|
|
22
|
+
// [3 lines of pre-context]
|
|
23
|
+
// - [old_code]
|
|
24
|
+
// + [new_code]
|
|
25
|
+
// [3 lines of post-context]
|
|
26
|
+
//
|
|
27
|
+
// - If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
|
|
28
|
+
//
|
|
29
|
+
// @@ class BaseClass
|
|
30
|
+
// @@ def method():
|
|
31
|
+
// [3 lines of pre-context]
|
|
32
|
+
// - [old_code]
|
|
33
|
+
// + [new_code]
|
|
34
|
+
// [3 lines of post-context]
|
|
35
|
+
//
|
|
36
|
+
// The full grammar definition is below:
|
|
37
|
+
// Patch := Begin { FileOp } End
|
|
38
|
+
// Begin := "*** Begin Patch" NEWLINE
|
|
39
|
+
// End := "*** End Patch" NEWLINE
|
|
40
|
+
// FileOp := AddFile | UpdateFile
|
|
41
|
+
// AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
|
|
42
|
+
// UpdateFile := "*** Update File: " path NEWLINE { Hunk }
|
|
43
|
+
// Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
|
|
44
|
+
// HunkLine := (" " | "-" | "+") text NEWLINE
|
|
45
|
+
//
|
|
46
|
+
// Example for Update File:
|
|
47
|
+
// *** Begin Patch
|
|
48
|
+
// *** Update File: pygorithm/searching/binary_search.py
|
|
49
|
+
// @@ class BaseClass
|
|
50
|
+
// @@ def search():
|
|
51
|
+
// - pass
|
|
52
|
+
// + raise NotImplementedError()
|
|
53
|
+
//
|
|
54
|
+
// @@ class Subclass
|
|
55
|
+
// @@ def search():
|
|
56
|
+
// - pass
|
|
57
|
+
// + raise NotImplementedError()
|
|
58
|
+
// *** End Patch
|
|
59
|
+
//
|
|
60
|
+
// Example for Add File:
|
|
61
|
+
// *** Begin Patch
|
|
62
|
+
// *** Add File: [path/to/file]
|
|
63
|
+
// + [new_code]
|
|
64
|
+
// *** End Patch
|
|
65
|
+
//
|
|
66
|
+
// It is important to remember:
|
|
67
|
+
// - You must only include one file per call
|
|
68
|
+
// - You must include a header with your intended action (Add/Update)
|
|
69
|
+
// - You must prefix new lines with ` +` even when creating a new file
|
|
70
|
+
//
|
|
71
|
+
// All file paths must be absolute paths. Make sure to read the file before applying a patch to get the latest file content, unless you are creating a new file.
|
|
72
|
+
//
|
|
73
|
+
// IMPORTANT: This tool only accepts string inputs that obey the lark grammar start: begin_patch hunk end_patch
|
|
74
|
+
// begin_patch: "*** Begin Patch" LF
|
|
75
|
+
// end_patch: "*** End Patch" LF?
|
|
76
|
+
//
|
|
77
|
+
// hunk: add_hunk | update_hunk
|
|
78
|
+
// add_hunk: "*** Add File: " filename LF add_line+
|
|
79
|
+
// update_hunk: "*** Update File: " filename LF change?
|
|
80
|
+
//
|
|
81
|
+
// filename: /(.+)/
|
|
82
|
+
// add_line: "+" /(.*)/ LF -> line
|
|
83
|
+
//
|
|
84
|
+
// change: (change_context | change_line)+ eof_line?
|
|
85
|
+
//
|
|
86
|
+
// change_context: ("@@" | "@@ " /(.+)/) LF
|
|
87
|
+
// change_line: ("+" | "-" | " ") /(.*)/ LF
|
|
88
|
+
// eof_line: "*** End of File" LF
|
|
89
|
+
//
|
|
90
|
+
// %import common.LF
|
|
91
|
+
// . You must reason carefully about the input and make sure it obeys the grammar.
|
|
92
|
+
// IMPORTANT: Do NOT call this tool in parallel with other tools.
|
|
93
|
+
type ApplyPatch = (FREEFORM) => any;
|