@lucascardozo/pi-edit-guard

Pi extension that wraps the native edit tool with indentation-drift recovery, uniqueness enforcement, and batch-aware error reporting. Fixes the most common LLM failure mode in code editing.

Packages

Package details

extension

Install @lucascardozo/pi-edit-guard from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@lucascardozo/pi-edit-guard
Package
@lucascardozo/pi-edit-guard
Version
0.15.0
Published
Sep 3, 2026
Downloads
1,813/mo · 82/wk
Author
lucascardozo
License
MIT
Types
extension
Size
126.8 KB
Dependencies
1 dependency · 1 peer
Pi manifest JSON
{
  "extensions": [
    "./index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-edit-guard

Pi extension that wraps the native edit tool with silent auto-fix, uniqueness enforcement, and batch-aware error reporting.

Fixes the most common LLM failure mode in code editing: the model counts spaces wrong, sends an oldText that doesn't match the file's actual indentation, and the edit fails with a generic "Could not find" error. The model then has to either re-read the file (cost) or give up.

pi-edit-guard silently corrects these failures for the most common case (uniform leading-space shift) by mutating event.input in place and letting native edit run with corrected oldText/newText. When the failure is more complex (ambiguous match, character difference, no match), it surfaces a consolidated report so the model can fix everything in one pass instead of N.

Install

pi install npm:@lucascardozo/pi-edit-guard

Quick start

After pi install, the extension is active by default with zero config. The most common failure mode (model writes oldText with wrong leading indentation) is handled silently — pi-edit-guard mutates the edit in place so native edit succeeds.

Verify it works

The debug logger is off by default — no files are written to /tmp unless you opt in. To capture a session for triage, set the env vars before launching Pi:

PI_EDIT_GUARD_DEBUG=1 pi   # capture one NDJSON line per edit
PI_EDIT_GUARD_DEBUG=1 PI_EDIT_GUARD_LOG_FULL=1 PI_EDIT_GUARD_LOG_SNAPSHOTS=1 pi  # full content + file snapshots

Once enabled, the log goes to /tmp/pi-edit-guard-<pid>.log (override with PI_EDIT_GUARD_LOG_PATH=...). Run a model turn that touches a file and inspect:

# in one terminal
tail -f /tmp/pi-edit-guard-$(pgrep -f "pi-coding-agent" | head -1).log
# in another: ask the model to edit any file in your project

If the extension is intercepting correctly, you'll see one JSON line per edit with fields like result: "autofixed" (silent correction), result: "blocked" (atomic block with consolidated report), or result: "formatter-rewritten" (formatter applied after a successful native edit).

Optional: configure a formatter

If you want the extension to defer to an external formatter (prettier, biome, deno fmt, etc.) instead of mutating newText itself, create a config file. See Auto-format (opt-in, v0.12.0+) for the schema and Common formatter recipes for copy-paste configs.

The simplest setup — use prettier on everything:

mkdir -p .pi/extensions/pi-edit-guard
cat > .pi/extensions/pi-edit-guard/config.json <<'JSON'
{
  "commands": { "prettier": ["npx", "prettier", "--write"] },
  "filetypes": { "*": "prettier" }
}
JSON

Restart Pi. From now on, any edit the model makes will run through prettier and the model will see the atomic final state (original → formatted), never the intermediate drift.

Disabling temporarily

To verify whether pi-edit-guard is causing a problem in a specific session, set the env var before launching Pi:

# Bypass autofix but keep cascade (lets an external formatter handle drift):
PI_EDIT_GUARD_TRUST_FORMATTER=1 pi

# Capture a debug session (default is OFF — no files written unless opted in):
PI_EDIT_GUARD_DEBUG=1 pi
# Full content + file snapshots for richer triage:
PI_EDIT_GUARD_DEBUG=1 PI_EDIT_GUARD_LOG_FULL=1 PI_EDIT_GUARD_LOG_SNAPSHOTS=1 pi

There's no kill switch that disables the extension entirely — the guard's design assumes it's better to have it on with a noisy error message than to silently let edits fail with Pi's generic "Could not find the exact text" message. If you really want to disable it, pi uninstall @lucascardozo/pi-edit-guard.

What it does

Layer 1 — tool_call (before native edit runs)

For each oldText in the batch:

  1. Counts line-anchored literal occurrences (native edit semantics: match must start at the beginning of a line)
    • 1 unique → pass through
    • 0 → continue to step 2
    • 1 → block with examples

  2. Whitespace-normalized exact match (strip leading spaces per line)
    • 1 unique → auto-fix path (silent, see below) or block if auto-fix can't apply
    • 1 → block with examples

    • 0 → continue to step 3
  3. Char-level Levenshtein per line, averaged across the block
    • 1 candidate above threshold (default 0.90) → block with similarity
    • 1 above threshold → block with examples

    • 0 above threshold → block with best-match hint

Auto-fix (silent success) — when the cascade resolves to unique-drift with a uniform leading-space shift, pi-edit-guard mutates the edit in place:

  • oldText is replaced with the file's verbatim block (the cascade already guarantees this matches).
  • newText is shifted by the same delta, per non-blank line.
  • For the cases auto-fix declines (non-uniform drift, tabs, large delta), the model gets a clear verdict + the file's verbatim block. To opt out of autofix entirely (e.g. when running pi-autoformat alongside), set PI_EDIT_GUARD_TRUST_FORMATTER=1; the guard only validates, then passes newText verbatim to native edit (see Configuration table).

No-op detection — when oldText === newText, the edit would make no change. Pi's native edit rejects this with a misleading "No changes made... special characters or text not existing" message. pi-edit-guard catches it before the cascade and returns a clear, actionable verdict so the model knows it sent a no-op.

  • The model receives a normal edit-success result. No error message, no retry cost.

Auto-fix declines (and falls back to the block path) with a specific reason when:

  • missing-textoldText or newText is empty/missing.
  • line-count-mismatcholdText has different line count than the matched file block.
  • tab-in-oldtextoldText uses tabs (spaces-only file assumption).
  • tab-in-newtext — any line of newText has a leading tab (would write mixed-indent).
  • tab-in-file-block — the matched file block uses tabs.
  • non-uniform-delta — different non-blank lines need different shifts (not a clean shift mistake).
  • zero-deltaoldText already matches (cascade would have returned ok-literal anyway).
  • delta-too-large — defensive cap of ±50 spaces exceeded.

The decline reason attaches to the EditEvaluation and is surfaced as a specific hint in the consolidated report so the model can correct on the next try instead of looping.

Layer 2 — tool_result (when native edit fails atomically)

Re-runs the cascade against the file's current state and mutates the error message in-place to surface the most probable target.

Batch semantics

Both layers iterate over input.edits[]. The atomicity rule: when the cascade resolves to auto-fixable for some edits but unfixable for others, the entire batch is blocked with a consolidated report so the model can fix everything in one pass:

Edit guard: 1 of 2 edits have issues.

Edit 2: Found 6 similar blocks (similarity ≥ 0.90).
First 3 examples:
- Lines 3-3:

});

