pi-shorthand 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 +60 -0
- package/display.ts +205 -0
- package/index.ts +261 -0
- package/overlay-linux.ts +80 -0
- package/overlay-macos.ts +277 -0
- package/package.json +82 -0
- package/prelude.ts +243 -0
- package/runner.ts +451 -0
- package/skills/shorthand/SKILL.md +20 -0
- package/skills/shorthand/ast-grep.md +75 -0
- package/skills/shorthand/gritql.md +20 -0
- package/skills/shorthand/writing.md +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Seb Insua
|
|
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,60 @@
|
|
|
1
|
+
# pi-shorthand
|
|
2
|
+
|
|
3
|
+
A [Pi](https://github.com/earendil-works/pi) tool for token-efficient writes. The model writes a whole
|
|
4
|
+
change as one small Bun program, in shorthand, instead of calling `read`, `edit` and `bash` over
|
|
5
|
+
and over: fewer tokens, and fewer round trips.
|
|
6
|
+
|
|
7
|
+
The program sees your repo as normal, but its writes are held back. If it succeeds, they're applied
|
|
8
|
+
and the model gets the diff. If it fails, nothing changes.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pi install npm:pi-shorthand
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or from GitHub (`pi install git:github.com/sebinsua/pi-shorthand`), or a local clone: `npm install`, then `pi install /path/to/pi-shorthand`.
|
|
17
|
+
|
|
18
|
+
You also need Bun, git, and either [bubblewrap](https://github.com/containers/bubblewrap) 0.9+
|
|
19
|
+
(Linux) or [AgentFS](https://github.com/tursodatabase/agentfs) (macOS:
|
|
20
|
+
`curl -fsSL https://agentfs.ai/install | bash`).
|
|
21
|
+
|
|
22
|
+
## What a program can use
|
|
23
|
+
|
|
24
|
+
Anything in Bun or Node, plus these globals (no imports):
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
await $`bun test src/api.test.ts`; // Bun's shell (the only async one)
|
|
28
|
+
glob("src/**/*.ts"); // → ["src/a.ts", …]
|
|
29
|
+
grep("oldApi(", "src"); // → [{ file, line, text }, …]
|
|
30
|
+
sg.find("oldApi($$$ARGS)", "src"); // ast-grep search
|
|
31
|
+
sg.rewrite("oldApi($$$ARGS)", "newApi($$$ARGS)", "src");
|
|
32
|
+
sg.parse(sg.Lang.TypeScript, source); // ast-grep's own API (or import from "@ast-grep/napi")
|
|
33
|
+
grit("`console.log($x)` => `logger.info($x)`", "src");
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Options
|
|
37
|
+
|
|
38
|
+
- `rollback`: `"all"` (default) applies nothing if the program fails. `"file"` keeps the files it
|
|
39
|
+
finished writing.
|
|
40
|
+
- `timeout`: seconds before the program is killed. Default 2.
|
|
41
|
+
|
|
42
|
+
## Good to know
|
|
43
|
+
|
|
44
|
+
- Only files git tracks, or would track, are applied.
|
|
45
|
+
- On macOS your repo is briefly swapped for the overlay while a program runs (about 150 ms), so your
|
|
46
|
+
editor may notice. On Linux nothing outside the program sees it (about 15 ms).
|
|
47
|
+
- To try it with only `read` and `code`: `pi --tools read,code`.
|
|
48
|
+
- To watch runs as they happen, including each command a program starts: `tail -f ~/.cache/pi-shorthand/runs.jsonl`.
|
|
49
|
+
|
|
50
|
+
## Developing
|
|
51
|
+
|
|
52
|
+
`npm run check` type-checks (TypeScript 7), lints (oxlint) and checks formatting (oxfmt). A pre-commit
|
|
53
|
+
hook runs it; `npm run format` fixes formatting.
|
|
54
|
+
|
|
55
|
+
`npm test` runs the tests against real overlays (it needs AgentFS on macOS, bubblewrap on Linux).
|
|
56
|
+
`test/linux.sh` runs them on Linux in Docker.
|
|
57
|
+
|
|
58
|
+
`bun e2e/run.ts --repo <path or git URL> --task "…" --setup baseline|code|read-code --check "…"` runs Pi
|
|
59
|
+
with a real model on a fresh copy of a repo and summarises what it did (time, turns, tool calls,
|
|
60
|
+
tokens, whether the check passed).
|
package/display.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a code call looks in Pi. (The model gets a separate plain-text version: see textForModel.)
|
|
3
|
+
*
|
|
4
|
+
* A verdict line first, whose colour carries the outcome. Lines indented under it belong to it: what
|
|
5
|
+
* went wrong, or what to watch out for. A blank line starts a new section. The sections come in the
|
|
6
|
+
* order that matters for the outcome: on success the diff (the point of the tool), then the output;
|
|
7
|
+
* on failure the output, then what would have changed; for an exploration, just the output.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { keyHint, renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { FileChange, RunResult } from "./runner.ts";
|
|
12
|
+
|
|
13
|
+
const OUTPUT_PREVIEW_LINES = 5; // like Pi's bash tool
|
|
14
|
+
const INLINE_DIFF_LINES = 40; // a longer diff collapses to a list of its files…
|
|
15
|
+
const LISTED_FILES = 8; // …showing this many, then "and N more files"
|
|
16
|
+
const EXPANDED_DIFF_LINES = 2000; // even expanded, a diff of hundreds of files stops here
|
|
17
|
+
|
|
18
|
+
export function callLine(args: { title?: string; timeout?: number; rollback?: string }, theme: Theme): string {
|
|
19
|
+
const settings = [args.rollback === "file" && "rollback per file", args.timeout && `timeout ${args.timeout}s`];
|
|
20
|
+
const suffix = settings.filter(Boolean).join(", ");
|
|
21
|
+
return `${theme.fg("toolTitle", theme.bold("code"))} ${args.title ?? ""}${suffix ? theme.fg("muted", ` (${suffix})`) : ""}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Lines that belong to the verdict line above them. */
|
|
25
|
+
function indent(line: string): string {
|
|
26
|
+
return ` ${line}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resultLines(run: RunResult, expanded: boolean, theme: Theme): string[] {
|
|
30
|
+
const applied = run.changes.filter((change) => run.applied.includes(change.path));
|
|
31
|
+
const notApplied = run.changes.filter(
|
|
32
|
+
(change) => !run.applied.includes(change.path) && !run.rolledBack.includes(change.path),
|
|
33
|
+
);
|
|
34
|
+
const outputLinesAll = run.output.split("\n");
|
|
35
|
+
const printedWarnings = outputLinesAll.filter((line) => line.startsWith("warning: ")).map((line) => line.slice(9));
|
|
36
|
+
const output = outputLinesAll
|
|
37
|
+
.filter((line) => !line.startsWith("warning: "))
|
|
38
|
+
.join("\n")
|
|
39
|
+
.trim();
|
|
40
|
+
|
|
41
|
+
// The verdict, and what belongs to it.
|
|
42
|
+
const lines = [verdict(run, applied, theme)];
|
|
43
|
+
const error = run.exitCode !== 0 && !run.timedOut ? errorMessage(run.output) : undefined;
|
|
44
|
+
if (error) lines.push(indent(theme.fg("error", error)));
|
|
45
|
+
if (error && run.errorLine) lines.push(indent(theme.fg("muted", shorten(run.errorLine, 88))));
|
|
46
|
+
// A command still running is what it was stuck on; otherwise the last step it logged is a clue.
|
|
47
|
+
for (const command of run.stillRunning) lines.push(indent(theme.fg("warning", `stuck on $ ${command}`)));
|
|
48
|
+
if (run.timedOut && run.stillRunning.length === 0 && run.lastStep) {
|
|
49
|
+
lines.push(indent(theme.fg("warning", `last step: ${run.lastStep}`)));
|
|
50
|
+
}
|
|
51
|
+
for (const file of run.rolledBack) {
|
|
52
|
+
lines.push(indent(theme.fg("warning", `rolled back ${file}: half-written when the program was killed`)));
|
|
53
|
+
}
|
|
54
|
+
for (const warning of [...run.warnings, ...printedWarnings]) lines.push(indent(theme.fg("warning", `⚠ ${warning}`)));
|
|
55
|
+
|
|
56
|
+
// The sections, each after a blank line.
|
|
57
|
+
const sections: string[][] = [];
|
|
58
|
+
if (applied.length > 0) sections.push(diffLines(applied, expanded, theme));
|
|
59
|
+
if (output && (expanded || !error)) sections.push(outputLines(output, expanded, run.changes.length > 0, theme));
|
|
60
|
+
if (notApplied.length > 0) sections.push(notAppliedLines(notApplied, expanded, theme));
|
|
61
|
+
for (const section of sections) lines.push("", ...section);
|
|
62
|
+
return lines;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** e.g. "✓ Applied 3 files · +6 −6 · 0.6s" or "✕ Failed · rolled back all changes · exit 1 · 0.2s" */
|
|
66
|
+
function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
|
|
67
|
+
const muted = (text: string) => theme.fg("muted", text);
|
|
68
|
+
const took = muted(` · ${(run.durationMs / 1000).toFixed(1)}s`);
|
|
69
|
+
const failure = run.timedOut ? `Timed out after ${run.timeoutMs / 1000}s` : "Failed";
|
|
70
|
+
const exit = run.timedOut ? "" : muted(` · exit ${run.exitCode}`);
|
|
71
|
+
|
|
72
|
+
if (run.exitCode === 0 && run.changes.length === 0) return theme.fg("success", "✓ No changes") + took;
|
|
73
|
+
if (run.exitCode === 0) {
|
|
74
|
+
return theme.fg("success", `✓ Applied ${fileCount(applied)}`) + ` · ${stats(applied, theme)}` + took;
|
|
75
|
+
}
|
|
76
|
+
if (applied.length > 0) {
|
|
77
|
+
const rolledBack = run.rolledBack.length > 0 ? `, rolled back ${run.rolledBack.length}` : "";
|
|
78
|
+
const kept = theme.fg("warning", `⚠ ${failure} · kept ${fileCount(applied)}${rolledBack}`);
|
|
79
|
+
return kept + ` · ${stats(applied, theme)}` + exit + took;
|
|
80
|
+
}
|
|
81
|
+
const undone =
|
|
82
|
+
run.changes.length === 0 ? "no changes" : run.rollback === "all" ? "rolled back all changes" : "nothing to keep";
|
|
83
|
+
return theme.fg("error", `✕ ${failure} · ${undone}`) + exit + took;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The error Bun printed, e.g. "expected 1 match, found 3" (or "TypeError: …"). */
|
|
87
|
+
function errorMessage(output: string): string | undefined {
|
|
88
|
+
const line = output.split("\n").findLast((candidate) => /^(error|\w*Error): /.test(candidate));
|
|
89
|
+
return line?.replace(/^error: /, "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The last few lines, like Pi's bash tool; all of it when expanded. Labelled when not alone. */
|
|
93
|
+
function outputLines(output: string, expanded: boolean, labelled: boolean, theme: Theme): string[] {
|
|
94
|
+
const lines = output.split("\n").map((line) => theme.fg("toolOutput", line));
|
|
95
|
+
const label = labelled ? [theme.fg("muted", "Program output")] : [];
|
|
96
|
+
if (expanded || lines.length <= OUTPUT_PREVIEW_LINES) return [...label, ...lines];
|
|
97
|
+
|
|
98
|
+
const earlier = lines.length - OUTPUT_PREVIEW_LINES;
|
|
99
|
+
const hint =
|
|
100
|
+
theme.fg("muted", `… ${earlier} earlier lines (`) +
|
|
101
|
+
keyHint("app.tools.expand", "to expand") +
|
|
102
|
+
theme.fg("muted", ")");
|
|
103
|
+
return [...label, hint, ...lines.slice(-OUTPUT_PREVIEW_LINES)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Each file's diff under its name, in Pi's own diff style. A long diff collapses to a list of files. */
|
|
107
|
+
function diffLines(changes: FileChange[], expanded: boolean, theme: Theme): string[] {
|
|
108
|
+
const files = changes.map((change) => [fileLine(change, theme), ...renderDiff(toPiDiff(change.patch)).split("\n")]);
|
|
109
|
+
const total = files.reduce((sum, file) => sum + file.length, 0);
|
|
110
|
+
if (!expanded && total > INLINE_DIFF_LINES) {
|
|
111
|
+
return [...fileList(changes, theme), theme.fg("muted", `(${keyHint("app.tools.expand", "to see the diff")})`)];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const lines = files.flatMap((file, index) => (index === 0 ? file : ["", ...file]));
|
|
115
|
+
if (lines.length <= EXPANDED_DIFF_LINES) return lines;
|
|
116
|
+
const more = lines.length - EXPANDED_DIFF_LINES;
|
|
117
|
+
return [...lines.slice(0, EXPANDED_DIFF_LINES), theme.fg("muted", `… ${more} more lines of diff`)];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function notAppliedLines(changes: FileChange[], expanded: boolean, theme: Theme): string[] {
|
|
121
|
+
const heading = theme.fg("muted", `Would have changed ${fileCount(changes)} · `) + stats(changes, theme);
|
|
122
|
+
if (expanded) return [heading, "", ...diffLines(changes, true, theme)];
|
|
123
|
+
return [heading + theme.fg("muted", ` (${keyHint("app.tools.expand", "to see the diff")})`)];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The first few files, then how many more. */
|
|
127
|
+
function fileList(changes: FileChange[], theme: Theme): string[] {
|
|
128
|
+
const listed = changes.slice(0, LISTED_FILES).map((change) => fileLine(change, theme));
|
|
129
|
+
const more = changes.length - LISTED_FILES;
|
|
130
|
+
return more > 0 ? [...listed, theme.fg("muted", `… and ${more} more files`)] : listed;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** e.g. "src/a.ts +3 −1", or "src/new.ts (new) +12 −0" */
|
|
134
|
+
function fileLine(change: FileChange, theme: Theme): string {
|
|
135
|
+
const kind = change.kind === "modified" ? "" : theme.fg("muted", change.kind === "added" ? " (new)" : " (deleted)");
|
|
136
|
+
return `${theme.fg("accent", change.path)}${kind} ${stats([change], theme)}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** "+6 −2", in the diff colours. */
|
|
140
|
+
function stats(changes: FileChange[], theme: Theme): string {
|
|
141
|
+
const counts = changes.map((change) => countLines(change.patch));
|
|
142
|
+
const additions = counts.reduce((sum, count) => sum + count.additions, 0);
|
|
143
|
+
const deletions = counts.reduce((sum, count) => sum + count.deletions, 0);
|
|
144
|
+
return `${theme.fg("toolDiffAdded", `+${additions}`)} ${theme.fg("toolDiffRemoved", `−${deletions}`)}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Cut to fit on one line, since a wrapped line loses its indentation. The full text is in the output. */
|
|
148
|
+
function shorten(text: string, length: number): string {
|
|
149
|
+
return text.length <= length ? text : `${text.slice(0, length - 1)}…`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fileCount(changes: FileChange[]): string {
|
|
153
|
+
return changes.length === 1 ? "1 file" : `${changes.length} files`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Converts a git-style patch to the format Pi's renderDiff reads: "+12 added", "-12 removed",
|
|
158
|
+
* " 12 context", and " ..." between hunks. Removed and context lines use old line numbers.
|
|
159
|
+
*/
|
|
160
|
+
function toPiDiff(patch: string): string {
|
|
161
|
+
if (patch.includes("\nBinary file changed")) return " binary file changed";
|
|
162
|
+
const lastLines = [...patch.matchAll(/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@/gm)].flatMap((hunk) => [
|
|
163
|
+
Number(hunk[1]) + Number(hunk[2]),
|
|
164
|
+
Number(hunk[3]) + Number(hunk[4]),
|
|
165
|
+
]);
|
|
166
|
+
const width = String(Math.max(1, ...lastLines)).length;
|
|
167
|
+
const number = (n: number | string) => String(n).padStart(width, " ");
|
|
168
|
+
|
|
169
|
+
const out: string[] = [];
|
|
170
|
+
let inHunks = false; // skips the headers before the first hunk
|
|
171
|
+
let oldLine = 0;
|
|
172
|
+
let newLine = 0;
|
|
173
|
+
for (const line of patch.split("\n")) {
|
|
174
|
+
const hunk = line.match(/^@@ -(\d+),\d+ \+(\d+),\d+ @@/);
|
|
175
|
+
if (hunk) {
|
|
176
|
+
if (inHunks) out.push(` ${number("")} ...`);
|
|
177
|
+
inHunks = true;
|
|
178
|
+
oldLine = Number(hunk[1]);
|
|
179
|
+
newLine = Number(hunk[2]);
|
|
180
|
+
} else if (!inHunks) {
|
|
181
|
+
continue;
|
|
182
|
+
} else if (line.startsWith("+")) {
|
|
183
|
+
out.push(`+${number(newLine++)} ${line.slice(1)}`);
|
|
184
|
+
} else if (line.startsWith("-")) {
|
|
185
|
+
out.push(`-${number(oldLine++)} ${line.slice(1)}`);
|
|
186
|
+
} else if (line.startsWith(" ")) {
|
|
187
|
+
out.push(` ${number(oldLine++)} ${line.slice(1)}`);
|
|
188
|
+
newLine++;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Added and removed lines in a patch. Only lines after the first "@@" count, not the ---/+++ headers. */
|
|
195
|
+
export function countLines(patch: string) {
|
|
196
|
+
let additions = 0;
|
|
197
|
+
let deletions = 0;
|
|
198
|
+
let inHunks = false;
|
|
199
|
+
for (const line of patch.split("\n")) {
|
|
200
|
+
if (line.startsWith("@@")) inHunks = true;
|
|
201
|
+
else if (inHunks && line.startsWith("+")) additions++;
|
|
202
|
+
else if (inHunks && line.startsWith("-")) deletions++;
|
|
203
|
+
}
|
|
204
|
+
return { additions, deletions };
|
|
205
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `code` tool: the model writes one Bun program that makes a multi-step change to the repository.
|
|
3
|
+
* Its writes go to a copy-on-write overlay; if it succeeds, they're applied and the diff is returned.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { closeSync, openSync, readSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
11
|
+
import { type ExtensionAPI, truncateHead, truncateTail } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
import { callLine, countLines, resultLines } from "./display.ts";
|
|
15
|
+
import type { FileChange, RunOptions, RunResult } from "./runner.ts";
|
|
16
|
+
|
|
17
|
+
// Where runner.ts logs each step. (Not imported from runner.ts, which only runs under Bun.)
|
|
18
|
+
const LOG_FILE = path.join(homedir(), ".cache", "pi-shorthand", "runs.jsonl");
|
|
19
|
+
|
|
20
|
+
// Runs typically take well under a second. Programs that run tests or builds pass a longer timeout.
|
|
21
|
+
const DEFAULT_TIMEOUT_SECONDS = 2;
|
|
22
|
+
|
|
23
|
+
const DESCRIPTION = `Make a repository change with one TypeScript program, run by Bun as a transaction: its writes are applied only if it exits successfully, and you get the diff. Put the checks that prove the change worked (type-check, targeted tests, no leftover matches) in the same program, and throw if they fail. Work out what to change inside the program (e.g. with grep) rather than copying lists from earlier output.
|
|
24
|
+
|
|
25
|
+
Use it when a change takes several deterministic steps (reads, searches, multi-file edits, structural rewrites, checks) and you already know what to do with each intermediate result. If seeing an intermediate result could change your plan, look first with a normal tool call.
|
|
26
|
+
|
|
27
|
+
The program runs in the working directory, and sees the repository at its usual path. Top-level await works, and so do ordinary Bun and Node APIs. These globals are synchronous, and see the files git sees (not node_modules or ignored files):
|
|
28
|
+
- glob(pattern, dir?) → string[]
|
|
29
|
+
- grep(stringOrRegExp, paths?) → {file, line, text}[]. A string matches literally.
|
|
30
|
+
- sg.find(pattern, files?) → {file, line, text, vars}[]. ast-grep pattern: $X is one node, $$$X is zero or more. files is a file, directory, glob or a list of them (JS/TS).
|
|
31
|
+
- sg.rewrite(pattern, templateOrFunction, files?) → number rewritten. A template can use $X and $$$X; a function gets the match (its captures are on it: m.X) and returns the new text, or null to leave it.
|
|
32
|
+
- sg also has ast-grep's own API (sg.parse, sg.Lang, sg.findInFiles, …), and import "@ast-grep/napi" works too.
|
|
33
|
+
- grit(gritqlPattern, paths?, {lang?, dryRun?}) → {file, matches}[], e.g. grit("\`a($x)\` => \`b($x)\`", "src")
|
|
34
|
+
Bun's shell $ needs await: await $\`bun test src/foo.test.ts\`. You can also run the ast-grep, grit and git CLIs with it. For how to write these programs, see the shorthand skill.
|
|
35
|
+
|
|
36
|
+
Throw or exit non-zero to fail. rollback decides what a failure undoes:
|
|
37
|
+
- "all" (default): nothing is applied; you get the error and the candidate diff.
|
|
38
|
+
- "file": every file the program finished writing is applied; files left half-written (e.g. by a timeout) are rolled back and listed.
|
|
39
|
+
The default timeout is 2 seconds; pass a longer timeout when the program runs tests or builds. Only files git sees (tracked, or untracked and not ignored) are diffed and applied; writes to .git are blocked. Print what you need to know (counts, assertions), not whole files.`;
|
|
40
|
+
|
|
41
|
+
export default function (pi: ExtensionAPI) {
|
|
42
|
+
// A failed run is an error, both for the model and for how Pi shows it. (execute() returns its details
|
|
43
|
+
// rather than throwing, since a thrown error loses them.)
|
|
44
|
+
pi.on("tool_result", async (event) => {
|
|
45
|
+
const run = event.details as RunResult | undefined;
|
|
46
|
+
if (event.toolName === "code" && run && run.exitCode !== 0) return { isError: true };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
pi.registerTool({
|
|
50
|
+
name: "code",
|
|
51
|
+
label: "Code",
|
|
52
|
+
description: DESCRIPTION,
|
|
53
|
+
promptSnippet:
|
|
54
|
+
"Make a change with one Bun program, run as a transaction: its edits are kept only if it exits 0, so put your checks inside it",
|
|
55
|
+
promptGuidelines: [
|
|
56
|
+
"Use code when several related reads, searches, edits or checks can be done without looking at intermediate results: put that logic in one program rather than many read/edit/bash calls.",
|
|
57
|
+
"Don't use code to explore when you need to see results before deciding what to do.",
|
|
58
|
+
],
|
|
59
|
+
parameters: Type.Object({
|
|
60
|
+
title: Type.String({ description: "A few words describing the change, shown to the user" }),
|
|
61
|
+
program: Type.String({ description: "TypeScript program run with Bun (top-level await allowed)" }),
|
|
62
|
+
rollback: Type.Optional(
|
|
63
|
+
StringEnum(["all", "file"] as const, {
|
|
64
|
+
description:
|
|
65
|
+
'On failure: "all" (default) applies nothing; "file" applies finished files and rolls back half-written ones',
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
timeout: Type.Optional(Type.Number({ description: "Seconds before the program is killed (default 2)" })),
|
|
69
|
+
}),
|
|
70
|
+
|
|
71
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
72
|
+
// While it runs, show how long it's been going and the latest step from the log.
|
|
73
|
+
const runId = Math.random().toString(36).slice(2, 10);
|
|
74
|
+
const startedAt = Date.now();
|
|
75
|
+
const progress = setInterval(() => {
|
|
76
|
+
const elapsed = `${((Date.now() - startedAt) / 1000).toFixed(1)} s`;
|
|
77
|
+
const latest = latestStep(runId);
|
|
78
|
+
onUpdate?.({
|
|
79
|
+
content: [{ type: "text", text: "running" }],
|
|
80
|
+
details: { progress: latest ? `${elapsed} · ${latest}` : elapsed },
|
|
81
|
+
});
|
|
82
|
+
}, 500);
|
|
83
|
+
|
|
84
|
+
const result = await runWithBun(
|
|
85
|
+
{
|
|
86
|
+
runId,
|
|
87
|
+
cwd: ctx.cwd,
|
|
88
|
+
program: params.program,
|
|
89
|
+
timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
|
90
|
+
rollback: params.rollback ?? "all",
|
|
91
|
+
},
|
|
92
|
+
signal,
|
|
93
|
+
).finally(() => clearInterval(progress));
|
|
94
|
+
return {
|
|
95
|
+
content: [{ type: "text", text: textForModel(result, toolCallId) }],
|
|
96
|
+
details: result,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
renderCall(args, theme) {
|
|
101
|
+
return new Text(callLine(args, theme), 0, 0);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
105
|
+
if (isPartial) {
|
|
106
|
+
const progress = (result.details as { progress?: string } | undefined)?.progress;
|
|
107
|
+
return new Text(theme.fg("muted", progress ? `running… ${progress}` : "running…"), 0, 0);
|
|
108
|
+
}
|
|
109
|
+
const run = result.details as RunResult | undefined;
|
|
110
|
+
if (!run) return new Text(theme.fg("muted", "running…"), 0, 0);
|
|
111
|
+
return new Text(resultLines(run, expanded, theme).join("\n"), 0, 0);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Pi runs extensions in Node, so the work happens in a Bun process (runner.ts). On abort it's asked
|
|
118
|
+
* to stop (it then puts the repository back and applies nothing). It has its own process group,
|
|
119
|
+
* which is killed afterwards so no subprocess the program started is left behind.
|
|
120
|
+
*/
|
|
121
|
+
function runWithBun(options: RunOptions, signal?: AbortSignal): Promise<RunResult> {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
if (signal?.aborted) return reject(new Error("Aborted"));
|
|
124
|
+
const runner = spawn("bun", [path.join(import.meta.dirname, "runner.ts")], { detached: true });
|
|
125
|
+
const stop = () => runner.kill("SIGTERM");
|
|
126
|
+
signal?.addEventListener("abort", stop);
|
|
127
|
+
|
|
128
|
+
let stdout = "";
|
|
129
|
+
let stderr = "";
|
|
130
|
+
runner.stdout.setEncoding("utf8"); // so a multi-byte character split across chunks decodes intact
|
|
131
|
+
runner.stderr.setEncoding("utf8");
|
|
132
|
+
runner.stdout.on("data", (chunk) => (stdout += chunk));
|
|
133
|
+
runner.stderr.on("data", (chunk) => (stderr += chunk));
|
|
134
|
+
runner.on("error", reject);
|
|
135
|
+
runner.on("close", (code) => {
|
|
136
|
+
signal?.removeEventListener("abort", stop);
|
|
137
|
+
try {
|
|
138
|
+
process.kill(-runner.pid!, "SIGKILL"); // anything the program left running
|
|
139
|
+
} catch {
|
|
140
|
+
// nothing left
|
|
141
|
+
}
|
|
142
|
+
if (signal?.aborted) reject(new Error("Aborted"));
|
|
143
|
+
else if (code === 0) resolve(JSON.parse(stdout));
|
|
144
|
+
else reject(new Error(stderr.trim() || `runner exited with ${code}`));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
runner.stdin.end(JSON.stringify(options));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The latest step logged for a run, e.g. "$ find / -name x" or "grep (18 ms)". Reads only the end of
|
|
153
|
+
* the log, which is shared by every run.
|
|
154
|
+
*/
|
|
155
|
+
function latestStep(runId: string): string | undefined {
|
|
156
|
+
let tail: string;
|
|
157
|
+
try {
|
|
158
|
+
const size = statSync(LOG_FILE).size;
|
|
159
|
+
const length = Math.min(size, 64 * 1024);
|
|
160
|
+
const buffer = Buffer.alloc(length);
|
|
161
|
+
const file = openSync(LOG_FILE, "r");
|
|
162
|
+
readSync(file, buffer, 0, length, size - length);
|
|
163
|
+
closeSync(file);
|
|
164
|
+
tail = buffer.toString("utf8");
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined; // no log yet
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const lines = tail.split("\n").filter((line) => line.includes(`"run":"${runId}"`));
|
|
170
|
+
const last = lines.at(-1);
|
|
171
|
+
if (!last) return undefined;
|
|
172
|
+
const event = JSON.parse(last);
|
|
173
|
+
if (event.event === "command") return `$ ${event.command}`;
|
|
174
|
+
if (event.event === "helper") return `${event.helper} (${event.ms} ms)`;
|
|
175
|
+
return event.event;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** e.g. "✓ exit 0 · 326 ms · 6 files · +48 −17 · applied" */
|
|
179
|
+
function summaryLine(run: RunResult): string {
|
|
180
|
+
const parts = [];
|
|
181
|
+
parts.push(run.timedOut ? "timed out" : `exit ${run.exitCode}`);
|
|
182
|
+
parts.push(`${run.durationMs} ms`);
|
|
183
|
+
|
|
184
|
+
if (run.changes.length === 0) {
|
|
185
|
+
parts.push("no changes");
|
|
186
|
+
} else {
|
|
187
|
+
const counts = run.changes.map((change) => countLines(change.patch));
|
|
188
|
+
const additions = counts.reduce((sum, count) => sum + count.additions, 0);
|
|
189
|
+
const deletions = counts.reduce((sum, count) => sum + count.deletions, 0);
|
|
190
|
+
const files = run.changes.length === 1 ? "1 file" : `${run.changes.length} files`;
|
|
191
|
+
parts.push(`${files} · +${additions} −${deletions}`);
|
|
192
|
+
|
|
193
|
+
if (run.applied.length === run.changes.length) parts.push("applied");
|
|
194
|
+
else if (run.applied.length === 0) parts.push("NOT applied");
|
|
195
|
+
else parts.push(`${run.applied.length} of ${run.changes.length} applied`);
|
|
196
|
+
}
|
|
197
|
+
return `${run.exitCode === 0 ? "✓" : "✕"} ${parts.join(" · ")}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** e.g. " M src/a.ts +3 −1" */
|
|
201
|
+
function fileLine(change: FileChange): string {
|
|
202
|
+
const letter = { added: "A", modified: "M", deleted: "D" }[change.kind];
|
|
203
|
+
const { additions, deletions } = countLines(change.patch);
|
|
204
|
+
return ` ${letter} ${change.path} +${additions} −${deletions}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function textForModel(run: RunResult, toolCallId: string): string {
|
|
208
|
+
const lines = [summaryLine(run)];
|
|
209
|
+
|
|
210
|
+
if (run.applied.length === 0 && run.changes.length > 0) {
|
|
211
|
+
lines.push("The real workspace is unchanged. Below is the candidate diff.");
|
|
212
|
+
}
|
|
213
|
+
for (const change of run.changes) lines.push(fileLine(change));
|
|
214
|
+
for (const warning of run.warnings) lines.push(`warning: ${warning}`);
|
|
215
|
+
if (run.rolledBack.length > 0) {
|
|
216
|
+
lines.push(`Rolled back, because they were half-written when the program was killed: ${run.rolledBack.join(", ")}`);
|
|
217
|
+
}
|
|
218
|
+
if (run.stillRunning.length > 0) {
|
|
219
|
+
lines.push("Still running when it was killed:", ...run.stillRunning.map((command) => ` ${command}`));
|
|
220
|
+
} else if (run.lastStep) {
|
|
221
|
+
lines.push(`Its last logged step before the timeout: ${run.lastStep}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const output = outputForModel(run, toolCallId);
|
|
225
|
+
const diff = diffForModel(run, toolCallId);
|
|
226
|
+
|
|
227
|
+
// On failure the error goes last, where it's easiest to find; on success, the diff does.
|
|
228
|
+
if (run.exitCode === 0) lines.push(...output, ...diff);
|
|
229
|
+
else lines.push(...diff, ...output);
|
|
230
|
+
return lines.join("\n");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The program's output, within Pi's usual limits for tool output. Says so if anything was cut. */
|
|
234
|
+
function outputForModel(run: RunResult, toolCallId: string): string[] {
|
|
235
|
+
const output = run.output.trim();
|
|
236
|
+
if (!output) return [];
|
|
237
|
+
|
|
238
|
+
const truncated = truncateTail(output);
|
|
239
|
+
if (!truncated.truncated) return ["", "output:", output];
|
|
240
|
+
|
|
241
|
+
const fullOutputPath = path.join(tmpdir(), `pi-shorthand-${toolCallId}.output`);
|
|
242
|
+
writeFileSync(fullOutputPath, output);
|
|
243
|
+
const notice = `[output truncated: the last ${truncated.outputLines} of ${truncated.totalLines} lines; full output: ${fullOutputPath}]`;
|
|
244
|
+
return ["", "output:", notice, truncated.content];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function diffForModel(run: RunResult, toolCallId: string): string[] {
|
|
248
|
+
const diff = run.changes.map((change) => change.patch).join("\n");
|
|
249
|
+
if (!diff) return [];
|
|
250
|
+
|
|
251
|
+
const truncated = truncateHead(diff);
|
|
252
|
+
const lines = ["", truncated.content];
|
|
253
|
+
if (truncated.truncated) {
|
|
254
|
+
const fullDiffPath = path.join(tmpdir(), `pi-shorthand-${toolCallId}.diff`);
|
|
255
|
+
writeFileSync(fullDiffPath, diff);
|
|
256
|
+
lines.push(
|
|
257
|
+
`[diff truncated at ${truncated.outputLines} of ${truncated.totalLines} lines; full diff: ${fullDiffPath}]`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return lines;
|
|
261
|
+
}
|
package/overlay-linux.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linux: bubblewrap (0.9+) mounts a kernel overlayfs over the repository in a private mount
|
|
3
|
+
* namespace, so only the program sees it. Its writes land in an upper directory in tempDir, which
|
|
4
|
+
* is also where the changes are read from. There's nothing to undo afterwards.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs/promises";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { $ } from "bun";
|
|
10
|
+
import type { Overlay } from "./runner.ts";
|
|
11
|
+
|
|
12
|
+
export async function openLinuxOverlay(repo: string, tempDir: string): Promise<Overlay> {
|
|
13
|
+
const bwrap = Bun.which("bwrap");
|
|
14
|
+
if (!bwrap) throw new Error("The code tool needs bubblewrap (0.9 or later) on Linux.");
|
|
15
|
+
|
|
16
|
+
const upper = path.join(tempDir, "upper");
|
|
17
|
+
const work = path.join(tempDir, "work");
|
|
18
|
+
await fs.mkdir(upper);
|
|
19
|
+
await fs.mkdir(work);
|
|
20
|
+
|
|
21
|
+
const wrap = (command: string[], cwd: string) => [
|
|
22
|
+
bwrap,
|
|
23
|
+
"--die-with-parent", // so killing bwrap also kills the program
|
|
24
|
+
"--dev-bind",
|
|
25
|
+
"/",
|
|
26
|
+
"/",
|
|
27
|
+
"--overlay-src",
|
|
28
|
+
repo,
|
|
29
|
+
"--overlay",
|
|
30
|
+
upper,
|
|
31
|
+
work,
|
|
32
|
+
repo,
|
|
33
|
+
"--chdir",
|
|
34
|
+
cwd, // resolve the working directory again, inside the overlay
|
|
35
|
+
"--",
|
|
36
|
+
...command,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
originalDir: repo,
|
|
41
|
+
writableDir: upper,
|
|
42
|
+
gitExcludes: [],
|
|
43
|
+
wrap,
|
|
44
|
+
changes: async () => [...(await writtenFiles(upper)), ...(await deletedFiles(repo, wrap))],
|
|
45
|
+
close: async () => {},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Regular files in the upper directory, except git's own writes to .git (e.g. refreshing its index). */
|
|
50
|
+
async function writtenFiles(upper: string) {
|
|
51
|
+
const written: { file: string; contents: Uint8Array | null }[] = [];
|
|
52
|
+
for (const entry of await fs.readdir(upper, { recursive: true, withFileTypes: true })) {
|
|
53
|
+
const fullPath = path.join(entry.parentPath, entry.name);
|
|
54
|
+
const file = path.relative(upper, fullPath);
|
|
55
|
+
if (!entry.isFile() || file.startsWith(".git/")) continue;
|
|
56
|
+
written.push({ file, contents: await Bun.file(fullPath).bytes() });
|
|
57
|
+
}
|
|
58
|
+
return written;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Files git saw before that are gone in the overlay, found by asking git inside it. (overlayfs's own
|
|
63
|
+
* records aren't enough: deleting a directory leaves one "whiteout" for all of it, and recreating a
|
|
64
|
+
* directory hides everything that was in it.)
|
|
65
|
+
*/
|
|
66
|
+
async function deletedFiles(repo: string, wrap: (command: string[], cwd: string) => string[]) {
|
|
67
|
+
const untracked = ["git", "ls-files", "-z", "--others", "--exclude-standard"];
|
|
68
|
+
const inOverlay = (command: string[]) =>
|
|
69
|
+
$`${wrap(command, repo)}`.env({ ...process.env, GIT_OPTIONAL_LOCKS: "0" }).text();
|
|
70
|
+
|
|
71
|
+
const before = $`${untracked}`.cwd(repo).text(); // outside the overlay, so it can run alongside
|
|
72
|
+
// Inside the overlay, one mount at a time: overlayfs won't let two mounts share a work directory.
|
|
73
|
+
const trackedGone = await inOverlay(["git", "ls-files", "-z", "--deleted"]);
|
|
74
|
+
const untrackedAfter = await inOverlay(untracked);
|
|
75
|
+
const untrackedBefore = await before;
|
|
76
|
+
|
|
77
|
+
const stillThere = new Set(untrackedAfter.split("\0"));
|
|
78
|
+
const deleted = [...trackedGone.split("\0"), ...untrackedBefore.split("\0").filter((file) => !stillThere.has(file))];
|
|
79
|
+
return deleted.filter(Boolean).map((file) => ({ file, contents: null }));
|
|
80
|
+
}
|