omp packages

pi-multiedit0.4.1

Transactional multi-file edit tool for pi: text, regex, line-anchor, ast-grep and JSON selectors in one atomic call

jkeskikangas Published 7 days ago MIT ~1.1K downloads/mo

Untested on omp

This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.

Did it work for you?

Result

One report per person. A new report replaces your old one.

Reports 0

No reports yet.

Readme

pi-multiedit

One edit call for a whole change in pi. An agent can rename a function across a codebase, create a module and register it, add an entry to a JSON registry and rewrite a section of docs, all in a single edit call that applies everything or nothing. What comes back is what landed on disk, line by line.

pi install npm:pi-multiedit

It replaces pi's read and edit tools and withdraws write (even if --tools names it), since an edit without a selector creates or overwrites a file. Remove pi-hashline-edit if you have it installed: both register read and edit.

Why it's built this way

Agents edit files badly in predictable ways. The usual edit tool changes one file per call, so a ten-file change takes ten calls, or the agent writes a throwaway Python script to do it. Those scripts fail halfway and leave half a change on disk. Exact-text matching breaks on a trailing space the model didn't remember. After editing, the agent runs git diff or cat to see what happened. pi-multiedit is built so that none of this is needed.

  • All or nothing. Every edit in a call is checked in memory before anything is written. If any one fails, no file changes, and the agent gets every failure at once with the nearby lines to retry from. Writes are atomic per file, and if a write fails halfway through a call, the files already written are restored.
  • Point at lines instead of retyping them. read tags each line with a short anchor, like 12#KT:. An edit can name a range by its anchors (from: "12#KT", to: "15#BH"), so the model doesn't reproduce text it might get slightly wrong. Anchors stay valid through the earlier edits in the same call, and a stale one is refused instead of hitting the wrong line.
  • The right selector for the job. Exact text for small changes, anchor ranges for blocks, regex for patterns, and JSON pointers for data files. Any of them can apply to one file or to every file matching a glob.
  • The result is the proof. Files are re-read after writing and compared byte for byte with what was intended. The result shows only the lines that changed, as word diffs with fresh anchors the next call can use. There's nothing left to check with git diff.
  • No new syntax errors. Every edited file with a known grammar is parsed before and after. An edit that breaks parsing is refused, and the broken lines come back with anchors. Errors that were already in the file don't count.
  • One concept, no overlapping options. Every change is an edit: a scope, at most one selector, an action and a count. Without a selector, an edit applies to the whole file, which is how files are created, appended to or deleted. There is no second syntax and no alias. The description covers every field and ends with a complete example; in evals, that brought tool errors to zero where looser variants had several.

The two tools