- Lines 7-7:

});

- Lines 11-11:

});


Re-read the file and provide a more specific oldText that uniquely identifies the target block.

Fix the issues above and re-submit the entire batch. Edits already passing will be re-evaluated with the new file state.

The consolidated report is the model's single source of truth: it knows exactly which edits failed and why, and can fix all of them in one go instead of N trial-and-error rounds.

Mutates events in-place

  • tool_call: mutates event.input.edits[i].oldText and .newText in place when auto-fix applies. The Pi runtime applies mutations across handlers; native edit then runs with the corrected arguments.
  • tool_result: mutates event.content in place so custom renderers (e.g. gentle-pi's quiet-tools) pick up the enriched message. isError is set to false so renderers like quiet-tools collapse long output via their COLLAPSED_TAIL_LINE_LIMIT.

Output format

Single edit: fuzzy-match (character difference)

When auto-fix doesn't apply and the cascade surfaces a fuzzy match:

Error: Edit failed. Your oldText had a small difference from the file.

Lines 12-12. Use this block verbatim as your new oldText:
return 11;
(similarity 0.92)

Single edit: drift recovery (unfixable case)

When auto-fix declines (e.g. the file uses tabs, or the delta is non-uniform), the consolidated report includes the specific decline reason so the model knows what to fix:

Error: Edit failed. Indentation in your oldText didn't match the file.

Lines 12-16. Use these lines verbatim as your new oldText (including leading whitespace):
if (item % 2 === 0) {
  return acc + item * 2;
} else {
  return acc + item;
}
(Hint: autofix declined — tab detected in newText line 3. Replace the tab with spaces to match the file's indent.)

The block is the file's actual lines, byte-exact. The model copies them as-is — no transformation needed. The hint narrows the retry to a specific cause.

Single edit: ambiguous (with examples)

Found 13 similar blocks (literal).
First 3 examples:
- Lines 12-14:

});

