pi-edit-first 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +82 -2
- package/extensions/edit-first.ts +236 -0
- package/package.json +28 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sorinirimies
|
|
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
CHANGED
|
@@ -1,3 +1,83 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-edit-first
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Pi extension that cuts output tokens by making the agent **edit, not rewrite**.
|
|
4
|
+
|
|
5
|
+
Enforcement lives in a `tool_call` hook, so it adds **zero tokens to the system prompt**.
|
|
6
|
+
The model only sees a one-line reason when a call is blocked.
|
|
7
|
+
|
|
8
|
+
## Rules
|
|
9
|
+
|
|
10
|
+
| # | Blocks | Tells the model |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| 1 | `write` over an existing file longer than `maxWriteLines` (default 40) | use `edit` with targeted replacements |
|
|
13
|
+
| 2 | `write` of `Cargo.toml`, `settings.gradle[.kts]`, `package.json` in a dir with no project yet | run `cargo init` / `android create` / `npm init -y` |
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:pi-edit-first
|
|
19
|
+
# or
|
|
20
|
+
pi install git:github.com/sorinirimies/pi-edit-first
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Applies to every pi session (including pi run as an external agent in editors).
|
|
24
|
+
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
- `/edit-first` — status and block count
|
|
28
|
+
- `/edit-first off` / `on` — disable / enable for this session
|
|
29
|
+
- `/edit-first allow <path>` — allow one full rewrite of `<path>` this session
|
|
30
|
+
|
|
31
|
+
## Config (optional)
|
|
32
|
+
|
|
33
|
+
`~/.pi/agent/edit-first.json`:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{ "maxWriteLines": 40, "scaffold": true, "ignore": ["CHANGELOG.md", "*.lock"] }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Notes
|
|
40
|
+
|
|
41
|
+
- A block costs one extra round trip, far cheaper than a whole-file rewrite.
|
|
42
|
+
- Only affects the `write` tool; `edit` is never blocked.
|
|
43
|
+
- Does not affect other agents (e.g. Zed's native agent).
|
|
44
|
+
|
|
45
|
+
## Security notes
|
|
46
|
+
|
|
47
|
+
- **A token saver, not a sandbox.** It guards pi's `write` tool. An agent can still write files through `bash`; the guard exists to keep wasteful rewrites out of your context, not to confine the agent.
|
|
48
|
+
- **Same path as the tool.** Paths are resolved exactly like pi's own tools (`@file`, `~/file`, `file://`, Unicode spaces, Windows shell paths), so those spellings can't slip past it. A contract test compares it with pi's real `resolveToCwd`.
|
|
49
|
+
- **Fails open.** pi blocks a tool when a `tool_call` handler throws, so any unexpected error here means "allow": the guard can never stop legitimate work.
|
|
50
|
+
- **Constant memory.** Line counts are streamed (64 KB chunks, early exit), never the whole file.
|
|
51
|
+
- No network access, no shell commands, **zero runtime dependencies**.
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
bun install
|
|
57
|
+
just check # typecheck + tests + pack check + nushell tests (what CI runs)
|
|
58
|
+
just test # bun test only (with coverage)
|
|
59
|
+
just coverage # tests + a coverage table
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Coverage:** every `bun test` collects coverage (`bunfig.toml`) and **fails below 95% lines / 95% functions**. CI shows the table on the run page and uploads `lcov.info`.
|
|
63
|
+
|
|
64
|
+
CI scripts are [nushell](https://www.nushell.sh) (`scripts/`), the same ones locally and in GitHub / Gitea Actions.
|
|
65
|
+
`just --list` shows every task.
|
|
66
|
+
|
|
67
|
+
## Releases (automatic)
|
|
68
|
+
|
|
69
|
+
| Workflow | When | What |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| **CI** | push / PR | quality gate on Linux; tests on macOS, Windows and extreme time zones |
|
|
72
|
+
| **Nightly Dependency Update** | every night (GitHub 02:00 UTC, Gitea 02:30) | `bun update` within ranges, verify on all platforms, commit `chore(deps)`; then **publish a patch** when a *runtime* dependency was upgraded or `feat`/`fix`/`perf` commits are waiting since the last tag |
|
|
73
|
+
| **Release** | tag `vX.Y.Z` | validates the tag against `package.json`, runs the gate, `npm publish` (idempotent), creates the release |
|
|
74
|
+
|
|
75
|
+
Dev-tooling-only bumps are committed but never released on their own. A downgrade blocks the whole update.
|
|
76
|
+
|
|
77
|
+
Manual release: `just bump patch` (or `minor` / `major` / `X.Y.Z`), then `just release-push`.
|
|
78
|
+
|
|
79
|
+
**Secrets** (repo settings): `NPM_TOKEN` — npm *granular access token* with publish rights and "bypass 2FA" (required for CI publishing). Optional: `GH_PAT` (lets a tag push trigger the release itself), `GITEA_TOKEN` (Gitea).
|
|
80
|
+
|
|
81
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org); the changelog is generated by git-cliff.
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-edit-first — make the agent edit, not rewrite.
|
|
3
|
+
*
|
|
4
|
+
* Zero prompt tokens: enforcement happens in a `tool_call` hook. The model only
|
|
5
|
+
* sees a short reason when a call is blocked.
|
|
6
|
+
*
|
|
7
|
+
* Rules
|
|
8
|
+
* 1. `write` over an existing file longer than `maxWriteLines` is blocked → use `edit`.
|
|
9
|
+
* 2. `write` of a project manifest (Cargo.toml, settings.gradle[.kts], package.json)
|
|
10
|
+
* in a directory with no project yet is blocked → use a scaffolder.
|
|
11
|
+
*
|
|
12
|
+
* Commands
|
|
13
|
+
* /edit-first status + block count
|
|
14
|
+
* /edit-first off | on disable / enable for this session
|
|
15
|
+
* /edit-first allow <path> allow one full rewrite of <path> this session
|
|
16
|
+
*
|
|
17
|
+
* Config (optional): <agent dir>/edit-first.json
|
|
18
|
+
* { "maxWriteLines": 40, "scaffold": true, "ignore": ["CHANGELOG.md", "*.lock"] }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { open, readFile } from "node:fs/promises";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
|
|
28
|
+
interface Config {
|
|
29
|
+
maxWriteLines: number;
|
|
30
|
+
scaffold: boolean;
|
|
31
|
+
ignore: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULTS: Config = { maxWriteLines: 40, scaffold: true, ignore: [] };
|
|
35
|
+
|
|
36
|
+
/** Where pi keeps its config: PI_CODING_AGENT_DIR, then $XDG_CONFIG_HOME/pi/agent, then ~/.pi/agent. */
|
|
37
|
+
export function resolveAgentDir(env: Record<string, string | undefined>, home: string): string {
|
|
38
|
+
if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
|
|
39
|
+
if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "pi", "agent");
|
|
40
|
+
return join(home, ".pi", "agent");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const agentDir = () => resolveAgentDir(process.env, homedir());
|
|
44
|
+
|
|
45
|
+
async function loadConfig(): Promise<Config> {
|
|
46
|
+
try {
|
|
47
|
+
const p = JSON.parse(await readFile(join(agentDir(), "edit-first.json"), "utf8"));
|
|
48
|
+
return {
|
|
49
|
+
maxWriteLines:
|
|
50
|
+
Number.isFinite(p.maxWriteLines) && p.maxWriteLines > 0 ? p.maxWriteLines : DEFAULTS.maxWriteLines,
|
|
51
|
+
scaffold: typeof p.scaffold === "boolean" ? p.scaffold : DEFAULTS.scaffold,
|
|
52
|
+
ignore: Array.isArray(p.ignore) ? p.ignore.filter((x: unknown) => typeof x === "string") : [],
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return { ...DEFAULTS };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Minimal glob: `*` matches anything except `/`; matched against path or basename. */
|
|
60
|
+
/** Minimal glob (`*` = anything except `/`) against the path or its basename. */
|
|
61
|
+
export function matchesIgnore(path: string, patterns: string[]): boolean {
|
|
62
|
+
return patterns.some((pat) => {
|
|
63
|
+
if (pat.length > 256) return false; // absurd pattern: ignore it rather than risk slow matching
|
|
64
|
+
const body = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*+/g, "*").replace(/\*/g, "[^/]*");
|
|
65
|
+
const re = new RegExp(`^${body}$`);
|
|
66
|
+
return re.test(path) || re.test(basename(path));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Path handling — mirrors pi's own tool path resolution (`resolveToCwd`), so the
|
|
73
|
+
// guard looks at the same file the tool will: `@file`, `~/file`, `file://…`,
|
|
74
|
+
// Unicode spaces and Windows shell paths are normalised exactly as pi does.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
78
|
+
|
|
79
|
+
function normalizeWindowsShellPath(filePath: string): string {
|
|
80
|
+
if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\")) return filePath;
|
|
81
|
+
const m = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
82
|
+
if (!m) return filePath;
|
|
83
|
+
return `${m[1].toUpperCase()}:\\${m[2]?.replaceAll("/", "\\") ?? ""}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveToolPath(
|
|
87
|
+
input: string,
|
|
88
|
+
cwd: string,
|
|
89
|
+
home: string = homedir(),
|
|
90
|
+
platform: string = process.platform,
|
|
91
|
+
): string {
|
|
92
|
+
let p = input.replace(UNICODE_SPACES, " ");
|
|
93
|
+
if (p.startsWith("@")) p = p.slice(1);
|
|
94
|
+
if (platform === "win32") p = normalizeWindowsShellPath(p);
|
|
95
|
+
if (p === "~") p = home;
|
|
96
|
+
else if (p.startsWith("~/") || (platform === "win32" && p.startsWith("~\\"))) p = join(home, p.slice(2));
|
|
97
|
+
if (/^file:\/\//.test(p)) {
|
|
98
|
+
try {
|
|
99
|
+
p = fileURLToPath(p);
|
|
100
|
+
} catch {
|
|
101
|
+
/* malformed URL: leave as is; the tool will report it */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return isAbsolute(p) ? resolve(p) : resolve(cwd, p);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Count lines without loading the file: constant memory, and it stops as soon as
|
|
109
|
+
* `cap` is exceeded. `lines` is exact unless `capped`.
|
|
110
|
+
*/
|
|
111
|
+
export async function countLinesCapped(path: string, cap: number): Promise<{ lines: number; capped: boolean }> {
|
|
112
|
+
const fh = await open(path, "r");
|
|
113
|
+
try {
|
|
114
|
+
const buf = Buffer.allocUnsafe(64 * 1024);
|
|
115
|
+
let newlines = 0;
|
|
116
|
+
let size = 0;
|
|
117
|
+
for (;;) {
|
|
118
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
|
|
119
|
+
if (bytesRead === 0) break;
|
|
120
|
+
size += bytesRead;
|
|
121
|
+
for (let i = 0; i < bytesRead; i++) if (buf[i] === 10) newlines++;
|
|
122
|
+
if (newlines + 1 > cap) return { lines: cap, capped: true };
|
|
123
|
+
}
|
|
124
|
+
return { lines: size === 0 ? 0 : newlines + 1, capped: false };
|
|
125
|
+
} finally {
|
|
126
|
+
await fh.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Lines counted exactly up to this many beyond the limit; past that the message says "over N". */
|
|
131
|
+
const COUNT_HEADROOM = 10_000;
|
|
132
|
+
|
|
133
|
+
/** Manifest → what to run instead, and a marker that proves a project already exists. */
|
|
134
|
+
const SCAFFOLDS: Record<string, { hint: string; exists: string[] }> = {
|
|
135
|
+
"Cargo.toml": { hint: "run `cargo init` / `cargo new`", exists: ["Cargo.toml"] },
|
|
136
|
+
"settings.gradle.kts": {
|
|
137
|
+
hint: "run `android create` or `gradle init`",
|
|
138
|
+
exists: ["settings.gradle.kts", "settings.gradle"],
|
|
139
|
+
},
|
|
140
|
+
"settings.gradle": {
|
|
141
|
+
hint: "run `android create` or `gradle init`",
|
|
142
|
+
exists: ["settings.gradle.kts", "settings.gradle"],
|
|
143
|
+
},
|
|
144
|
+
"package.json": { hint: "run `npm init -y`", exists: ["package.json"] },
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export default function editFirst(pi: ExtensionAPI) {
|
|
148
|
+
let config: Config = { ...DEFAULTS };
|
|
149
|
+
let enabled = true;
|
|
150
|
+
let blocked = 0;
|
|
151
|
+
const allowed = new Set<string>();
|
|
152
|
+
|
|
153
|
+
pi.on("session_start", async () => {
|
|
154
|
+
config = await loadConfig();
|
|
155
|
+
enabled = true;
|
|
156
|
+
blocked = 0;
|
|
157
|
+
allowed.clear();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* pi BLOCKS the tool when a `tool_call` handler throws, so a bug here must never
|
|
162
|
+
* stop legitimate work: any unexpected error means "allow".
|
|
163
|
+
*/
|
|
164
|
+
const guard = async (event: any, ctx: any) => {
|
|
165
|
+
if (!enabled || event.toolName !== "write") return undefined;
|
|
166
|
+
|
|
167
|
+
const raw = event.input?.path;
|
|
168
|
+
if (typeof raw !== "string" || raw === "") return undefined;
|
|
169
|
+
const abs = resolveToolPath(raw, ctx.cwd);
|
|
170
|
+
|
|
171
|
+
if (allowed.has(abs) || matchesIgnore(raw, config.ignore)) return undefined;
|
|
172
|
+
|
|
173
|
+
const deny = (reason: string) => {
|
|
174
|
+
blocked++;
|
|
175
|
+
if (ctx.hasUI) ctx.ui.notify(`edit-first: blocked write to ${raw}`, "warning");
|
|
176
|
+
return { block: true as const, reason };
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (existsSync(abs)) {
|
|
180
|
+
let count: { lines: number; capped: boolean };
|
|
181
|
+
try {
|
|
182
|
+
count = await countLinesCapped(abs, config.maxWriteLines + COUNT_HEADROOM);
|
|
183
|
+
} catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
if (count.lines > config.maxWriteLines) {
|
|
187
|
+
const size = count.capped ? `over ${count.lines} lines` : `${count.lines} lines`;
|
|
188
|
+
return deny(
|
|
189
|
+
`"${raw}" exists (${size}). Do not rewrite whole files: use the edit tool with small targeted replacements. ` +
|
|
190
|
+
`If a full rewrite is truly required, ask the user to run /edit-first allow ${raw}.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (config.scaffold) {
|
|
197
|
+
const scaffold = SCAFFOLDS[basename(abs)];
|
|
198
|
+
if (scaffold && dirname(abs) === resolve(ctx.cwd)) {
|
|
199
|
+
const hasProject = scaffold.exists.some((f) => existsSync(join(dirname(abs), f)));
|
|
200
|
+
if (!hasProject) {
|
|
201
|
+
return deny(
|
|
202
|
+
`No project here yet. Do not hand-write ${basename(abs)}: ${scaffold.hint}, then fill in the logic.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
211
|
+
try {
|
|
212
|
+
return await guard(event, ctx);
|
|
213
|
+
} catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
pi.registerCommand("edit-first", {
|
|
219
|
+
description: "Status, on/off, or `allow <path>` for pi-edit-first",
|
|
220
|
+
handler: async (args, ctx) => {
|
|
221
|
+
const [cmd, ...rest] = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
222
|
+
if (cmd === "off") enabled = false;
|
|
223
|
+
else if (cmd === "on") enabled = true;
|
|
224
|
+
else if (cmd === "allow" && rest.length > 0) {
|
|
225
|
+
const p = rest.join(" ");
|
|
226
|
+
allowed.add(resolveToolPath(p, ctx.cwd));
|
|
227
|
+
ctx.ui.notify(`edit-first: full rewrite of ${p} allowed this session`, "info");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
ctx.ui.notify(
|
|
231
|
+
`edit-first: ${enabled ? "on" : "off"} · maxWriteLines=${config.maxWriteLines} · scaffold=${config.scaffold} · blocked=${blocked}`,
|
|
232
|
+
"info",
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-edit-first",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pi extension that cuts output tokens: blocks whole-file `write` rewrites and hand-written project manifests, steering the agent to targeted `edit` calls and scaffolders. Zero prompt tokens.",
|
|
5
|
+
"keywords": ["pi-package", "pi-extension", "tokens", "edit", "guard"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "sorinirimies",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/sorinirimies/pi-edit-first.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/sorinirimies/pi-edit-first#readme",
|
|
13
|
+
"files": ["extensions", "README.md", "LICENSE"],
|
|
14
|
+
"pi": {
|
|
15
|
+
"extensions": ["./extensions/edit-first.ts"]
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "bun test",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"check": "nu scripts/ci/quality_gate.nu"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@earendil-works/pi-coding-agent": "^1.1.0",
|
|
27
|
+
"@types/bun": "^1.4.2",
|
|
28
|
+
"typescript": "^7.0.2"
|
|
29
|
+
}
|
|
30
|
+
}
|