pi-tian-edit-safe 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,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TianZuo555
4
+
5
+ Algorithmic lineage (not code) adapted from opencode (MIT, Copyright (c) 2025 opencode),
6
+ which itself draws from cline diff-apply (MIT) and gemini-cli editCorrector (Apache-2.0).
7
+ See NOTICES.md for attribution. This project contains no code from maka-agent
8
+ (which carries no license); only the design philosophy (strict ambiguity handling,
9
+ full-span verification) is reproduced from scratch.
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
package/NOTICES.md ADDED
@@ -0,0 +1,34 @@
1
+ # Dependency attribution. none required at runtime beyond what pi bundles,
2
+ # but recorded for completeness.
3
+
4
+ ## opencode
5
+ - Source: https://github.com/sst/opencode (`packages/opencode/src/tool/edit.ts`)
6
+ - License: MIT, Copyright (c) 2025 opencode
7
+ - Used for: algorithmic reference for the line-trimmed, whitespace-normalized,
8
+ escape-normalized fuzzy strategies and the `isDisproportionateMatch` heuristic.
9
+ Re-implemented from scratch; no code copied verbatim.
10
+
11
+ ## pi-coding-agent
12
+ - Source: https://github.com/earendil-works/pi (`core/tools/edit-diff.ts`)
13
+ - License: MIT
14
+ - Used for: the unicode punctuation normalization table (smart quotes, dash
15
+ variants, special spaces, NFKC) behind the `unicode` fuzzy strategy. Applied
16
+ span-only here (comparison never rewrites file bytes), unlike upstream's
17
+ whole-content normalize-and-restore pipeline.
18
+
19
+ ## cline
20
+ - Source: https://github.com/cline/cline (evals/diff-edits/diff-apply)
21
+ - License: MIT
22
+ - Used for: original upstream diff-apply concepts (via opencode).
23
+
24
+ ## gemini-cli
25
+ - Source: https://github.com/google-gemini/gemini-cli (editCorrector)
26
+ - License: Apache-2.0
27
+ - Used for: original upstream edit-correction concepts (via opencode).
28
+
29
+ ## maka-agent
30
+ - Source: https://github.com/Maka-Agent/maka-agent
31
+ - License: NONE (all rights reserved) — NOT used as a code source.
32
+ - Used for: design philosophy only (strict ambiguity handling, full-span
33
+ verification, hard gates before fuzzy matching, throw-on-ambiguity
34
+ orchestration). Reproduced from scratch without referencing its source code.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # pi-edit-safe
2
+
3
+ A drop-in replacement for the **pi coding agent**'s built-in `edit` tool, with a
4
+ stricter matcher that refuses to silently edit the wrong place — and a call shape
5
+ that weaker models can actually use.
6
+
7
+ > Exposes **one** call shape — `{path, edits: [...]}`, always an array, even for a
8
+ > single replacement — so the model has nothing to choose per call. Multi-edit
9
+ > applies **in order** (sequential), the contract models assume from multi-edit
10
+ > tools elsewhere, instead of pi's all-against-the-original-file semantic.
11
+ >
12
+ > The looser shapes models emit anyway are still folded onto that form before
13
+ > validation, so a stray legacy call is normalized rather than rejected — they
14
+ > are just not advertised.
15
+
16
+ Install: `npm:pi-tian-edit-safe` · npm package `pi-tian-edit-safe` · workspace `packages/pi-edit-safe`
17
+
18
+ ## Call shape
19
+
20
+ One shape, always an array:
21
+
22
+ ```jsonc
23
+ // single change — still a one-element array:
24
+ { "path": "src/a.ts", "edits": [
25
+ { "oldText": "const x = 1;", "newText": "const x = 2;" }
26
+ ] }
27
+
28
+ // several changes, applied in order, top to bottom:
29
+ { "path": "src/a.ts", "edits": [
30
+ { "oldText": "...", "newText": "..." },
31
+ { "oldText": "...", "newText": "..." }
32
+ ] }
33
+ ```
34
+
35
+ `path` and `edits` are both required and `edits` has `minItems: 1`. Nothing else
36
+ is advertised to the model.
37
+
38
+ ### Tolerated but not advertised
39
+
40
+ `prepareArguments` runs **before** schema validation (per pi's extension
41
+ contract) and folds these onto the canonical form: `file_path`/`filePath`/`filename`
42
+ for `path`; `old_string`/`new_string`, `oldString`/`newString`, `old_str`/`new_str`
43
+ for the pair (per entry or top-level); top-level `oldText`/`newText` shorthand;
44
+ `edits` as a JSON **string**; and `edits` as a single object.
45
+
46
+ This keeps old resumed sessions and off-contract calls working without widening
47
+ the public schema. Exact duplicate entries are rejected loudly. When a later edit
48
+ fails because an earlier edit in the same call rewrote its target, the error says
49
+ exactly that.
50
+
51
+ ## Design stance
52
+
53
+ | Decision | typical fuzzy edit tools | **pi-edit-safe** |
54
+ |---|---|---|
55
+ | Partial-signal strategies (first/last-line anchor) | yes | **none** |
56
+ | On ambiguity | fall through to next candidate | **throw immediately** |
57
+ | Fuzzy candidate validation | per-candidate similarity threshold | **full-span structural equality + exactly-one + occurs-once** |
58
+ | Overlapping exact matches | often missed | **caught** (overlap-aware counting) |
59
+ | Unicode punctuation drift (smart quotes, em dash, NBSP) | varies | **strict `unicode` strategy**, span-only |
60
+ | Untouched bytes | may be normalized whole-file | **never touched** (slice + verbatim splice) |
61
+ | Line endings | whole-file normalize/restore | **span-boundary only**; mixed-ending files never flattened |
62
+ | `newText` write | `String.replace` (`$&` hazard) | **slice-join** (literal) |
63
+ | Multi-edit semantics | all edits vs the original file | **sequential, in order** |
64
+ | Call shapes offered to the model | varies | **exactly one** (`edits[]`) |
65
+
66
+ Fuzzy matching only ever *locates a span*. The replacement is spliced in
67
+ verbatim — it is never re-indented or rewritten.
68
+
69
+ ## Matching order
70
+
71
+ 1. **Exact**, never gated, counted overlap-aware (`aa` in `aaa` is ambiguous, not unique).
72
+ 2. If exact fails, hard gates: ≥5 non-space chars, no NUL byte, ≤1M chars, ≤50k lines.
73
+ 3. Increasingly tolerant **full-span** strategies: line-trimmed → unicode
74
+ punctuation → collapsed whitespace → escape-normalized.
75
+ 4. More than one candidate for a strategy → **throw**. Never falls through from
76
+ ambiguity to a looser matcher.
77
+ 5. Splice verbatim. Uniform LF/CRLF files adapt the replacement's newlines;
78
+ mixed-ending files take it as-is.
79
+ 6. Apply all entries in memory, in order, then write once — a later failure
80
+ leaves the file unchanged.
81
+
82
+ ## Result details
83
+
84
+ Returns pi's built-in `EditToolDetails` shape (`diff`, `patch`,
85
+ `firstChangedLine`) alongside a `edits[]` array of per-edit provenance
86
+ (`matchedVia`, `startLine`, `endLine`).
87
+
88
+ Because pi resolves renderer inheritance **per slot**
89
+ (`toolDefinition.renderCall ?? builtInToolDefinition.renderCall`), this override
90
+ deliberately defines neither `renderCall` nor `renderResult`, and so inherits
91
+ pi's streaming diff preview and final diff rendering for free.
92
+
93
+ The diff is computed against the **real bytes** on both sides. pi's built-in
94
+ diffs its LF-normalized view instead, which is why the two disagree on
95
+ mixed-line-ending files (see bench case 11).
96
+
97
+ ## Disable
98
+
99
+ ```bash
100
+ PI_EDIT_SAFE_DISABLE=1 pi # falls back to the built-in edit
101
+ ```
102
+
103
+ ## Tests and A/B bench
104
+
105
+ ```bash
106
+ npm test -w pi-tian-edit-safe # 45 unit tests (Node's built-in runner)
107
+ npm run bench -w pi-tian-edit-safe
108
+ ```
109
+
110
+ The bench imports pi's **actual shipped** `edit-diff.js` functions and composes
111
+ them exactly as `dist/core/tools/edit.js` does, then runs the same 12
112
+ (file, edits) cases through both pipelines and reports every divergence,
113
+ including "canary" lines that must stay byte-identical.
114
+
115
+ These are deterministic corruption regressions. They are **not** a model-level
116
+ eval — no cross-model pass@1 / token / retry benchmark exists for this tool yet.
117
+
118
+ ## Attribution
119
+
120
+ Fuzzy-strategy concepts adapted from opencode (MIT, via cline MIT and
121
+ gemini-cli Apache-2.0); the unicode normalization table mirrors pi's own
122
+ normalizer (MIT), applied span-only. Design philosophy reproduced
123
+ independently. See [NOTICES.md](./NOTICES.md).
package/index.ts ADDED
@@ -0,0 +1,134 @@
1
+ // pi extension: override the built-in `edit` tool with a stricter, full-span
2
+ // matcher. The model-facing contract is ONE call shape — {path, edits: [...]},
3
+ // always an array, even for a single replacement — so there is no per-call
4
+ // choice to get wrong. Multi-edit applies IN ORDER (sequential), the contract
5
+ // models assume from multi-edit tools elsewhere.
6
+ //
7
+ // Looser shapes models emit anyway (top-level oldText/newText, alias field
8
+ // names, stringified arrays, a bare edit object) are folded onto the canonical
9
+ // form by prepareArguments BEFORE validation. They are accepted for robustness
10
+ // and older resumed sessions, but deliberately NOT advertised in the schema.
11
+ //
12
+ // Quick try: pi -e ./packages/pi-edit-safe
13
+ // Disable: PI_EDIT_SAFE_DISABLE=1 pi (falls back to the built-in edit)
14
+ //
15
+ // Result shape note: pi resolves built-in renderer inheritance per slot
16
+ // (`toolDefinition.renderCall ?? builtInToolDefinition.renderCall`), so this
17
+ // override deliberately defines NEITHER renderCall nor renderResult and instead
18
+ // returns the built-in `EditToolDetails` shape ({ diff, patch, firstChangedLine }).
19
+ // That inherits pi's streaming diff preview and final diff rendering for free.
20
+
21
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import {
23
+ generateDiffString,
24
+ generateUnifiedPatch,
25
+ withFileMutationQueue,
26
+ } from "@earendil-works/pi-coding-agent";
27
+ import { Type, type Static } from "typebox";
28
+ import { readFile, writeFile } from "node:fs/promises";
29
+ import { resolve } from "node:path";
30
+ import { applyEdits } from "./lib/edit-replace.ts";
31
+ import { prepareEditArguments } from "./lib/prepare-arguments.ts";
32
+
33
+ const parameters = Type.Object({
34
+ path: Type.String({
35
+ description: "Path to the file to edit (relative or absolute).",
36
+ }),
37
+ edits: Type.Array(
38
+ Type.Object({
39
+ oldText: Type.String({
40
+ description:
41
+ "Exact text to find. Must be unique in the file at the time this edit applies.",
42
+ }),
43
+ newText: Type.String({
44
+ description: "Replacement text, written verbatim.",
45
+ }),
46
+ }),
47
+ {
48
+ minItems: 1,
49
+ description:
50
+ "One or more replacements, applied in order, first to last. Use a single-element array for a single change. Each oldText is matched against the file as already changed by the previous edits in this call.",
51
+ },
52
+ ),
53
+ });
54
+
55
+ export default function editSafeExtension(pi: ExtensionAPI): void {
56
+ // Kill switch: if set, do not register and let the built-in edit remain active.
57
+ if (process.env.PI_EDIT_SAFE_DISABLE === "1") return;
58
+
59
+ pi.registerTool({
60
+ name: "edit", // same name as the built-in → overrides it
61
+ label: "edit (strict)",
62
+ description:
63
+ "Replace text in a file. Pass path and edits: [{oldText, newText}, ...] — always an array, even for a single change. Edits apply in order, one after another, each matched against the already-updated text. Each oldText must match the file and be unique; small whitespace/indentation, unicode-punctuation, or escape drift is tolerated only when the match is unambiguous. newText is written verbatim. On failure the error explains why so you can re-read the file and retry.",
64
+ parameters,
65
+ // Fold the looser shapes models emit (alias keys, stringified arrays, a bare
66
+ // edit object, top-level oldText/newText) onto the canonical {path, edits[]}
67
+ // form BEFORE schema validation. The public schema stays strict: `edits` is
68
+ // the only advertised way to call this tool, so the model has one shape to
69
+ // learn and no choice to make. Normalization exists only so a stray legacy
70
+ // shape — including tool calls stored in older resumed sessions — still
71
+ // works instead of hard-failing validation.
72
+ prepareArguments: (args: unknown) => prepareEditArguments(args) as Static<typeof parameters>,
73
+ promptGuidelines: [
74
+ "Prefer `edit` for targeted changes and `write` only for new files or full rewrites.",
75
+ "Always pass `edits` as an array — use a one-element array for a single change. Edits apply in order, top to bottom.",
76
+ "Each oldText must be unique in the file; include surrounding lines to disambiguate when needed.",
77
+ "On an edit failure, re-read the file before retrying — the error usually means your context was stale or the match was ambiguous.",
78
+ ],
79
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
80
+ // Re-normalize defensively: the prepareArguments hook already ran on
81
+ // current pi versions, and normalization is idempotent.
82
+ const { path, edits } = prepareEditArguments(params);
83
+ if (!path) {
84
+ throw new Error(`edit: missing "path" — pass the file to edit`);
85
+ }
86
+ const abs = resolve(ctx.cwd, path);
87
+
88
+ // Read, match, and write all inside the mutation queue so no other
89
+ // pi-side mutation can interleave between our read and our write.
90
+ return withFileMutationQueue(abs, async () => {
91
+ const throwIfAborted = () => {
92
+ if (signal?.aborted) throw new Error("Operation aborted");
93
+ };
94
+ throwIfAborted();
95
+
96
+ let source: string;
97
+ try {
98
+ source = await readFile(abs, "utf-8");
99
+ } catch (err) {
100
+ throw new Error(
101
+ `edit: cannot read "${path}" (${(err as NodeJS.ErrnoException).message}). Use the write tool to create a new file.`,
102
+ );
103
+ }
104
+ throwIfAborted();
105
+
106
+ const { content, edits: outcomes } = applyEdits(source, edits, path);
107
+ await writeFile(abs, content, "utf-8");
108
+
109
+ const summary = outcomes
110
+ .map((o, i) => ` ${i + 1}. ${o.matchedVia} match → lines ${o.startLine}-${o.endLine}`)
111
+ .join("\n");
112
+
113
+ // Diff against the real bytes on both sides. This matcher never
114
+ // normalizes the file, so `source` IS the base content — unlike
115
+ // pi's built-in, which diffs its LF-normalized view.
116
+ const { diff, firstChangedLine } = generateDiffString(source, content);
117
+ const patch = generateUnifiedPatch(path, source, content);
118
+
119
+ return {
120
+ content: [
121
+ {
122
+ type: "text",
123
+ text: `Edited ${path} (${outcomes.length} replacement${outcomes.length > 1 ? "s, applied in order" : ""}):\n${summary}`,
124
+ },
125
+ ],
126
+ // Built-in EditToolDetails shape (diff/patch/firstChangedLine) so
127
+ // pi's inherited renderer and SDK/ACP consumers keep working;
128
+ // `edits` carries the extra strict-matcher provenance.
129
+ details: { path: abs, diff, patch, firstChangedLine, edits: outcomes },
130
+ };
131
+ });
132
+ },
133
+ });
134
+ }
@@ -0,0 +1,475 @@
1
+ // edit-replace.ts — strict, fault-tolerant multi-edit string replacement.
2
+ //
3
+ // Algorithmic concepts (line-trimmed / whitespace-normalized / escape-normalized
4
+ // fuzzy strategies, isDisproportionate heuristic) adapted from opencode's MIT
5
+ // edit.ts, which traces to cline (MIT) and gemini-cli (Apache-2.0). The unicode
6
+ // punctuation mapping mirrors pi's own fuzzy normalizer (MIT), applied here
7
+ // span-only. Re-implemented from scratch. See NOTICES.md.
8
+ //
9
+ // DESIGN PHILOSOPHY (reproduced independently from the maka-agent project, which
10
+ // is unlicensed and was NOT used as a code source):
11
+ // 1. Exact match first, no gating. Large files edit fine with an exact snippet.
12
+ // 2. Fuzzy matching is gated (min length, binary, size) and ONLY locates a span;
13
+ // the replacement is written VERBATIM. Fuzzy never re-indents or rewrites.
14
+ // 3. Every fuzzy candidate must be a FULL-span structural match, must be the
15
+ // single candidate, and must occur exactly once. Any ambiguity THROWS rather
16
+ // than falling through to a looser strategy. "Better to fail loudly than to
17
+ // silently edit the wrong place."
18
+ // 4. No partial-signal strategies (no first/last-line anchoring, no similarity
19
+ // thresholds). Those are the documented cause of wrong-location edits in
20
+ // upstream projects.
21
+ // 5. Line endings are handled at the span boundary, never whole-file: a fuzzy
22
+ // span never captures the trailing \r of a CRLF pair, newText is converted
23
+ // only when the file's endings are uniform, and untouched terminators are
24
+ // never rewritten (mixed files stay mixed).
25
+ // 6. Multi-edit calls apply SEQUENTIALLY, in order — each oldText is matched
26
+ // against the result of the previous edits, the contract models assume
27
+ // from multi-edit tools elsewhere (pi's built-in instead matches all edits
28
+ // against the original file). Disjoint edits behave identically under both;
29
+ // dependent edits work here, duplicates are rejected loudly, and failures
30
+ // caused by an earlier edit in the same call say so.
31
+
32
+ export type MatchStrategy = "exact" | "line-trimmed" | "unicode" | "whitespace" | "escape";
33
+
34
+ export interface EditRequest {
35
+ oldText: string;
36
+ newText: string;
37
+ }
38
+
39
+ export interface EditOutcome {
40
+ /** Which strategy located oldText. */
41
+ matchedVia: MatchStrategy;
42
+ /** 1-based first line of the matched span, in the content the edit was
43
+ * applied to (the original file for edit 1; for later edits, the text as
44
+ * already changed by the previous edits in the call). */
45
+ startLine: number;
46
+ /** 1-based last line (inclusive) of the matched span, same reference. */
47
+ endLine: number;
48
+ }
49
+
50
+ /** Machine-readable failure codes for every error this module throws. */
51
+ export type EditErrorCode =
52
+ | "no-edits"
53
+ | "invalid-edit"
54
+ | "duplicate-edit"
55
+ | "empty-old-text"
56
+ | "no-op-edit"
57
+ | "too-short"
58
+ | "binary"
59
+ | "too-large"
60
+ | "not-unique"
61
+ | "ambiguous"
62
+ | "disproportionate"
63
+ | "not-found"
64
+ | "no-changes";
65
+
66
+ /** Every failure this module throws, tagged with a machine-readable code.
67
+ *
68
+ * `code` is assigned in the constructor body rather than declared as a
69
+ * TypeScript parameter property: parameter properties emit real code, so they
70
+ * are rejected by Node's type-stripping loader (and every other strip-only
71
+ * transform). This package ships uncompiled TypeScript, so it must stay within
72
+ * erasable-syntax-only constructs. */
73
+ export class EditError extends Error {
74
+ readonly code: EditErrorCode;
75
+
76
+ constructor(message: string, code: EditErrorCode) {
77
+ super(message);
78
+ this.name = "EditError";
79
+ this.code = code;
80
+ }
81
+ }
82
+
83
+ export interface ApplyResult {
84
+ content: string;
85
+ edits: EditOutcome[];
86
+ }
87
+
88
+ // Fuzzy is O(n) per pass over the whole source; gate it to text-sized inputs.
89
+ const MIN_FUZZY_OLD_TEXT_LENGTH = 5;
90
+ const MAX_FUZZY_SOURCE_CHARS = 1_000_000;
91
+ const MAX_FUZZY_SOURCE_LINES = 50_000;
92
+
93
+ /**
94
+ * Apply one or more edits to `source`, SEQUENTIALLY: each edit's `oldText` is
95
+ * matched against the content as already changed by the previous edits in the
96
+ * same call — the contract models assume from multi-edit tools elsewhere.
97
+ * Disjoint edits produce exactly the same bytes as matching everything against
98
+ * the original file; dependent edits (a later oldText targeting an earlier
99
+ * newText's output) work instead of erroring.
100
+ *
101
+ * @throws when any oldText is missing, ambiguous (not unique), empty, identical
102
+ * to its newText, an exact duplicate of an earlier edit, too short for a safe
103
+ * fuzzy match, on a binary/too-large file, or when the result is identical to
104
+ * the source.
105
+ */
106
+ export function applyEdits(source: string, edits: EditRequest[], where: string): ApplyResult {
107
+ if (!Array.isArray(edits) || edits.length === 0) {
108
+ throw new EditError(
109
+ `edit: no edits provided for ${where} — pass oldText/newText for a single replacement, or edits: [{oldText, newText}, ...] for several`,
110
+ "no-edits",
111
+ );
112
+ }
113
+
114
+ // Validate shape and reject exact duplicates up front. Models sometimes emit
115
+ // the same edit twice; under sequential application a duplicate could even
116
+ // apply twice (when newText still contains oldText), so fail loudly instead.
117
+ const seen = new Set<string>();
118
+ for (let i = 0; i < edits.length; i++) {
119
+ const e = edits[i];
120
+ if (!e || typeof e.oldText !== "string" || typeof e.newText !== "string") {
121
+ throw new EditError(
122
+ `edit ${i + 1}: each edit needs string oldText and newText fields in ${where}`,
123
+ "invalid-edit",
124
+ );
125
+ }
126
+ const key = JSON.stringify([e.oldText, e.newText]);
127
+ if (seen.has(key)) {
128
+ throw new EditError(
129
+ `edit ${i + 1} is an exact duplicate of an earlier edit in ${where}; remove it (each replacement is applied once)`,
130
+ "duplicate-edit",
131
+ );
132
+ }
133
+ seen.add(key);
134
+ }
135
+
136
+ let content = source;
137
+ const outcomes: EditOutcome[] = new Array(edits.length);
138
+ for (let i = 0; i < edits.length; i++) {
139
+ const { oldText, newText } = edits[i];
140
+ const editWhere = edits.length === 1 ? where : `${where} (edit ${i + 1} of ${edits.length})`;
141
+ if (oldText === "") {
142
+ throw new EditError(`edit ${i + 1}: oldText must not be empty in ${where}`, "empty-old-text");
143
+ }
144
+ if (oldText === newText) {
145
+ throw new EditError(`edit ${i + 1}: no changes to apply in ${where} (oldText === newText)`, "no-op-edit");
146
+ }
147
+
148
+ let span: { start: number; end: number; via: MatchStrategy };
149
+ try {
150
+ span = resolveSpan(content, oldText, editWhere);
151
+ } catch (err) {
152
+ throw withSequentialHint(err, source, oldText, i);
153
+ }
154
+
155
+ outcomes[i] = {
156
+ matchedVia: span.via,
157
+ startLine: lineOf(content, span.start),
158
+ endLine: lineOf(content, span.end - 1),
159
+ };
160
+ content = content.slice(0, span.start) + toFileLineEndings(newText, content) + content.slice(span.end);
161
+ }
162
+
163
+ // A fuzzy span can equal newText even when oldText !== newText (drifted
164
+ // oldText, file already in the desired state). A silent no-op write would
165
+ // hide the model's stale context — fail loudly instead.
166
+ if (content === source) {
167
+ throw new EditError(
168
+ `edit: no changes were produced in ${where} — the file already matches the requested result; re-read it before retrying`,
169
+ "no-changes",
170
+ );
171
+ }
172
+
173
+ return { content, edits: outcomes };
174
+ }
175
+
176
+ /** When a later edit fails because an EARLIER edit in the same call rewrote its
177
+ * target (or introduced new matches), say so — a bare "not found" would send the
178
+ * model re-reading a file that never contained the problem. */
179
+ function withSequentialHint(err: unknown, source: string, oldText: string, index: number): unknown {
180
+ if (index === 0 || !(err instanceof EditError)) return err;
181
+ if (err.code === "not-found" && countOccurrences(source, oldText) > 0) {
182
+ return new EditError(
183
+ `${err.message}; note: edits apply in order, and an earlier edit in this call already changed this text — write oldText against the updated content, or merge the edits into one`,
184
+ err.code,
185
+ );
186
+ }
187
+ if ((err.code === "not-unique" || err.code === "ambiguous") && countOccurrences(source, oldText) <= 1) {
188
+ return new EditError(
189
+ `${err.message}; note: edits apply in order, and an earlier edit's newText introduced additional matches — merge or reorder the edits`,
190
+ err.code,
191
+ );
192
+ }
193
+ return err;
194
+ }
195
+
196
+ /** Resolve a single oldText to a unique [start, end) span, exact then guarded fuzzy. */
197
+ function resolveSpan(source: string, oldText: string, where: string): { start: number; end: number; via: MatchStrategy } {
198
+ // Exact match first — never gated. Occurrences are counted overlap-aware:
199
+ // "aa" in "aaa" is ambiguous (positions 0 and 1), not a unique match.
200
+ const exactCount = countOccurrences(source, oldText);
201
+ if (exactCount === 1) {
202
+ const idx = source.indexOf(oldText);
203
+ return { start: idx, end: idx + oldText.length, via: "exact" };
204
+ }
205
+ if (exactCount > 1) {
206
+ throw new EditError(`oldText is not unique in ${where} (${exactCount} exact matches); provide more context`, "not-unique");
207
+ }
208
+
209
+ // Exact failed → fuzzy. Apply hard gates up front.
210
+ if (oldText.trim().length < MIN_FUZZY_OLD_TEXT_LENGTH) {
211
+ throw new EditError(
212
+ `oldText is too short for a non-exact match in ${where}; provide a longer, exact snippet`,
213
+ "too-short",
214
+ );
215
+ }
216
+ if (source.indexOf("\0") !== -1) {
217
+ throw new EditError(`refusing a non-exact match in ${where}: file looks binary (contains a NUL byte); re-read and pass exact text`, "binary");
218
+ }
219
+ if (source.length > MAX_FUZZY_SOURCE_CHARS || countOccurrences(source, "\n") + 1 > MAX_FUZZY_SOURCE_LINES) {
220
+ throw new EditError(`refusing a non-exact match in ${where}: file is too large to fuzzy-match safely; re-read and pass exact text`, "too-large");
221
+ }
222
+
223
+ // Ordered by increasing tolerance. `effectiveFind` is what the strategy
224
+ // semantically matched against, used for the disproportion guard (the escape
225
+ // strategy legitimately expands a 1-line "a\nb\nc" oldText to 3 real lines).
226
+ const strategies: Array<{ via: MatchStrategy; find: (c: string, f: string) => string[]; effectiveFind: string }> = [
227
+ { via: "line-trimmed", find: lineTrimmedSpans, effectiveFind: oldText },
228
+ { via: "unicode", find: unicodePunctuationSpans, effectiveFind: oldText },
229
+ { via: "whitespace", find: whitespaceNormalizedSpans, effectiveFind: oldText },
230
+ { via: "escape", find: escapeNormalizedSpans, effectiveFind: unescapeText(oldText) },
231
+ ];
232
+
233
+ for (const { via, find, effectiveFind } of strategies) {
234
+ const candidates = dedupe(find(source, oldText).filter((c) => c.length > 0 && source.includes(c)));
235
+ if (candidates.length === 0) continue; // this strategy found nothing → try the next
236
+ if (candidates.length > 1) {
237
+ throw new EditError(`oldText matched ${candidates.length} different ${via} candidates in ${where}; provide more context to disambiguate`, "ambiguous");
238
+ }
239
+ const span = candidates[0];
240
+ // Must occur exactly once as a string too.
241
+ if (source.indexOf(span) !== source.lastIndexOf(span)) {
242
+ throw new EditError(`oldText matched a ${via} span that occurs more than once in ${where}; provide more context to disambiguate`, "ambiguous");
243
+ }
244
+ if (isDisproportionate(span, effectiveFind)) {
245
+ throw new EditError(`refusing ${via} match in ${where}: the matched span is much larger than oldText; re-read and pass exact text`, "disproportionate");
246
+ }
247
+ const start = source.indexOf(span);
248
+ let end = start + span.length;
249
+ // Strategies split on \n, so in a CRLF file the last matched line carries
250
+ // its \r into the span. That \r belongs to the file's line terminator:
251
+ // leave it in place, or the splice would turn \r\n into a bare \n.
252
+ if (source[end - 1] === "\r" && source[end] === "\n") end -= 1;
253
+ return { start, end, via };
254
+ }
255
+
256
+ throw new EditError(`oldText not found in ${where}; it must match the file's text including whitespace and indentation`, "not-found");
257
+ }
258
+
259
+ // --- fuzzy strategies: each returns ORIGINAL substrings (never normalized) ---
260
+
261
+ /**
262
+ * Shared line-block walker: match `find` against consecutive whole lines of
263
+ * `content` using `sameLine`, returning the ORIGINAL substring for each match.
264
+ * If `find` ends with a newline, the span must include the file's newline after
265
+ * the last matched line (EOF without one is not a faithful match → skip).
266
+ */
267
+ function lineBlockSpans(
268
+ content: string,
269
+ find: string,
270
+ sameLine: (fileLine: string, findLine: string) => boolean,
271
+ ): string[] {
272
+ const out: string[] = [];
273
+ const lines = content.split("\n");
274
+ const findEndsWithNewline = find.endsWith("\n");
275
+ const search = find.split("\n");
276
+ if (search.length > 0 && search[search.length - 1] === "") search.pop();
277
+ if (search.length === 0) return out;
278
+
279
+ for (let i = 0; i <= lines.length - search.length; i++) {
280
+ let ok = true;
281
+ for (let j = 0; j < search.length; j++) {
282
+ if (!sameLine(lines[i + j], search[j])) {
283
+ ok = false;
284
+ break;
285
+ }
286
+ }
287
+ if (!ok) continue;
288
+
289
+ let start = 0;
290
+ for (let k = 0; k < i; k++) start += lines[k].length + 1;
291
+ let end = start;
292
+ for (let k = 0; k < search.length; k++) {
293
+ end += lines[i + k].length;
294
+ if (k < search.length - 1) end += 1;
295
+ }
296
+ if (findEndsWithNewline) {
297
+ const lastLine = i + search.length - 1;
298
+ if (lastLine >= lines.length - 1) continue;
299
+ end += 1;
300
+ }
301
+ out.push(content.slice(start, end));
302
+ }
303
+ return out;
304
+ }
305
+
306
+ /** Match line-by-line after trimming each line. Handles indentation/trailing-ws drift. */
307
+ function lineTrimmedSpans(content: string, find: string): string[] {
308
+ return lineBlockSpans(content, find, (a, b) => a.trim() === b.trim());
309
+ }
310
+
311
+ /**
312
+ * Unicode punctuation mapping (mirrors pi's fuzzy normalizer): smart quotes,
313
+ * unicode dashes, and special spaces collapse to their ASCII equivalents, plus
314
+ * NFKC. Used for COMPARISON only — matched spans keep their original bytes.
315
+ */
316
+ function normalizeUnicodePunctuation(text: string): string {
317
+ return text
318
+ .normalize("NFKC")
319
+ // Smart single quotes → '
320
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
321
+ // Smart double quotes → "
322
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
323
+ // Hyphen/dash variants and minus → -
324
+ .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
325
+ // NBSP and other special spaces → regular space
326
+ .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
327
+ }
328
+
329
+ /** Match after mapping unicode punctuation to ASCII (per line, trimmed). The
330
+ * drift the model most often gets wrong in prose/markdown: “ ” vs ", — vs -, NBSP. */
331
+ function unicodePunctuationSpans(content: string, find: string): string[] {
332
+ return lineBlockSpans(
333
+ content,
334
+ find,
335
+ (a, b) => normalizeUnicodePunctuation(a).trim() === normalizeUnicodePunctuation(b).trim(),
336
+ );
337
+ }
338
+
339
+ /** Match after collapsing all runs of whitespace to a single space. */
340
+ function whitespaceNormalizedSpans(content: string, find: string): string[] {
341
+ const out: string[] = [];
342
+ const norm = (t: string) => t.replace(/\s+/g, " ").trim();
343
+ const findEndsWithNewline = find.endsWith("\n");
344
+ const findLines = find.split("\n");
345
+ if (findEndsWithNewline) findLines.pop();
346
+ if (findLines.length === 0) return out;
347
+ const want = norm(findLines.join("\n"));
348
+ if (want === "") return out;
349
+ const lines = content.split("\n");
350
+
351
+ // When oldText ends with a newline, the span must include the file's newline
352
+ // after the last matched line — and must NOT absorb a following
353
+ // whitespace-only line (norm() would treat its bytes as invisible).
354
+ const push = (i: number, count: number) => {
355
+ const block = lines.slice(i, i + count).join("\n");
356
+ if (findEndsWithNewline) {
357
+ if (i + count - 1 >= lines.length - 1) return; // EOF: no newline to include
358
+ out.push(block + "\n");
359
+ } else {
360
+ out.push(block);
361
+ }
362
+ };
363
+
364
+ if (findLines.length === 1) {
365
+ // Single-line oldText matches whole lines only. A multi-line oldText must
366
+ // never collapse onto one physical line (would be a wrong-location edit).
367
+ for (let i = 0; i < lines.length; i++) {
368
+ if (norm(lines[i]) === want) push(i, 1);
369
+ }
370
+ } else {
371
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
372
+ const block = lines.slice(i, i + findLines.length).join("\n");
373
+ if (norm(block) === want) push(i, findLines.length);
374
+ }
375
+ }
376
+ return out;
377
+ }
378
+
379
+ /** Unescape common escape sequences (\n \t \\ etc.) written literally. */
380
+ function unescapeText(str: string): string {
381
+ return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (m, ch: string) => {
382
+ switch (ch) {
383
+ case "n": return "\n";
384
+ case "t": return "\t";
385
+ case "r": return "\r";
386
+ case "'": return "'";
387
+ case '"': return '"';
388
+ case "`": return "`";
389
+ case "\\": return "\\";
390
+ case "\n": return "\n";
391
+ case "$": return "$";
392
+ default: return m;
393
+ }
394
+ });
395
+ }
396
+
397
+ /** Match after unescaping common escape sequences in oldText. */
398
+ function escapeNormalizedSpans(content: string, find: string): string[] {
399
+ const out: string[] = [];
400
+ const want = unescapeText(find);
401
+ if (content.includes(want)) out.push(want);
402
+ // CRLF variant of the direct match: the unescaped \n may correspond to \r\n
403
+ // in the file. The returned span keeps the file's original bytes.
404
+ if (want.includes("\n") && content.includes("\r\n")) {
405
+ const wantCRLF = want.replace(/\n/g, "\r\n");
406
+ if (wantCRLF !== want && content.includes(wantCRLF)) out.push(wantCRLF);
407
+ }
408
+ const lines = content.split("\n");
409
+ const findLines = want.split("\n");
410
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
411
+ const block = lines.slice(i, i + findLines.length).join("\n");
412
+ // Compare with the block's CRLF pairs normalized (they are file line
413
+ // terminators, not content); the pushed span keeps the original bytes.
414
+ if (unescapeText(block.replace(/\r\n/g, "\n")) === want) out.push(block);
415
+ }
416
+ return out;
417
+ }
418
+
419
+ // --- helpers ---
420
+
421
+ /** Defense-in-depth guard. The curated strategies above all match a span whose
422
+ * line count equals the effective find's, so this rarely fires today — it exists
423
+ * to reject any future partial-signal strategy (first/last-line anchoring) that
424
+ * could balloon a span, which is the documented cause of wrong-location edits
425
+ * upstream. */
426
+ export function isDisproportionate(span: string, find: string): boolean {
427
+ const oldLines = find.split("\n").length;
428
+ const spanLines = span.split("\n").length;
429
+ if (spanLines >= Math.max(oldLines + 3, oldLines * 2)) return true;
430
+ if (oldLines === 1) return false;
431
+ return span.trim().length > Math.max(find.trim().length + 500, find.trim().length * 4);
432
+ }
433
+
434
+ /** Overlap-aware occurrence count: advances one char at a time, so "aa" occurs
435
+ * twice in "aaa". Anything else would silently pick one of two valid positions. */
436
+ function countOccurrences(haystack: string, needle: string): number {
437
+ if (needle === "") return 0;
438
+ let count = 0;
439
+ let idx = haystack.indexOf(needle);
440
+ while (idx !== -1) {
441
+ count++;
442
+ idx = haystack.indexOf(needle, idx + 1);
443
+ }
444
+ return count;
445
+ }
446
+
447
+ function dedupe(arr: string[]): string[] {
448
+ return [...new Set(arr)];
449
+ }
450
+
451
+ function lineOf(source: string, index: number): number {
452
+ let line = 1;
453
+ for (let i = 0; i < index && i < source.length; i++) {
454
+ if (source[i] === "\n") line++;
455
+ }
456
+ return line;
457
+ }
458
+
459
+ /**
460
+ * Convert the replacement's line endings to the file's style, but only when the
461
+ * file is uniform: CRLF-only files get newText's LF → CRLF, LF-only files get
462
+ * newText's stray CRLF → LF. Mixed-ending files (and files with no newlines)
463
+ * take newText verbatim — guessing would rewrite bytes the model never asked for.
464
+ */
465
+ function toFileLineEndings(text: string, source: string): string {
466
+ const hasCRLF = source.includes("\r\n");
467
+ const hasBareLF = /(?<!\r)\n/.test(source);
468
+ if (hasCRLF && !hasBareLF) {
469
+ return text.replace(/\r?\n/g, "\r\n");
470
+ }
471
+ if (hasBareLF && !hasCRLF) {
472
+ return text.replace(/\r\n/g, "\n");
473
+ }
474
+ return text;
475
+ }
@@ -0,0 +1,78 @@
1
+ // Input-shape normalizer: maps the call shapes models actually emit onto the
2
+ // canonical {path, edits[]} form BEFORE schema validation. Weaker models
3
+ // pattern-match field names and shapes from other tools they saw in training
4
+ // (Claude Code's old_string/new_string + file_path, opencode's
5
+ // oldString/newString), send `edits` as a JSON string, send a single object
6
+ // instead of an array, or skip the array entirely and pass top-level
7
+ // oldText/newText. All of those are unambiguous — accept them.
8
+ //
9
+ // This function never throws: anything unrecognized passes through so schema
10
+ // validation / applyEdits can report a precise error.
11
+
12
+ import type { EditRequest } from "./edit-replace.ts";
13
+
14
+ const PATH_KEYS = ["path", "file_path", "filePath", "filename"];
15
+ const OLD_KEYS = ["oldText", "old_text", "oldString", "old_string", "old_str"];
16
+ const NEW_KEYS = ["newText", "new_text", "newString", "new_string", "new_str"];
17
+
18
+ function firstString(obj: Record<string, unknown>, keys: string[]): string | undefined {
19
+ for (const key of keys) {
20
+ const value = obj[key];
21
+ if (typeof value === "string") return value;
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ /** Map one edit-like object to {oldText, newText}, or undefined if it has no
27
+ * recognizable old/new pair. */
28
+ function toEditEntry(value: unknown): EditRequest | undefined {
29
+ if (!value || typeof value !== "object") return undefined;
30
+ const obj = value as Record<string, unknown>;
31
+ const oldText = firstString(obj, OLD_KEYS);
32
+ const newText = firstString(obj, NEW_KEYS);
33
+ if (oldText === undefined || newText === undefined) return undefined;
34
+ return { oldText, newText };
35
+ }
36
+
37
+ export interface PreparedEditArguments {
38
+ path?: string;
39
+ edits: EditRequest[];
40
+ }
41
+
42
+ /** Normalize raw tool-call arguments to {path, edits[]}. Idempotent: canonical
43
+ * input maps to itself, so it is safe to run both as the prepareArguments hook
44
+ * and again defensively inside execute(). */
45
+ export function prepareEditArguments(raw: unknown): PreparedEditArguments {
46
+ const obj = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
47
+ const path = firstString(obj, PATH_KEYS);
48
+
49
+ // Some models send edits as a JSON string instead of an array (pi's own
50
+ // built-in works around the same behavior).
51
+ let rawEdits = obj.edits;
52
+ if (typeof rawEdits === "string") {
53
+ try {
54
+ rawEdits = JSON.parse(rawEdits);
55
+ } catch {
56
+ rawEdits = undefined;
57
+ }
58
+ }
59
+ // A single {oldText, newText} object instead of a one-element array.
60
+ if (rawEdits && typeof rawEdits === "object" && !Array.isArray(rawEdits)) {
61
+ rawEdits = [rawEdits];
62
+ }
63
+
64
+ const edits: EditRequest[] = [];
65
+ if (Array.isArray(rawEdits)) {
66
+ for (const entry of rawEdits) {
67
+ // Keep unmappable entries as-is so applyEdits rejects them with a
68
+ // precise "edit N needs string oldText/newText" error.
69
+ edits.push(toEditEntry(entry) ?? (entry as EditRequest));
70
+ }
71
+ }
72
+
73
+ // Top-level oldText/newText shorthand (with or without an edits array).
74
+ const top = toEditEntry(obj);
75
+ if (top) edits.push(top);
76
+
77
+ return { path, edits };
78
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "pi-tian-edit-safe",
3
+ "version": "0.1.0",
4
+ "description": "A stricter edit tool for the pi coding agent: full-span match verification, throw-on-ambiguity, verbatim replacement, and a weak-model-friendly call shape.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi",
8
+ "pi-package",
9
+ "pi-extension",
10
+ "pi-coding-agent",
11
+ "edit-tool"
12
+ ],
13
+ "author": "Tian Zuo",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/TianZuo555/pi-tian-extensions.git",
18
+ "directory": "packages/pi-edit-safe"
19
+ },
20
+ "homepage": "https://github.com/TianZuo555/pi-tian-extensions#pi-edit-safe",
21
+ "bugs": {
22
+ "url": "https://github.com/TianZuo555/pi-tian-extensions/issues"
23
+ },
24
+ "files": [
25
+ "index.ts",
26
+ "lib",
27
+ "NOTICES.md"
28
+ ],
29
+ "pi": {
30
+ "extensions": [
31
+ "./index.ts"
32
+ ]
33
+ },
34
+ "scripts": {
35
+ "test": "node --test --experimental-strip-types test/*.test.ts",
36
+ "bench": "node --experimental-strip-types bench/ab.ts"
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "typebox": "*"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }