@r3b1s/pi-repair-layer 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 r3b1s
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,323 @@
1
+ # pi-repair-layer
2
+
3
+ A tool-input repair layer for the [pi coding agent](https://github.com/earendil-works/pi), doing
4
+ what commandcode's repair layer does: catch the small, finite set of malformed
5
+ tool calls that open models emit, fix them at the exact paths the validator
6
+ flags, and tell the model what was fixed so it can self-correct next turn.
7
+
8
+ No LLM calls, no network, no uploaded telemetry.
9
+
10
+ ## Glossary
11
+
12
+ Plain-English first, precise meaning after — for readers new to LLM tool-calling.
13
+
14
+ - **Tool call** — the model asking the agent to run a tool (read a file, run a
15
+ shell command). Concretely, a structured request with a tool name and a JSON
16
+ arguments object that the agent validates and executes.
17
+ - **Schema / validation** — the rulebook for a tool's arguments, and the check
18
+ against it. Each built-in tool declares a schema (which fields exist, their
19
+ types, which are required); pi validates every call against it and rejects
20
+ calls that don't fit.
21
+ - **Silent coercion** — when a wrong value gets quietly bent into a valid-looking
22
+ one and runs anyway. pi's validator runs TypeBox `Value.Convert` first, which
23
+ turns `null` into the string `"null"` and `'["a"]'` into `['["a"]']` — the call
24
+ then passes validation and executes with garbage instead of erroring.
25
+ - **Grammar leak** — the model prints its tool call as text instead of actually
26
+ making one. Different model families use different tool-call "grammars" (XML
27
+ tags, sentinel tokens); when one leaks, a block like
28
+ `<tool_call>read<arg_key>path</arg_key>…` shows up in the assistant's prose and
29
+ no tool runs.
30
+ - **Anchor bleed** — regex anchor characters (`^`, `$`) stuck onto a value that
31
+ isn't a regex. Some models emit `read {path: "^/x$"}` where the `^`/`$` bled in
32
+ from the tool-call grammar and were never meant to be part of the path.
33
+ - **Phantom tool call** — the model signals "I'm calling a tool" but sends no
34
+ actual call. On some providers the stream ends with a tool-use stop reason but
35
+ zero tool-call blocks, leaving the loop with nothing to run. (Out of scope
36
+ here — see [Limitations](#limitations).)
37
+ - **Repair note** — the short explanation this layer hands back to the model
38
+ after fixing a call, so it can self-correct. It rides along as a
39
+ `<repair_note>…</repair_note>` line prefixed to the tool result.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ ln -s /home/dev/Local/personal/pi-commandcode-conundrum ~/.pi/agent/extensions/pi-repair-layer
45
+ ```
46
+
47
+ pi auto-discovers `~/.pi/agent/extensions/*/index.ts`. Remove the symlink to
48
+ uninstall. pi's TUI will show a one-time warning that built-in tools were
49
+ overridden — that is this extension working as intended.
50
+
51
+ At runtime pi's extension loader aliases `@earendil-works/pi-coding-agent` and
52
+ `typebox` to the running instance's own modules, so the extension always binds
53
+ to your live pi and its exact validator version. The `node_modules/` here is
54
+ only used for the tests and `tsc`.
55
+
56
+ ```bash
57
+ pnpm install # once, if you want to run the tests
58
+ pnpm test # pure engine unit tests + end-to-end against pi's real tools
59
+ ```
60
+
61
+ The toolchain is managed with [mise](https://mise.jdx.dev/) (`mise.toml` pins
62
+ node/pnpm). `mise run ci` runs typecheck + lint + test.
63
+
64
+ ## What it repairs
65
+
66
+ | Rule | Example | Fix |
67
+ |---|---|---|
68
+ | `renameAliasedField` | `{file_path: "/x"}` for `read` | `{path: "/x"}` |
69
+ | `dropNullOrUndefinedField` | `{path: "/x", offset: null}` | `{path: "/x"}` |
70
+ | `dropEmptyObjectPlaceholder` | `{tags: {}}` where array expected | field dropped |
71
+ | `parseJsonStringifiedArray` | `{include: '["a","b"]'}` | `{include: ["a","b"]}` |
72
+ | `parseJsonStringifiedObject` | stringified object for object field | parsed |
73
+ | `wrapBareStringAsArray` | `{include: "foo"}` | `{include: ["foo"]}` |
74
+ | `wrapRootStringAsObject` | `"echo hi"` as the whole input to `bash` | `{command: "echo hi"}` |
75
+ | `parseJsonStringifiedRootObject` | `'{"command":"ls"}'` as the whole input | parsed |
76
+ | `unwrapMarkdownAutoLink` | `path: "/x/[notes.md](http://notes.md)"` | `path: "/x/notes.md"` |
77
+ | `foldFlatEditFields` | `{path, old_string, new_string}` for `edit` | `{path, edits: [{oldText, newText}]}` |
78
+
79
+ Alias tables cover the contracts models actually leak: Claude Code's
80
+ `file_path`/`old_string`/`new_string`, aider's `search`/`replace`, generic
81
+ `cmd`/`query`/`text`/`contents`, at any nesting depth (e.g. inside `edits[n]`).
82
+
83
+ Every repair is surfaced to the model as a `<repair_note>` prefixed to the tool
84
+ result, e.g.:
85
+
86
+ ```
87
+ <repair_note>Renamed `file_path` to `path` for tool "read". `file_path` is not a valid field for this tool — use `path` next time.</repair_note>
88
+ ```
89
+
90
+ Transparency over silent magic: the model sees what was picked and can correct
91
+ itself on the next turn.
92
+
93
+ ## Model-gated value strips
94
+
95
+ Before the validate-then-repair engine runs, a small pre-pass
96
+ (`src/value-strips.ts`) cleans two model-specific artifacts that are *valid
97
+ strings* and so slip past validation entirely:
98
+
99
+ - **Anchor bleed** — leading `^` / trailing `$` bled into a value
100
+ (`read {path: "^/x$"}` → `{path: "/x"}`).
101
+ - **Grammar-token leaks** — GLM `<arg_key>`/`<arg_value>` markers stuck onto
102
+ object keys or values (`{"<arg_key>pattern</arg_key>": "<arg_value>foo</arg_value>"}`
103
+ → `{pattern: "foo"}`).
104
+
105
+ Both are gated on the current model id — anchor bleed on `kimi-k2` / `minimax` /
106
+ `glm`, grammar tokens on `glm` — since these are model-specific quirks. Each
107
+ strip emits a `<repair_note>` and telemetry exactly like an engine repair, and
108
+ the strips are adapted from [pi-tool-repair](#prior-art). One improvement over
109
+ upstream: the anchor strip **skips `grep.pattern`**, the one built-in field
110
+ that is a real regex, where a `^`/`$` may be intended syntax and is
111
+ indistinguishable from a bled anchor — so it is never guessed at.
112
+ (`find.pattern` is a glob, not a regex, so it is *not* exempt.) Whether anchor
113
+ bleed happens on pi at all is an open empirical question; shipping the strips
114
+ instrumented answers it for free.
115
+
116
+ ## Grammar-leak recovery (opt-in)
117
+
118
+ When a model prints a tool call as text (a [grammar leak](#glossary)) instead of
119
+ emitting a real one, `src/grammar-recovery.ts` (adapted from
120
+ [pi-tool-repair](#prior-art), 10 grammar families, code-fence-aware) handles it
121
+ on pi's `message_end` hook. Modes, set via `/repair-settings`:
122
+
123
+ - **`off`** — never touch assistant text.
124
+ - **`strip`** (default) — remove the leaked grammar from the text on gated
125
+ models, but never promote it to an executable call.
126
+ - **`recover`** — additionally promote the parsed call to a real tool call that
127
+ executes the same turn and re-enters this layer's `prepareArguments` repair
128
+ path. A recovery note is stashed so the executed call surfaces
129
+ `<repair_note>recovered a leaked … tool call…</repair_note>`.
130
+
131
+ Because promotion turns model *text* into *execution*, it is guarded: opt-in
132
+ `recover` mode, a known-tool allowlist, an empty-arguments skip, role
133
+ preservation, and a **stopReason gate** — a call is promoted only when the
134
+ original message's `stopReason` is `"stop"`. Upstream promotes regardless and
135
+ overwrites `stopReason: "length"`, which would defeat pi's protection that fails
136
+ all tool calls on truncated output ([research.md Claim 7][r-claim7]). Stripping
137
+ leaked text is allowed on any stopReason; only promotion is gated.
138
+
139
+ ## Design: validate-then-repair, hooked pre-validation
140
+
141
+ pi's agent loop runs, per tool call ([research.md Claim 1][r-claim1]):
142
+
143
+ ```
144
+ tool.prepareArguments(raw) → validateToolArguments() → tool_call event → execute
145
+ ```
146
+
147
+ Every mechanical claim in this section is verified against pi's source, with
148
+ file:line citations and a verification date, in
149
+ [`docs/research.md`](docs/research.md); the linked claim numbers point at the
150
+ specific entries. Automated tripwires in `test/upstream-drift.test.ts` execute
151
+ these claims against the installed pi so a pi upgrade can't invalidate them
152
+ silently.
153
+
154
+ Two consequences drove the architecture:
155
+
156
+ 1. **The `tool_call` extension event fires *after* validation**
157
+ ([research.md Claims 1–2][r-claim1]). A validation
158
+ failure short-circuits to an error result before any event handler runs, so
159
+ an extension listening on `tool_call` can never see — let alone repair —
160
+ malformed input. (This is why repair extensions built on that hook can't
161
+ work for built-in tools.) The only pre-validation seam is
162
+ `prepareArguments`, which extensions reach by overriding a built-in tool via
163
+ `pi.registerTool({ same name })`. Each override here spreads the original
164
+ definition (`createReadToolDefinition(cwd)` etc. are exported from pi's
165
+ root), so renderers, prompt metadata, and execution are the real built-ins;
166
+ only `prepareArguments` is chained and `execute` thinly wrapped for notes.
167
+
168
+ 2. **pi's own coercion (`Value.Convert`) silently corrupts the classic failure
169
+ modes.** Measured on typebox 1.1.38, which pi validates with:
170
+
171
+ | Model sends | Convert produces | Then |
172
+ |---|---|---|
173
+ | `'["a","b"]'` for an array | `['["a","b"]']` | passes validation, executes with garbage |
174
+ | `null` for an optional string | `"null"` | passes, e.g. greps for the string `null` |
175
+ | `null` for a required `path` | `"null"` | passes, reads a file named `null` |
176
+ | `null` for an optional number | `0` | passes, offset silently zero |
177
+
178
+ So this engine checks **strictly first** (no Convert) and repairs at the
179
+ strict-error sites before Convert can mangle them. Benign coercions
180
+ (`"5"` → `5`) still fall through to pi's native behavior untouched. The
181
+ repair layer therefore doesn't just recover calls that would have errored —
182
+ it fixes calls pi currently executes *wrongly*.
183
+
184
+ The engine itself follows the validate-then-repair shape commandcode's author
185
+ described publicly: parse as-is; if it succeeds, ship it untouched (fast path
186
+ returns the original object by reference); on failure, walk the validator's own
187
+ issue list and spend repair budget only at the paths the schema disagreed with;
188
+ re-validate; never ship a repair that doesn't verify. Rule order is fixed —
189
+ JSON-array parsing before bare-string wrapping, renames before null-drops.
190
+
191
+ The one deliberate preprocess (not validate-then-repair) is markdown auto-link
192
+ unwrapping on path fields, because `"/x/[notes.md](http://notes.md)"` is a
193
+ perfectly valid string that validation can never flag. It unwraps only the
194
+ degenerate case where the link text equals the URL minus its protocol; real
195
+ markdown links pass through, and content fields are never touched.
196
+
197
+ ## Telemetry (local only)
198
+
199
+ Repair outcomes append to `~/.pi/agent/tool-repair/telemetry.jsonl`: timestamp,
200
+ tool, model id, outcome, rules fired, an issue summary, and an FNV-1a
201
+ fingerprint of the (tool, failure-shape) pair — the same shape-fingerprint
202
+ trick commandcode uses to count distinct failure signatures. Inputs themselves
203
+ are never logged.
204
+
205
+ Records come on two channels. The **tool channel** (the default; records with no
206
+ `channel` field, so old logs read unchanged) keys on a tool, with `outcome` one
207
+ of `repaired`, `unrepairable`, or `recovered` (a promoted grammar-leak call).
208
+ The **message channel** (`channel: "message"`) records grammar strip-only events
209
+ — a leak removed with nothing promoted, so there is no tool to key on — with the
210
+ grammar family that was stripped. `/repair-stats` summarizes both:
211
+
212
+ ```
213
+ /repair-stats
214
+ ```
215
+
216
+ It reports repairs/recoveries by tool and by model, grammar strip-only events by
217
+ family, and a rules-fired tally. Watch per-model rates to catch a model
218
+ regressing on a specific contract.
219
+
220
+ Env switches: `PI_TOOL_REPAIR_LOG=1` logs decisions to stderr;
221
+ `PI_TOOL_REPAIR_TELEMETRY=off` disables telemetry (or set it to a path).
222
+
223
+ ## Chaos test (deterministic live exercise)
224
+
225
+ `test/run-chaos.sh` + `test/chaos-provider.ts` register a scripted fake
226
+ provider that replays canned malformed tool calls through pi's real agent loop
227
+ (print mode via the `pi` binary, no network, no tokens), then reports the
228
+ `<repair_note>` lines the "model" saw and asserts on them. It exercises the full
229
+ path end-to-end — streaming, preflight, validation, repair, execution, and note
230
+ surfacing — and passes against pi 0.80.6:
231
+
232
+ ```bash
233
+ test/run-chaos.sh
234
+ ```
235
+
236
+ (An earlier pi release resolved the stream function before an extension's
237
+ custom provider finished registering, so this hung; 0.80.6 streams through the
238
+ registered provider correctly.) The `test/upstream-drift.test.ts` suite drives
239
+ the same real loop fully in-process (via pi-ai's faux provider), so the loop
240
+ behaviors this layer depends on are covered by `pnpm test` too, without the
241
+ `pi` binary.
242
+
243
+ ## Display settings
244
+
245
+ Repaired tool calls get a `🔨 ✓ input repaired (rules...)` line appended to
246
+ their result row in the TUI; optionally the repair note text renders beneath
247
+ it. `/repair-settings` toggles the indicator and the note text independently,
248
+ and cycles the grammar-leak recovery mode (`off → strip → recover`; default
249
+ `strip`). All three persist to `~/.pi/agent/tool-repair/settings.json` (or
250
+ `PI_TOOL_REPAIR_SETTINGS=<path>`), alongside an optional `grammarAllowedTools`
251
+ list that restricts which tool names a leaked call may be promoted to (when
252
+ empty, the active tool set is used). Indicators are persisted per session via
253
+ custom entries, so they survive `/reload` and session resume.
254
+
255
+ ## Unrepairable input
256
+
257
+ When repairs can't make the input valid, the layer raises a model-readable
258
+ retry error (`Invalid input for tool "write". Fix these issues and retry: ...`)
259
+ instead of passing the input through — passing through would let pi's
260
+ `Value.Convert` coerce it into garbage (`content: null` becomes the literal
261
+ string `"null"` on disk) and execute anyway. Set `PI_TOOL_REPAIR_PASSTHROUGH=1`
262
+ to restore pi's native behavior.
263
+
264
+ ## Prior art
265
+
266
+ The value strips and the grammar-leak recovery approach come from
267
+ [`monotykamary/pi-tool-repair`](https://github.com/monotykamary/pi-tool-repair)
268
+ (MIT), Tom X Nguyen's pi extension. What this project adapted from it:
269
+
270
+ - The anchor-bleed and grammar-token-leak strips (`src/value-strips.ts`),
271
+ reworked to run inside `prepareArguments`, to skip the `grep.pattern` regex
272
+ field, and to report through this layer's repair-note / telemetry machinery.
273
+ - The grammar-leak recovery parsers, candidate selection, code-fence handling,
274
+ and range removal (`src/grammar-recovery.ts`, ~10 grammar families), adapted
275
+ in place with a one-line provenance header, plus the new `stopReason`
276
+ promotion gate.
277
+
278
+ The two projects also share a common Command Code lineage in their
279
+ validate-then-repair engines (near-identical rule names and alias tables).
280
+
281
+ Where they differ is **which hook reaches a malformed built-in call**, and that
282
+ is mechanical, not a matter of quality. pi runs
283
+ `prepareArguments → validateToolArguments → tool_call event → execute`
284
+ ([research.md Claim 1][r-claim1]). A malformed built-in call makes
285
+ `validateToolArguments` **throw** before the `tool_call` event ever fires
286
+ ([Claim 2][r-claim1]), so a repair that keys on `tool_call` cannot see it; and
287
+ even for a call that does pass, the `tool_call` event only propagates a handler's
288
+ *in-place* mutation of `event.input`, never a reassignment ([Claim 3][r-claim3]).
289
+ pi-tool-repair's built-in repair keys on `tool_call`; this project overrides the
290
+ built-in tool so its repair runs in `prepareArguments`, the one seam ahead of
291
+ validation. That is why the two hooks reach different cases — see the cited
292
+ research entries for the source. Conversely, the capabilities assimilated here
293
+ (value strips, grammar recovery) live on hooks that *do* propagate their results
294
+ (`prepareArguments`, `message_end` — [Claim 4][r-claim4]), which is why adapting
295
+ them is sound.
296
+
297
+ [r-claim1]: docs/research.md#claim-1--loop-ordering-preparearguments-runs-before-validation-which-runs-before-the-tool_call-event
298
+ [r-claim3]: docs/research.md#claim-3--tool_call-event-in-place-input-mutation-propagates-reassignment-does-not
299
+ [r-claim4]: docs/research.md#claim-4--message_end-replacement-mutates-the-loops-message-in-place-listeners-are-awaited-and-the-replacement-executes-same-turn-with-a-role-guard
300
+ [r-claim7]: docs/research.md#claim-7--stopreason-length-causes-pi-to-fail-all-tool-calls-in-the-message
301
+
302
+ ## Limitations
303
+
304
+ - Only pi's seven built-in tools are wrapped. Custom tools from other
305
+ extensions aren't (pi has no API to wrap another extension's execute), but
306
+ they can import `repairToolInput` from `src/repair-engine.ts` and use it in
307
+ their own `prepareArguments`.
308
+ - Wrong-but-optional fields are invisible to validation by design: pi's schemas
309
+ allow extra keys, so `grep {pattern, directory: "src"}` validates and
310
+ silently searches the cwd. No validate-then-repair layer can catch this
311
+ (commandcode's included); it would take strict schemas or key-similarity
312
+ heuristics, both with false-positive costs.
313
+ - Unrepairable input falls through to pi's stock validation error, which is
314
+ already model-readable (per-path issues plus the received arguments).
315
+ - If another extension also overrides a built-in tool, load order decides which
316
+ override wins — they don't compose.
317
+ - Phantom tool calls (a tool-use stop reason with zero tool-call blocks) are not
318
+ handled. On pi 0.80.6 that state is a clean terminal stop, not a recoverable
319
+ retry ([research.md Claim 6][r-claim6]), so normalizing it would be cosmetic;
320
+ it will be revisited only if a concrete stuck state is reproduced on a
321
+ provider.
322
+
323
+ [r-claim6]: docs/research.md#claim-6--stopreason-error-and-aborted-is-terminal-not-a-retry
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Entry point for pi's extension auto-discovery (~/.pi/agent/extensions/*/index.ts).
2
+ export { default } from "./src/index.ts";
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@r3b1s/pi-repair-layer",
3
+ "version": "0.1.0",
4
+ "description": "Tool-input repair layer for the pi coding agent: validate-then-repair for built-in tool calls, ported from the behavior of commandcode's repair layer",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/r3b1s/pi-repair-layer.git"
8
+ },
9
+ "files": [
10
+ "index.ts",
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "type": "module",
16
+ "main": "src/index.ts",
17
+ "license": "MIT",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "keywords": [
22
+ "pi-package",
23
+ "pi-extension",
24
+ "pi-coding-agent",
25
+ "tool-repair",
26
+ "validation"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./src/index.ts"
31
+ ]
32
+ },
33
+ "dependencies": {
34
+ "typebox": "1.1.38"
35
+ },
36
+ "devDependencies": {
37
+ "@biomejs/biome": "2.4.8",
38
+ "@earendil-works/pi-ai": "^0.80.3",
39
+ "@earendil-works/pi-coding-agent": "0.80.3",
40
+ "@types/node": "^24.0.0",
41
+ "eslint": "^9.25.1",
42
+ "typescript": "^5.9.3",
43
+ "typescript-eslint": "^8.31.1",
44
+ "vitest": "^4.1.10"
45
+ },
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-coding-agent": "*"
48
+ },
49
+ "engines": {
50
+ "node": ">=22"
51
+ },
52
+ "scripts": {
53
+ "check": "tsc --noEmit",
54
+ "lint": "biome check . && eslint src/ test/ index.ts",
55
+ "test": "vitest run",
56
+ "test:watch": "vitest",
57
+ "format": "biome check --write .",
58
+ "clean": "rm -rf dist node_modules coverage"
59
+ }
60
+ }