Tool What it does
read{path, offset?, limit?} Returns text with an anchor on every line (12#KT: return total). The hashes are compatible with pi-hashline-edit. Images and other non-text files use pi's built-in read.
edit{edits, allowSyntaxErrors?} Applies every edit in order, all or nothing.

Each edit has a scope, at most one selector, and optionally an action and a count:

Options
scope path, or glob over git-visible files (e.g. src/**/*.ts)
selector old: exact text · from + to: whole lines between two anchors, inclusive (an end may also be exact text, or a bare line number of a line this session has shown, by read, edit or numbered shell output such as cat -n and rg -n, checked against that view like an anchor) · regex (+ flags), where new may use $1 and $<name> · json: a pointer whose segments may be [key=value] or - (append) · none: the whole file (path only)
action replace (default), before, after, delete. Every action except delete needs new; deleting is always explicit
count how many matches are expected across the scope: 1 (default), a number, or "all"

new is text for every selector except json, where it is the JSON value itself ("integration", 3, {"path": "a"}). The action composes with every selector the same way. On the whole file, replace creates or overwrites it, before/after prepend or append raw text to an existing file, and delete removes it.

A single call that changes code, creates a file, updates a JSON registry and every matching call site:

{"edits": [
  {"path": "src/accounts.ts", "from": "12#KT", "to": "15#BH", "action": "delete"},
  {"path": "src/accounts-api.ts", "new": "export const accountsApi = {};\n"},
  {"glob": "src/**/*.ts", "old": "repo.find(", "new": "repo.findOrThrow(", "count": "all"},
  {"path": "test/layers.json", "json": "/suites/-", "new": {"path": "test/accounts-api.test.ts", "layer": "integration"}}
]}

Moving a file is not an edit; use git mv.

What comes back

On success, the files that changed, with only their changed lines. ~ marks a rewritten line, + an added one, and each carries its new anchor:

Applied 2 step(s) to 2 file(s); re-read from disk: identical; syntax: ok (2 file(s)).

step 1: 2 matches in 1 file(s)

src/accounts.ts  +2 -2
@@ 4
~4#TS:  return repo.[-find-]{+findOrThrow+}("users", id);
@@ 8
~8#QM:  return repo.[-find-]{+findOrThrow+}("companies", id);

test/layers.json  +2 -1
@@ 3
~3#HR:    { "path": "test/accounts.test.ts", "layer": "unit" }{+,+}
+4#QJ:    { "path": "test/accounts-api.test.ts", "layer": "integration" }

On failure, nothing is written, and every failing step is listed with what the agent needs to fix it in one retry:

Nothing was written: 2 of 2 step(s) failed.

step 1 (src/accounts.ts): old not found in src/accounts.ts
nearest:
2#KM:
3#HW:export function fetchUser(id: string) {
4#YR:  return repo.find("users", id);
nearest:
6#SY:
7#JV:export function fetchCompany(id: string) {
8#WN:  return repo.find("companies", id);

step 2: old matched 2 times, expected 1. Add surrounding context, or set count to 2 or "all".
  src/accounts.ts: lines 4, 8

An edit that would leave a file unparsable is refused the same way:

Nothing was written: the edit introduces parse errors. Fix them in the retry, or set allowSyntaxErrors if the parser is wrong.
New parse errors near:
src/accounts.ts
  9#WN:  return repo.find("companies", id);
  10#BN:}

When old doesn't match exactly, the tool tries again ignoring trailing whitespace and curly quotes, then a consistent indentation shift (re-indenting new to match). It does so only when the match is unique, and the result says when it happened.

How it compares

A small synthetic eval at low thinking: 4 tasks, each run twice per setup. The tasks: a multi-file TypeScript change with a JSON registry and docs; a Python rename that adds a parameter; a TypeScript config and docs change; and converting 9 multi-line, nested assertEqual(a, b) calls across 3 test files to expect(a).toEqual(b), next to look-alikes that must not change. Each model ran in its own vendor's agent with its native edit tool, and in pi with each edit tool.

claude-opus-5-5

Setup Tool calls Edit calls Shell edits Tokens in Tokens out Time
Claude Code 28 6 8 250k 9.0k 117s
pi built-in 32 8 7 155k 7.8k 111s
pi-hashline-edit 21 1 9 162k 6.3k 99s
pi-multiedit 28 9 0 160k 6.6k 114s

claude-sonnet-5-5

Setup Tool calls Edit calls Shell edits Tokens in Tokens out Time
Claude Code 35 6 8 265k 9.7k 122s
pi built-in 32 5 11 169k 7.8k 105s
pi-hashline-edit 27 4 7 173k 7.1k 99s
pi-multiedit 24 8 0 142k 6.6k 84s

gpt-6-sol

Setup Tool calls Edit calls Shell edits Tokens in Tokens out Time
Codex 42 9 0 805k 6.8k 232s
pi built-in 62 26 2 122k 5.5k 200s
pi-hashline-edit 57 24 0 137k 4.0k 150s
pi-multiedit 36 10 0 90k 3.8k 146s

gpt-6-luna

Setup Tool calls Edit calls Shell edits Tokens in Tokens out Time
Codex 28 7 1 689k 5.5k 163s
pi built-in 58 23 1 131k 4.8k 151s
pi-hashline-edit 63 20 5 211k 6.1k 199s
pi-multiedit 40 9 0 106k 3.9k 128s

Every setup passed all 8 tasks except gpt-6-luna with pi built-in and with pi-hashline-edit (7/8 each; both failures were Python scripts on the assertEqual task). Claude Code and Codex are each model's own agent with its native edit tool.

"Shell edits" counts files changed with sed -i or Python scripts instead of the edit tool: no checks, no feedback, and the way half-applied changes happen. pi-multiedit is the only setup with none for every model. The GPT models gain the most: with gpt-6-sol it needed about 40% fewer tool calls than pi's other edit tools and the fewest input and output tokens of all four setups. Claude models already batch their edits, so their numbers are closer; with them, pi-multiedit's main gain is that every edit goes through the tool instead of sed. Input tokens include cached input, which the native agents use heavily (Codex served about 83% from cache). The pi-multiedit rows are from this version, with pi's write tool withdrawn. Two runs per task show a direction, not a significant result: a single extra turn moves a model's totals by several percent.

Line numbers from the shell

Models often read files through bash, with cat -n, nl, rg -n or grep -n, and then edit by the line numbers they saw there. Each numbered line of a bash result is matched to a file, either by the path on the line or by a path in the command or output, and is recorded only when that file has exactly that text at that number. A bare number in from/to then resolves like one from read, stale check included. Unnumbered output, such as sed -n 10,20p, is not recorded, because the model would have to count the lines itself.

For other extensions

A file that another extension puts in front of the model whole, with N#HH anchors, can count as read: emit anchored-view on pi's event bus with the absolute path and the exact text shown.

pi.events.emit("anchored-view", { path: "/abs/path/to/file.ts", text });

Bare line numbers in from/to then resolve against that view, with the same stale check as an anchor. The hashes are pi-hashline-edit's, so any implementation of them produces valid anchors.

Drafts

A draft holds edits in memory instead of writing them, and applies them later as one all-or-nothing commit. It is for runs whose edits must not touch the user's files yet: a speculative turn, or review before write.

Inside a draft, an edit call is planned against the draft's view of the workspace: disk, with the draft's own changes on top. Files it created exist there, and globs see them; files it deleted do not. Each call gets the same checks, failures and syntax check as a normal edit. A failing call leaves the draft as it was. The result text is a normal edit result, except that drafted, not written takes the place of re-read from disk: …, and details.written is false. For each file, the draft keeps the disk content from the moment it first touched the file, along with the latest content. Reading in a draft shows the drafted content, with the same anchors and paging as read.

Each draft has its own record of what the model has seen. Line numbers seen in a draft never validate a bare line number in the main session, and line numbers seen in the main session never validate one in a draft. anchored-view events and numbered shell output count for the main session only.

Applying commits the drafted files under the same check as a normal edit: no file may have changed since the draft read it. If one has, the draft's edit calls are replayed in order against the current disk, and the replayed result is committed if every call still applies. Bare line numbers in those calls are pinned to the anchors they meant when drafted. If any call no longer applies, for example because its anchor's line changed, nothing is written and the error names the files that moved. After a successful apply, the draft is gone and the written files count as shown in the main session.

API (src/draft.ts)

import { openDraft, draftEdit, draftRead, applyDraft, discardDraft, draftSummary, materializeDraft, getDraft } from "pi-multiedit/src/draft.ts";

const id = await openDraft(cwd);                         // a new, empty draft over cwd
await draftEdit(id, { edits, allowSyntaxErrors? });      // {content, details}; throws the edit error text
await draftRead(id, { path, offset?, limit? });          // {content, abs, fileText}: N#HH lines as read shows them,
                                                         // the canonical path, the whole file's raw text
draftSummary(id);   // {files: [{path, status: "created"|"modified"|"deleted", diff}], insertions, deletions}
await materializeDraft(id, dir);  // writes drafted files into dir (same relative paths), deletes drafted deletions;
                                  // refuses, writing nothing, if a drafted file is outside cwd
await applyDraft(id);   // {content, details}: "Applied … ; re-read from disk: …"; throws with nothing written
discardDraft(id);       // true if the draft existed
getDraft(id);           // {id, cwd, changes: Map<abs, {abs, before, after}>, requests, shown} | undefined

Operations on one draft run in the order they were called. An unknown id is an error.

Event channel

An extension that does not import pi-multiedit uses pi's event bus. The request goes on multiedit:draft, and the reply comes back through the reply callback in the payload. reply is called exactly once, also when the request fails.

pi.events.emit("multiedit:draft", { op: "open", cwd, reply: (r) => … });
op request reply
open {cwd} (absolute) {draftId}
edit {draftId, params: {edits, allowSyntaxErrors?}} {content, details} (the edit tool's result) or {error}
read {draftId, path, offset?, limit?} {content, abs, fileText} or {error}: abs is the canonical path, fileText the whole file as drafted (or on disk)
apply {draftId} {ok: true, content, details} or {ok: false, error}
discard {draftId} {ok: true} or {error}
summary {draftId} {files, insertions, deletions} or {error}
materialize {draftId, dir} (absolute) {ok: true} or {ok: false, error}

An unknown op or draft replies {error}; for apply and materialize the reply is {ok: false, error}. On load the extension emits multiedit:draft-ready with {version: 1}. A listener registered later misses that event, so it can send any request instead: if pi-multiedit is loaded, reply is called.

Limits

  • Text files in valid UTF-8 only. Binary files and other encodings are refused, never rewritten.
  • A consistently CRLF file keeps CRLF. In a file with mixed line endings, unedited lines keep theirs. Edited lines are written with LF, and a multi-line old only matches loosely there (the result says so).
  • Writes go through symlinks to the file they point to. Deleting a symlink itself is refused; use bash for links.
  • Each write replaces the file with a new one (temp file and rename). The file mode is kept, but hard links break and the owner and extended attributes are not kept.
  • The syntax check covers TypeScript, JavaScript, CSS, HTML and Python out of the box, chosen by file extension. Python's parser is bundled (prebuilt for macOS and Linux on x64 and arm64, and Windows x64), so installing runs no dependency install scripts. Other languages (Go, Rust, Java, Ruby and more) need their grammar: npm i @ast-grep/lang-<name> --prefix ~/.pi/agent/npm. Without it, the syntax check skips those files.

How it works

A call is planned entirely in memory. Each edit runs against the current in-memory state of its files, in order, and records which lines it changed. That record is what lets anchors from your last read still find the right lines after earlier edits in the same call have moved them. If any edit fails, the plan is thrown away.

Planning takes no locks. Before writing, every file is checked against what was read during planning. If another agent, an editor or a formatter changed one in the meantime, the whole call is planned again against the new content, up to three times. Edits whose text is still there apply on top of the other change, and the result says so. An edit whose target was changed fails like any miss.

Writing is serialised within the pi process: parallel edit calls commit one after the other, for the few milliseconds each takes, so neither can drop the other's change. Each file is checked once more just before its own write, written to a temporary file and renamed into place; if a write fails, the files already written are put back, unless something else has changed them since. Finally every file is read back and compared with what was intended. One limit remains: another program writing a file in the brief window between that last check and the rename (while the temporary file is written) is overwritten without notice. Filesystems offer no way to replace a file only if it is unchanged.

Requirements

  • pi 1.0.3 or newer, on Node 22.19 or newer.

Development

npm install
npm test            # unit, property (fast-check) and tool tests; no model calls
npm run typecheck   # tsc -p .

Credits

Hash-anchored lines come from oh-my-pi, and the hashes match pi-hashline-edit. The syntax check parses with ast-grep's tree-sitter bindings.

License

MIT