pi-edit-first 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,15 @@
1
1
  # pi-edit-first
2
2
 
3
- Pi extension that cuts output tokens by making the agent **edit, not rewrite**.
3
+ [![npm](https://img.shields.io/npm/v/pi-edit-first.svg)](https://www.npmjs.com/package/pi-edit-first)
4
+ [![npm downloads](https://img.shields.io/npm/dm/pi-edit-first.svg)](https://www.npmjs.com/package/pi-edit-first)
5
+ [![CI](https://github.com/sorinirimies/pi-edit-first/actions/workflows/ci.yml/badge.svg)](https://github.com/sorinirimies/pi-edit-first/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
7
 
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.
8
+ A [pi](https://github.com/earendil-works/pi-coding-agent) extension that cuts output tokens by making the agent **edit, not rewrite**.
9
+
10
+ Enforcement lives in a `tool_call` hook, so it adds **zero tokens to the system prompt**. The model only sees a one-line reason when a call is blocked.
11
+
12
+ <img src="examples/vhs/generated/overview.gif" alt="The agent tries to rewrite a whole file; pi-edit-first blocks it and the agent makes a one-line targeted edit instead" width="900">
7
13
 
8
14
  ## Rules
9
15
 
@@ -22,11 +28,27 @@ pi install git:github.com/sorinirimies/pi-edit-first
22
28
 
23
29
  Applies to every pi session (including pi run as an external agent in editors).
24
30
 
31
+ ## Previews
32
+
33
+ Recorded from a real pi with only this plugin loaded; a small scripted mock model plays the agent, so you see the **real guard** reacting to **real tool calls**.
34
+
35
+ **A whole-file rewrite is blocked, the agent makes a targeted edit** (one line added, not 62 rewritten):
36
+
37
+ ![The write is blocked and the agent falls back to a one-line edit](examples/vhs/generated/overview.gif)
38
+
39
+ **Hand-written manifests are blocked too** (scaffold with the real tool instead):
40
+
41
+ ![Writing Cargo.toml by hand is blocked: run cargo init](examples/vhs/generated/scaffold.gif)
42
+
43
+ **Commands:** status and block count, `off` / `on`, and `allow <path>` for one deliberate rewrite:
44
+
45
+ ![/edit-first status, off, on and allow](examples/vhs/generated/commands.gif)
46
+
25
47
  ## Commands
26
48
 
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
49
+ - `/edit-first`: status and block count
50
+ - `/edit-first off` / `on`: disable / enable for this session
51
+ - `/edit-first allow <path>`: allow one full rewrite of `<path>` this session
30
52
 
31
53
  ## Config (optional)
32
54
 
@@ -40,6 +62,73 @@ Applies to every pi session (including pi run as an external agent in editors).
40
62
 
41
63
  - A block costs one extra round trip, far cheaper than a whole-file rewrite.
42
64
  - Only affects the `write` tool; `edit` is never blocked.
65
+ - Line counts match `wc -l` (a trailing newline is not an extra line).
43
66
  - Does not affect other agents (e.g. Zed's native agent).
44
67
 
68
+ ## Security notes
69
+
70
+ - **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.
71
+ - **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`.
72
+ - **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.
73
+ - **Constant memory.** Line counts are streamed (64 KB chunks, early exit), never the whole file.
74
+ - No network access, no shell commands, **zero runtime dependencies**.
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ bun install
80
+ just check # typecheck + tests + pack check + nushell tests (what CI runs)
81
+ just test # bun test only (with coverage)
82
+ just coverage # tests + a coverage table
83
+ ```
84
+
85
+ **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`.
86
+
87
+ CI scripts are [nushell](https://www.nushell.sh) (`scripts/`), the same ones locally and in GitHub / Gitea Actions.
88
+ `just --list` shows every task.
89
+
90
+ ## Demo recordings
91
+
92
+ The GIFs above live in [`examples/vhs/generated/`](examples/vhs/generated) and are stored with **Git LFS** (`git lfs install` once). They are recorded with [VHS](https://github.com/charmbracelet/vhs) from a **real pi** that loads only this extension, on **synthetic** data (`examples/vhs/fixture.sh`), never from real files, sessions or credentials. The agent is a scripted mock model (`examples/vhs/mock-llm.ts`), so the recording is repeatable.
93
+
94
+ ```sh
95
+ just vhs-all # every tape (examples/vhs/*.tape): needs vhs, ttyd, ffmpeg, pi, bun, python3
96
+ just vhs-tape overview # one tape
97
+ just vhs-list # list the tapes
98
+ just demo # try it yourself in a real pi on the same synthetic data
99
+ ```
100
+
101
+ ## Releases (automatic)
102
+
103
+ | Workflow | When | What |
104
+ |---|---|---|
105
+ | **CI** | push / PR | quality gate on Linux; tests on macOS and Windows |
106
+ | **Auto-merge library updates** | CI finished on a Dependabot PR | **patch and minor** updates (GitHub Actions) are merged automatically, but only **after CI is green** on that exact commit; a **major** update waits for you. Then it starts the nightly workflow so the update ships |
107
+ | **Nightly Dependency Update** | every night (GitHub 02:00 UTC, Gitea 02:30) and after each auto-merge | `bun update` within ranges, verify on all platforms, commit `chore(deps)`; then **build, tag and publish a new patch** whenever a library was upgraded or merged, or `feat`/`fix`/`perf` commits are waiting since the last tag |
108
+ | **Release** | tag `vX.Y.Z` | validates the tag against `package.json`, runs the gate, `npm publish` (idempotent, with provenance), creates the release |
109
+
110
+ So library updates need no human: they are merged once CI passes, built, versioned and published as a patch. A downgrade, a failing check, or a major update stops the chain and waits for review.
111
+
112
+ Manual release: `just bump patch` (or `minor` / `major` / `X.Y.Z`), then `just release-push`.
113
+
114
+ **Secrets** (repo settings): `NPM_TOKEN` is an 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).
115
+
116
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org); the changelog is generated by git-cliff.
117
+
118
+ ## Related plugins
119
+
120
+ Three small [pi](https://github.com/earendil-works/pi-coding-agent) extensions that save tokens without adding anything to the system prompt:
121
+
122
+ | Plugin | What it does |
123
+ |---|---|
124
+ | [**pi-edit-first**](https://github.com/sorinirimies/pi-edit-first) | Blocks whole-file `write` rewrites and hand-written project manifests, steering the agent to targeted `edit` calls and scaffolders |
125
+ | [**pi-read-guard**](https://github.com/sorinirimies/pi-read-guard) | Blocks full reads of large files, steering the agent to `offset`/`limit` or search |
126
+ | [**pi-tokenburn**](https://github.com/sorinirimies/pi-tokenburn) | Live token and cost counter in pi's footer, with charts and budgets |
127
+
128
+ ```bash
129
+ pi install npm:pi-edit-first
130
+ pi install npm:pi-read-guard
131
+ pi install npm:pi-tokenburn
132
+ ```
133
+
45
134
  MIT
@@ -19,9 +19,10 @@
19
19
  */
20
20
 
21
21
  import { existsSync } from "node:fs";
22
- import { readFile } from "node:fs/promises";
22
+ import { open, readFile } from "node:fs/promises";
23
23
  import { homedir } from "node:os";
24
24
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
25
+ import { fileURLToPath } from "node:url";
25
26
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
27
 
27
28
  interface Config {
@@ -32,12 +33,15 @@ interface Config {
32
33
 
33
34
  const DEFAULTS: Config = { maxWriteLines: 40, scaffold: true, ignore: [] };
34
35
 
35
- function agentDir(): string {
36
- if (process.env.PI_CODING_AGENT_DIR) return process.env.PI_CODING_AGENT_DIR;
37
- if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "pi", "agent");
38
- return join(homedir(), ".pi", "agent");
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");
39
41
  }
40
42
 
43
+ const agentDir = () => resolveAgentDir(process.env, homedir());
44
+
41
45
  async function loadConfig(): Promise<Config> {
42
46
  try {
43
47
  const p = JSON.parse(await readFile(join(agentDir(), "edit-first.json"), "utf8"));
@@ -53,17 +57,82 @@ async function loadConfig(): Promise<Config> {
53
57
  }
54
58
 
55
59
  /** Minimal glob: `*` matches anything except `/`; matched against path or basename. */
56
- function matchesIgnore(path: string, patterns: string[]): boolean {
60
+ /** Minimal glob (`*` = anything except `/`) against the path or its basename. */
61
+ export function matchesIgnore(path: string, patterns: string[]): boolean {
57
62
  return patterns.some((pat) => {
58
- const re = new RegExp(`^${pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")}$`);
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}$`);
59
66
  return re.test(path) || re.test(basename(path));
60
67
  });
61
68
  }
62
69
 
63
- function countLines(text: string): number {
64
- return text === "" ? 0 : text.split("\n").length;
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("/", "\\") ?? ""}`;
65
84
  }
66
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 `cap` is
109
+ * exceeded. Counts like `wc -l`, plus an unterminated last line. `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
+ let lastByte = 0;
118
+ for (;;) {
119
+ const { bytesRead } = await fh.read(buf, 0, buf.length, null);
120
+ if (bytesRead === 0) break;
121
+ size += bytesRead;
122
+ for (let i = 0; i < bytesRead; i++) if (buf[i] === 10) newlines++;
123
+ lastByte = buf[bytesRead - 1];
124
+ if (newlines > cap) return { lines: cap, capped: true }; // at least `newlines` lines: past the cap
125
+ }
126
+ const lines = size === 0 ? 0 : newlines + (lastByte === 10 ? 0 : 1);
127
+ return lines > cap ? { lines: cap, capped: true } : { lines, capped: false };
128
+ } finally {
129
+ await fh.close();
130
+ }
131
+ }
132
+
133
+ /** Lines counted exactly up to this many beyond the limit; past that the message says "over N". */
134
+ const COUNT_HEADROOM = 10_000;
135
+
67
136
  /** Manifest → what to run instead, and a marker that proves a project already exists. */
68
137
  const SCAFFOLDS: Record<string, { hint: string; exists: string[] }> = {
69
138
  "Cargo.toml": { hint: "run `cargo init` / `cargo new`", exists: ["Cargo.toml"] },
@@ -91,12 +160,16 @@ export default function editFirst(pi: ExtensionAPI) {
91
160
  allowed.clear();
92
161
  });
93
162
 
94
- pi.on("tool_call", async (event, ctx) => {
163
+ /**
164
+ * pi BLOCKS the tool when a `tool_call` handler throws, so a bug here must never
165
+ * stop legitimate work: any unexpected error means "allow".
166
+ */
167
+ const guard = async (event: any, ctx: any) => {
95
168
  if (!enabled || event.toolName !== "write") return undefined;
96
169
 
97
170
  const raw = event.input?.path;
98
171
  if (typeof raw !== "string" || raw === "") return undefined;
99
- const abs = isAbsolute(raw) ? raw : resolve(ctx.cwd, raw);
172
+ const abs = resolveToolPath(raw, ctx.cwd);
100
173
 
101
174
  if (allowed.has(abs) || matchesIgnore(raw, config.ignore)) return undefined;
102
175
 
@@ -107,15 +180,16 @@ export default function editFirst(pi: ExtensionAPI) {
107
180
  };
108
181
 
109
182
  if (existsSync(abs)) {
110
- let lines = 0;
183
+ let count: { lines: number; capped: boolean };
111
184
  try {
112
- lines = countLines(await readFile(abs, "utf8"));
185
+ count = await countLinesCapped(abs, config.maxWriteLines + COUNT_HEADROOM);
113
186
  } catch {
114
187
  return undefined;
115
188
  }
116
- if (lines > config.maxWriteLines) {
189
+ if (count.lines > config.maxWriteLines) {
190
+ const size = count.capped ? `over ${count.lines} lines` : `${count.lines} lines`;
117
191
  return deny(
118
- `"${raw}" exists (${lines} lines). Do not rewrite whole files: use the edit tool with small targeted replacements. ` +
192
+ `"${raw}" exists (${size}). Do not rewrite whole files: use the edit tool with small targeted replacements. ` +
119
193
  `If a full rewrite is truly required, ask the user to run /edit-first allow ${raw}.`,
120
194
  );
121
195
  }
@@ -134,6 +208,14 @@ export default function editFirst(pi: ExtensionAPI) {
134
208
  }
135
209
  }
136
210
  return undefined;
211
+ };
212
+
213
+ pi.on("tool_call", async (event, ctx) => {
214
+ try {
215
+ return await guard(event, ctx);
216
+ } catch {
217
+ return undefined;
218
+ }
137
219
  });
138
220
 
139
221
  pi.registerCommand("edit-first", {
@@ -144,7 +226,7 @@ export default function editFirst(pi: ExtensionAPI) {
144
226
  else if (cmd === "on") enabled = true;
145
227
  else if (cmd === "allow" && rest.length > 0) {
146
228
  const p = rest.join(" ");
147
- allowed.add(isAbsolute(p) ? p : resolve(ctx.cwd, p));
229
+ allowed.add(resolveToolPath(p, ctx.cwd));
148
230
  ctx.ui.notify(`edit-first: full rewrite of ${p} allowed this session`, "info");
149
231
  return;
150
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-edit-first",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
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
5
  "keywords": ["pi-package", "pi-extension", "tokens", "edit", "guard"],
6
6
  "license": "MIT",
@@ -11,6 +11,20 @@
11
11
  },
12
12
  "homepage": "https://github.com/sorinirimies/pi-edit-first#readme",
13
13
  "files": ["extensions", "README.md", "LICENSE"],
14
- "pi": { "extensions": ["./extensions/edit-first.ts"] },
15
- "peerDependencies": { "@earendil-works/pi-coding-agent": "*" }
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
+ }
16
30
  }