- Lines 47-49:

});

- Lines 81-83:

});


Re-read the file and provide a more specific oldText that uniquely identifies the target block.

Single edit: no match (with best similarity + hint)

No sufficiently similar block found.
Best match: similarity 0.62 at line 47 (below threshold 0.90).

Closest block (lines 47-49):
  await triggerOrderStatusChanged(...)

Re-read the file to see its current contents before retrying.

When the best similarity is below the hint minimum (default 0.50), the closest block is omitted to avoid misleading the model.

Auto-format (opt-in, v0.12.0+)

When a formatter is configured for a file, pi-edit-guard skips its own auto-fix layer and lets the external formatter rewrite the file post-edit. The model sees the atomic final state (original → formatted), never the intermediate drift between newText and the formatter's output.

Config file

Create one of these (or both — project overrides global for matching keys):

  • Global: ~/.pi/agent/edit-guard.json (since v0.15.0; was ~/.pi/agent/extensions/pi-edit-guard/config.json)
  • Project: .pi/extensions/pi-edit-guard/config.json

Schema (same as pi-code-formatter):

{
  "commands": {
    "prettier": ["npx", "prettier", "--write"],
    "eslint": ["npx", "eslint", "--fix"]
  },
  "filetypes": {
    "*.ts": "prettier",
    "*.md": "prettier",
    "*.{js,jsx}": "eslint",
    "*": "prettier"
  }
}

Common formatter recipes

Copy-paste configs for the most common stacks. Drop into ~/.pi/agent/edit-guard.json (global) or .pi/extensions/pi-edit-guard/config.json (project overrides global).

Prettier only (TypeScript / JavaScript / CSS / Markdown / HTML):

{
  "commands": {
    "prettier": ["npx", "prettier", "--write", "--ignore-unknown"]
  },
  "filetypes": {
    "*.{ts,tsx,js,jsx,mjs,cjs}": "prettier",
    "*.{css,scss,less}": "prettier",
    "*.{md,mdx}": "prettier",
    "*.{html,json,yaml,yml}": "prettier",
    "*": "prettier"
  }
}

Prettier + ESLint (format + lint-fix on JS/TS, prettier-only elsewhere):

{
  "commands": {
    "prettier": ["npx", "prettier", "--write"],
    "eslint": ["npx", "eslint", "--fix"]
  },
  "filetypes": {
    "*.{ts,tsx,js,jsx}": "eslint",
    "*.{md,css,html,json,yaml,yml}": "prettier",
    "*": "prettier"
  }
}

Biome (modern replacement for ESLint + Prettier in one binary):

{
  "commands": {
    "biome": ["npx", "@biomejs/biome", "format", "--write"]
  },
  "filetypes": {
    "*.{ts,tsx,js,jsx,json}": "biome",
    "*": "biome"
  }
}

Deno fmt (Deno projects):

{
  "commands": {
    "denofmt": ["deno", "fmt", "--quiet"]
  },
  "filetypes": {
    "*.{ts,tsx,js,jsx,json,md}": "denofmt",
    "*": "denofmt"
  }
}

Python (Ruff) — fast linter + formatter:

{
  "commands": {
    "ruff": ["ruff", "format"]
  },
  "filetypes": {
    "*.py": "ruff",
    "*": "ruff"
  }
}

Python (Black):

{
  "commands": {
    "black": ["black", "--quiet"]
  },
  "filetypes": {
    "*.py": "black",
    "*": "black"
  }
}

Go (goimports) — runs gofmt internally plus import sorting::

{
  "commands": {
    "goimports": ["goimports", "-w"]
  },
  "filetypes": {
    "*.go": "goimports"
  }
}

Mixed polyglot project (different tools per file type, wildcard for anything else):

{
  "commands": {
    "prettier": ["npx", "prettier", "--write"],
    "biome": ["npx", "@biomejs/biome", "format", "--write"],
    "ruff": ["ruff", "format"],
    "gofmt": ["gofmt", "-w"]
  },
  "filetypes": {
    "*.{ts,tsx,js,jsx}": "biome",
    "*.py": "ruff",
    "*.go": "gofmt",
    "*": "prettier"
  }
}

Important: every command must accept the file path as its last argument (after --). The extension invokes formatters as <command[0]> <command[1..n]> -- <absolute-file-path> with a 5-second timeout. If your formatter has a different flag convention, adapt the command accordingly — e.g., ["black", "-"] won't work because black expects stdin, not a path argument.

