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 +27 -0
- package/NOTICES.md +34 -0
- package/README.md +123 -0
- package/index.ts +134 -0
- package/lib/edit-replace.ts +475 -0
- package/lib/prepare-arguments.ts +78 -0
- package/package.json +45 -0
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
|
+
}
|