Pattern rules:

  • *.ext — glob, matches files ending in .ext (specific, wins over *)
  • /regex/ — explicit regex, the body between slashes
  • literal — literal suffix match
  • * — wildcard fallback (only matches if no specific pattern matched)

The first matching pattern wins. More-specific patterns are tried before the wildcard. Unknown command names are skipped with a console warning.

Behavior

When a formatter matches the file being edited:

  1. In tool_call: autofix is skipped (matchedFormatter propagates into processEditInput). The guard still validates that there IS a match — ambiguous/fuzzy/no-match still block with the consolidated report. The model's newText passes through verbatim.
  2. In tool_result (success only): the formatter runs via pi.exec (5-second timeout, -- separator + absolute path). If the formatter changes the file, details.{patch, diff, firstChangedLine} are rewritten so the model sees original → formatted. If the formatter fails, exits non-zero, or makes no change, the original details are kept (best-effort, never throws).

The result the model sees is the atomic final state — never the drift intermediate. This closes the "surrender" loop where the model re-reads the file to "fix" indentation it never actually broke.

Backward compatibility

If no config file exists, behavior is identical to v0.11.0: autofix runs for unique drift, blocks for everything else. The formatter integration is purely opt-in.

The pre-existing PI_EDIT_GUARD_TRUST_FORMATTER=1 flag (and --trust-formatter CLI flag) still works for projects that prefer an external formatter without the in-process rewrite — it's now redundant when a config file is present, but kept for users who already rely on it.

Debug logging (production triage)

The debug logger is off by default — no files are written to /tmp unless you opt in. To capture a session for triage, set the env vars before launching Pi:

PI_EDIT_GUARD_DEBUG=1 \                      # enable the NDJSON log (sha + length + 200-char preview)
PI_EDIT_GUARD_LOG_FULL=1 \                   # log full oldText/newText content
PI_EDIT_GUARD_LOG_SNAPSHOTS=1 \              # save verbatim file snapshots
pi

When enabled, the log goes to /tmp/pi-edit-guard-<pid>.log with one NDJSON line per cascade invocation. With LOG_SNAPSHOTS=1, snapshots go to /tmp/pi-edit-guard-<pid>/snapshots/<sha>.orig.

Fields per log entry:

Field Meaning
source 'tool_call' | 'tool_result' — which hook fired. Pair the two to see what we intercepted vs what native returned.
path File path the edit targets.
fileBytes / fileSha / filePreview Length, sha256 (12 hex), and the file content (full when LOG_FULL is on; first 200 chars + [+N chars] when redacted).
fileLeadingNewlines / fileTrailingNewlines Helpful to spot BOM or trailing-newline mismatches.
edits[i].oldTextBytes / oldTextSha / oldTextPreview / oldTextLeadingSpaces What the model sent (full content by default; redacted when LOG_FULL=0).
edits[i].newTextBytes / newTextSha / newTextPreview / newTextLeadingSpaces What the model wants to write.
edits[i].evaluationKind ok-literal, unique-drift, fuzzy-match, ambiguous-*, no-match.
edits[i].autofixOutcome ok (with autofixDelta) | declined (with declineReason) | n/a.
result autofixed | blocked (with blockReasonBytes) | pass | pass-oversized | pass-unreadable | formatter-rewritten | formatter-noop | formatter-failed.
formatterMatched True when a formatter was configured and matched this file's path. Orthogonal to result — when present with result: 'autofixed', the autofix ran in trust mode (corrected oldText only, left newText verbatim for the formatter).
autofixedCount Number of edits silently corrected (only present when result: 'autofixed').
snapshotPath Absolute path to the saved file snapshot (always present by default; absent when LOG_SNAPSHOTS=0).
nativeError What the native edit tool returned (only on tool_result events with isError). Shows what the model actually saw.
formatterCommand The resolved formatter command (only on tool_result formatter events).
formatterApplied true when the formatter actually changed the file content.
formatterReason Brief reason when formatter was skipped, failed, or no-op (exit-code-N, no-change, exec-threw).
formatterStderr Truncated stderr (only when formatter exited non-zero).
formatterDurationMs Wall-clock duration of the formatter run.

The log rotates at 5 MB. Snapshots are capped at 200 files / 100MB total (oldest by mtime get pruned).

Custom log path

PI_EDIT_GUARD_LOG_PATH=./edit-guard.log pi

Snapshots go to ./edit-guard/snapshots/ (i.e. <dirname(log-path)>/<basename-without-ext>/snapshots/). The grouping is per log file so that all artifacts from one session live together — rm -rf /tmp/pi-edit-guard-* is enough to clean up both the log and snapshots.

Triage workflow

  1. Reproduce the issue with debug logging enabled: PI_EDIT_GUARD_DEBUG=1 pi (or +LOG_FULL=1+LOG_SNAPSHOTS=1 for richer triage).
  2. cat <log-path> | jq . (or use any NDJSON viewer).
  3. Filter by source to see what the extension intercepted (tool_call) vs what came back from native (tool_result). On tool_result with nativeError, you'll see exactly what the model saw.
  4. If snapshotPath is set, cat <snapshotPath> shows the file at edit time.
  5. Compare oldTextLeadingSpaces vs the snapshot's leading whitespace per line — this is the smoking gun for any "I copied verbatim but it doesn't match" mystery.

Configuration

Env var Default Opt-out Effect
PI_EDIT_GUARD_THRESHOLD 0.90 n/a Similarity threshold for fuzzy matches (Level 3). Lower = more permissive.
PI_EDIT_GUARD_EXAMPLES 3 n/a Max number of example blocks shown for ambiguous cases.
PI_EDIT_GUARD_HINT_MIN 0.50 n/a Min similarity to show the closest block as hint in no-match messages.
PI_EDIT_GUARD_DEBUG OFF =1 NDJSON debug log written per cascade invocation. Off by default — no /tmp files unless opted in.
PI_EDIT_GUARD_LOG_PATH /tmp/pi-edit-guard-<pid>.log n/a Where the NDJSON log goes when enabled. Snapshots go to <dirname>/<basename-without-ext>/snapshots/ (e.g. /tmp/pi-edit-guard-<pid>/snapshots/). Only consulted when PI_EDIT_GUARD_DEBUG=1.
PI_EDIT_GUARD_LOG_SNAPSHOTS OFF =1 Save a verbatim copy of the file at edit time to <log-dir>/snapshots/<sha>.orig (e.g. /tmp/pi-edit-guard-<pid>/snapshots/<sha>.orig). Dedupe by sha, capped at 200 files / 100MB. Off by default — no snapshot directory is created unless opted in.
PI_EDIT_GUARD_TRUST_FORMATTER unset =0 Opt-in trust mode: skip autofix, pass newText verbatim to native edit. Designed for projects that run an external formatter (pi-autoformat, biome, prettier, etc.) alongside this extension. The cascade still validates; ambiguous/fuzzy/no-match still block. Also registered as --trust-formatter CLI flag for discoverability. Redundant when an auto-format config file is present (v0.12.0+); the config file is the preferred opt-in path.

All three log flags default to OFF — no /tmp files are created unless you opt in. Set the env var to 1, true, or yes to enable that flag. 0 / false / no is also accepted (no-op with the new defaults, but explicit).

Set before launching Pi:

PI_EDIT_GUARD_THRESHOLD=0.85 pi
PI_EDIT_GUARD_EXAMPLES=5 PI_EDIT_GUARD_HINT_MIN=0.6 pi
PI_EDIT_GUARD_DEBUG=1 pi   # capture session for triage

What it does NOT do

  • It does not silently fix content differences, only indentation drift with a uniform leading-spaces shift.
  • It does not suppress errors for tabs or non-uniform drift — those fall through to the existing block+report path, now with a specific decline hint.
  • It does not change the bash, write, read, or any other tool — only edit.

Troubleshooting

"I configured prettier but the model still gets drift"

pi-edit-guard v0.14.0+ probes each configured formatter at session start and filters out binaries that are not on PATH. If the formatter binary is missing, you'll see a one-time warning per session:

[pi-edit-guard] "prettier" no está en PATH; usando autofix propio

The cascade then runs in default mode (autofix applies the leading-space delta to both oldText and newText) instead of leaving the file with raw newText drift. This is the same behavior as when no formatter is configured at all. Install the missing binary or set PATH so the extension can find it, then /reload the session.

Under the hood: binaryAvailable(bin) runs which <bin> on POSIX or where <bin> on Windows, with the bin name as a single argv entry (no shell, no injection vector). Any probe error (sandbox block, probe binary missing) is conservative — it returns false and the formatter is treated as not available.

Testing

A test suite is included in tests/. It is split by module so each test runs independently and is fast:

tests/
├── _framework.ts              # dependency-free assertion library
├── run.ts                     # test runner (imports all test modules)
├── autofix.test.ts            # src/autofix.ts (delta + shift logic + decline reasons)
├── whitespace.test.ts         # src/whitespace.ts
├── block.test.ts              # src/block.ts
├── debug.test.ts              # src/debug.ts (NDJSON logger)
├── matchers/
│   ├── literal.test.ts        # src/matchers/literal.ts
│   ├── normalized.test.ts     # src/matchers/normalized.ts
│   └── fuzzy.test.ts          # src/matchers/fuzzy.ts
├── evaluate.test.ts           # src/evaluate.ts
├── format.test.ts             # src/format/* (all output formats)
├── format/
│   ├── runner.test.ts         # src/format/runner.ts (fake pi.exec)
│   └── tool-result-rewriter.test.ts  # src/format/tool-result-rewriter.ts
└── extension.test.ts          # src/extension.ts (e2e via jiti, formatter flow)

Coverage:

  • Unit tests for every pure module (whitespace, block, autofix, each matcher, evaluate, format, debug)
  • E2E test that loads the extension via jiti (same loader Pi uses), registers hooks, and fires events with real files
  • Regression cases from v0.5.0/v0.6.0 (drift, fuzzy, ok, ambiguous)
  • Autofix happy path (uniform shift) + decline paths (tabs, non-uniform, MAX_SANE_DELTA, line-count, missing)
  • Decline-reason assertions per AutofixDeclineReason variant
  • Batch semantics: atomic block when one edit can't be auto-fixed
  • CRLF normalization, edge cases, max-examples limit

Run with:

pnpm test          # one-shot
pnpm test:watch    # watch mode
pnpm run typecheck

(uses Node 22+ --experimental-strip-types, no build step required)

Project structure

pi-edit-guard/
├── index.ts                 # entry point (re-export from src/extension.ts)
├── src/
│   ├── extension.ts         # composition root: default export with tool_call/tool_result hooks
│   ├── evaluate.ts          # evaluateEdit, evaluateBatch (pure cascade)
│   ├── autofix.ts           # tryAutofix — pure leading-spaces delta + shift logic + decline reasons
│   ├── mutate.ts            # in-place mutation of tool result events
│   ├── config.ts            # env var readers and constants
│   ├── debug.ts             # opt-in NDJSON debug logger
│   ├── types.ts             # shared EditEvaluation and CandidateKind types
│   ├── block.ts             # BlockExcerpt type and toBlockExcerpt adapter
│   ├── whitespace.ts        # stripLeadingWhitespace, normalizeText (spaces-only)
│   ├── formatter-config.ts  # auto-format config: loadConfig, resolveFormatters, findFormatter
│   ├── format/
│   │   ├── index.ts         # barrel re-export
│   │   ├── candidate.ts     # formatCandidate: fuzzy-match + unfixable drift
│   │   ├── ambiguous.ts     # formatAmbiguousMessage + formatExamples
│   │   ├── consolidated.ts  # formatConsolidatedReport (atomic block output)
│   │   └── no-match.ts      # formatNoMatchMessage (best-similarity hint)
│   │   ├── runner.ts        # runFormatter via pi.exec (formatter subprocess)
│   │   └── tool-result-rewriter.ts  # generateRewriteResult: original → formatted patch/diff
│   └── matchers/
│       ├── index.ts         # barrel re-export
│       ├── literal.ts       # countLineAnchoredMatches, findLineAnchoredMatches
│       ├── normalized.ts    # findNormalizedMatches
│       └── fuzzy.ts         # findFuzzyMatches, lineCharSimilarity, levenshteinDistance
├── tests/                   # (see Testing section)
├── README.md
├── LICENSE
└── package.json

Each module has one responsibility. To add a new matcher (e.g. AST-based), create a file in src/matchers/ and add it to the barrel. To add a new output format, add a function under src/format/. The cascade in src/evaluate.ts is the only place that knows about the order of matchers.

Compatibility

  • Pi: 0.84+
  • Node: 22+ (uses native --experimental-strip-types for tests, node:fs/promises for runtime)
  • TypeScript: source-only (Pi loads via jiti, no build step required)

Tested alongside

  • gentle-pi v2.1.2 with quiet-tools.ts enabled — works correctly, custom renderer respects the mutation

Credits

The auto-format integration (v0.12.0+) is adapted from pi-code-formatter by losnappas, under the MIT License. The pattern compilation, config schema, tool_result rewriting, and pi.exec formatter runner pattern were ported from that extension. See the header comments in src/formatter-config.ts and src/format/tool-result-rewriter.ts for attribution.

License

MIT