@khanhicetea/pi-better-tool 0.1.0 → 0.2.2

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/README.md CHANGED
@@ -1,19 +1,16 @@
1
1
  # pi-better-tool
2
2
 
3
- Better built-in tools for the [pi coding agent](https://github.com/earendil-works/pi-mono) — starting with an `edit` override that turns failed edits into recoverable ones.
3
+ Better built-in tools for the [pi coding agent](https://github.com/earendil-works/pi-mono) — starting with an `edit` override that turns many failed edits into recoverable ones.
4
4
 
5
5
  ## Why
6
6
 
7
- The built-in `edit` tool requires `edits[].oldText` to match **exactly and uniquely**. When it doesn't, the tool fails with a bare error:
7
+ Pi's built-in `edit` requires every `edits[].oldText` to identify one non-overlapping region. When matching fails, this override returns bounded context that can make the next action safer:
8
8
 
9
- ```
10
- Could not find edits[1] in /code/app.go. The oldText must match exactly including all whitespace and newlines.
11
- ```
9
+ - **Ambiguous literal match after a bounded read** — selects an occurrence only when exactly one tracked literal occurrence is fully contained in the newest verified stored-context `read` of the same file.
10
+ - **Other ambiguous matches** reports bounded occurrence ranges and whole-line prefix/suffix expansions that are unique under edit matching.
11
+ - **Text not found** — reports a bounded closest-region comparison. It gives direct-retry wording only when the exact candidate is unique, sufficiently similar, and meaningfully better than a distinct runner-up.
12
12
 
13
- That failure wastes a whole loop: the model has to `read` the file again, guess a larger context, and retry — sometimes failing again. `pi-better-tool` keeps the refusal (writing the wrong occurrence would be worse) but returns **recovery context** so the next call succeeds without re-reading:
14
-
15
- - **Ambiguous match (2+ occurrences)** — every occurrence's line number plus the **minimum prefix/suffix context** that makes each occurrence unique, rendered as ready-to-use `oldText` snippets.
16
- - **Text not found** — the closest matching region (fuzzy line similarity), a line-by-line comparison against your `oldText`, the exact file bytes to retry with, and likely causes (tabs vs spaces, indentation, case).
13
+ Low-confidence, competing, stale, oversized, or omitted candidates tell the model to read the referenced range instead of retrying blindly.
17
14
 
18
15
  ## Install
19
16
 
@@ -31,14 +28,14 @@ It is also registered in the root `package.json` under `pi.extensions`.
31
28
 
32
29
  ## Example: ambiguous oldText
33
30
 
34
- ```text
31
+ ````text
35
32
  Found 2 occurrences of the text in dup.go. The text must be unique. Please provide more context to make it unique.
36
33
 
37
34
  Occurrences:
38
35
  1. lines 2-3
39
36
  2. lines 6-7
40
37
 
41
- Retry with a disambiguated oldText: pick ONE occurrence below and reuse its snippet exactly. Each snippet already includes the minimum surrounding context that makes it unique:
38
+ Disambiguated oldText candidates are shown below when they fit safely. A fenced snippet can be reused exactly; an omitted snippet must be read from its referenced range first:
42
39
 
43
40
  Occurrence 1 (lines 2-3) — minimum context: 0 lines before, 1 line after:
44
41
  ```
@@ -48,53 +45,82 @@ Occurrence 1 (lines 2-3) — minimum context: 0 lines before, 1 line after:
48
45
  func second() {
49
46
  ```
50
47
 
51
- Occurrence 2 (lines 6-7) minimum context: 0 lines before, 0 lines after:
48
+ Tip: only fenced snippets explicitly presented as retryable should be copied into oldText.
49
+
50
+ No changes were written — the file was not modified.
51
+ ````
52
+
53
+ ## Example: competing closest matches
54
+
55
+ ````text
56
+ Could not find the exact text in handlers.ts. The old text must match exactly including all whitespace and newlines.
57
+
58
+ Closest match in the file: lines 10-12 (~91% line similarity).
59
+ ...
60
+ Candidate file content at lines 10-12 is not safe for a direct retry (a distinct candidate at lines 30-32 has a similar heuristic score (~90%)). Read and verify this range before editing.
52
61
  ```
53
- log()
62
+ function firstHandler() {
63
+ work();
54
64
  }
55
65
  ```
56
66
 
57
- Tip: use the snippet byte-for-byte as the new oldText, and make newText the snippet with your change applied (the snippet may span whole lines).
58
-
59
67
  No changes were written — the file was not modified.
60
- ```
68
+ ````
61
69
 
62
- ## Example: text not found
70
+ Similarity scores are heuristics, not probabilities.
63
71
 
64
- ```text
65
- Could not find the exact text in tabs.go. The old text must match exactly including all whitespace and newlines.
72
+ ## Behavior and compatibility
66
73
 
67
- Closest match in the file: lines 3-5 (~91% line similarity).
68
- Differences vs your oldText (2 of 3 compared lines match):
69
- file line 4 differs from your oldText line 2:
70
- file: →tab→fmt.Println("hi")
71
- oldText: fmt.Println("hi")
74
+ The normal matching path follows Pi's built-in edit implementation:
72
75
 
73
- Exact file content at lines 3-5 retry using this text as oldText (then apply your change to newText):
74
- ```
75
- func main() {
76
- fmt.Println("hi")
77
- }
78
- ```
76
+ - exact match first, then fuzzy fallback for trailing whitespace, smart quotes, dashes, Unicode compatibility forms, and Unicode spaces
77
+ - uniqueness checked in fuzzy-normalized space
78
+ - all edits matched against the original content rather than applied incrementally
79
+ - overlap and no-change detection
80
+ - CRLF restoration and UTF-8 BOM preservation
81
+ - built-in-compatible success details (`details.diff`, `details.patch`, and `details.firstChangedLine`)
82
+ - no custom renderers, so Pi's built-in edit renderer is inherited
79
83
 
80
- Possible cause:
81
- - whitespace mismatch: the text matches when ALL whitespace is removed — check tabs vs spaces and indentation width
84
+ Intentional safety/compatibility differences are:
82
85
 
83
- No changes were written the file was not modified.
84
- ```
86
+ - 1–100 edits are accepted per call; empty batches are rejected by the public schema
87
+ - empty and fuzzy-normalized-empty `oldText` values are rejected
88
+ - invalid UTF-8 and NUL-containing files are rejected rather than silently transcoded
89
+ - conservative stored-read-based selection may resolve repeated literal text
90
+ - self-overlapping string occurrences retain Pi's non-overlapping counting policy
91
+
92
+ The argument compatibility shim accepts `edits` as an array, JSON string, single edit object, or legacy top-level `oldText`/`newText`. Preparation is pure and idempotent.
93
+
94
+ ### Read-evidence boundary
95
+
96
+ Read evidence is taken from Pi's active, compaction-aware **stored session context**. Retained-tail messages are handled when the host exposes them. The newest same-file read must have a matching successful result and reproduce built-in read formatting for the current LF-normalized content. Missing, failed, malformed, or stale newest evidence blocks fallback to older intent.
97
+
98
+ This is not proof of the final provider payload: another extension may remove messages in a `context` hook or rewrite the provider request. CRLF read output is intentionally compared after LF normalization, so this guarantee is content/format verification rather than literal byte identity. BOM-bearing read output is conservatively rejected because edit matching strips the BOM. Fuzzy/Unicode-equivalent ambiguity and highly repetitive files also fail closed.
99
+
100
+ ### Local filesystem and commit guarantees
85
101
 
86
- ## Behavior
102
+ This override uses local Node.js filesystem operations. It does **not** inherit an SSH, container, sandbox, or other custom edit backend.
87
103
 
88
- Happy-path semantics are **identical** to the built-in `edit` tool:
104
+ All replacements are analyzed before writing, so a matching/overlap/no-change/diagnostic failure starts no write. Immediately before writing, the tool performs a best-effort content and file-identity recheck to catch many external modifications.
89
105
 
90
- - same schema (`path` + `edits[{oldText,newText}]`), including the compatibility shim for models that send `edits` as a JSON string, a single edit object, or legacy top-level `oldText`/`newText`
91
- - same matching engine ported from pi's `edit-diff.ts`: exact match first, fuzzy fallback (trailing whitespace, smart quotes, dashes, unicode spaces), uniqueness checked in fuzzy-normalized space, all edits matched against the original content, overlap/empty/no-change detection
92
- - same BOM and CRLF handling
93
- - same success result shape (`details.diff` / `details.patch` / `details.firstChangedLine`), and no custom renderers — the built-in diff renderer is inherited
106
+ The final write is still an in-place filesystem overwrite:
94
107
 
95
- Failure behavior is the difference: errors carry the recovery context described above, and nothing is written on failure (edits remain atomic).
108
+ - it is not a cross-process lock or race-free compare-and-swap
109
+ - it is not rollback- or crash-safe filesystem atomicity
110
+ - another process can modify the file after the pre-write check
111
+ - a rejected write may leave the file unchanged, partially written, or fully written; inspect it before retrying
96
112
 
97
- Diagnostics degrade gracefully: files over ~2 MB skip the analysis and return the plain built-in-style error; repeated blocks that cannot be disambiguated within 12 context lines get a guidance note instead of snippets.
113
+ A resolved write is the tool's commit boundary. The extension returns the committed result rather than throwing a post-write cancellation error. A host may still suppress result delivery when cancelling the surrounding tool run; cancellation cannot roll back a completed filesystem write. Temporary-file/rename replacement is deliberately not used because it can replace symlinks, break hard-link semantics, or alter metadata without a carefully defined cross-platform policy.
114
+
115
+ ### Output and resource bounds
116
+
117
+ Diagnostic text is kept below Pi's 50 KB / 2,000-line tool-output limits. Markdown snippets are added atomically so a fence is never cut; oversized snippets are omitted with read guidance. Success expansion is skipped for oversized files. Closest-match work has explicit query, line, and operation budgets and falls back to concise read guidance when exhausted.
118
+
119
+ `details.diff` and `details.patch` remain complete for renderer compatibility and are not blindly truncated as diagnostic text.
120
+
121
+ ## Host compatibility
122
+
123
+ Pi's packaging guidance requires wildcard peer dependencies for Pi core packages. This package follows that guidance rather than bundling Pi. Version 0.2.1 is typechecked and tested against `@earendil-works/pi-coding-agent` 0.82.1; host upgrades should run the read-format, exported-helper, renderer-shape, and session-context compatibility tests.
98
124
 
99
125
  ## Development
100
126
 
@@ -105,7 +131,7 @@ npm test # vitest only
105
131
 
106
132
  ## Publishing
107
133
 
108
- From the repository root, verify the package and inspect its tarball before publishing:
134
+ From the repository root:
109
135
 
110
136
  ```bash
111
137
  npm run check --workspace=@khanhicetea/pi-better-tool
@@ -113,8 +139,6 @@ npm pack --dry-run --workspace=@khanhicetea/pi-better-tool
113
139
  npm publish --workspace=@khanhicetea/pi-better-tool
114
140
  ```
115
141
 
116
- The package is configured for public publishing under the `@khanhicetea` scope. npm authentication is required.
117
-
118
142
  ## License
119
143
 
120
144
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanhicetea/pi-better-tool",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "Better built-in tools for pi: an edit tool override that returns recovery context (closest match + disambiguation snippets) instead of bare failures",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/apply.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Edit matching and application engine.
3
3
  *
4
- * A port of pi's built-in `applyEditsToNormalizedContent` with one key
5
- * difference: instead of throwing opaque errors, `analyzeEdits` returns a
6
- * structured failure describing *why* an edit failed, which the diagnostics
7
- * layer turns into actionable recovery context for the model.
4
+ * A port of pi's built-in `applyEditsToNormalizedContent`. Instead of throwing
5
+ * opaque errors, `analyzeEdits` returns a structured failure describing *why*
6
+ * an edit failed. It also rejects fuzzy-normalized-empty needles and can accept
7
+ * a conservatively verified literal selection as intentional safety extensions.
8
8
  *
9
- * Matching semantics are kept identical to the built-in tool:
9
+ * Otherwise matching semantics follow the built-in tool:
10
10
  * - exact match first, then fuzzy-normalized fallback (trailing whitespace,
11
11
  * smart quotes, dashes, unicode spaces)
12
12
  * - uniqueness is always checked in fully fuzzy-normalized space
@@ -18,10 +18,11 @@ import {
18
18
  countFuzzyOccurrences,
19
19
  findAllOccurrences,
20
20
  getLineSpans,
21
+ getLogicalLineSpans,
21
22
  lineAt,
22
23
  normalizeForFuzzyMatch,
23
24
  normalizeToLF,
24
- splitLinesWithEndings,
25
+ splitLogicalLinesWithEndings,
25
26
  type LineSpan,
26
27
  } from "./text.ts";
27
28
 
@@ -42,7 +43,9 @@ export type EditFailure =
42
43
  | {
43
44
  kind: "ambiguous";
44
45
  editIndex: number;
45
- /** Offsets of every occurrence, enumerated in fully fuzzy-normalized space. */
46
+ /** Total non-overlapping occurrences in fully fuzzy-normalized space. */
47
+ occurrenceCount: number;
48
+ /** A bounded prefix of occurrence offsets for diagnostics/selection. */
46
49
  occurrenceOffsets: number[];
47
50
  }
48
51
  | {
@@ -77,6 +80,17 @@ export interface FuzzyFindResult {
77
80
 
78
81
  export type AnalyzeResult = { ok: true; analysis: EditAnalysis } | { ok: false; failure: EditFailure };
79
82
 
83
+ export interface AnalyzeOptions {
84
+ /**
85
+ * Exact offsets chosen for otherwise-ambiguous edits. Offsets are accepted
86
+ * only when they point at the literal oldText in the matching base.
87
+ */
88
+ ambiguousSelections?: ReadonlyMap<number, number>;
89
+ }
90
+
91
+ /** Avoid materializing unbounded offset arrays for highly repetitive files. */
92
+ const MAX_TRACKED_OCCURRENCE_OFFSETS = 256;
93
+
80
94
  export function normalizeEdits(edits: EditOp[]): EditOp[] {
81
95
  return edits.map((edit) => ({
82
96
  oldText: normalizeToLF(edit.oldText),
@@ -124,10 +138,13 @@ function rangeOf(spans: LineSpan[], matchIndex: number, matchLength: number): Li
124
138
  };
125
139
  }
126
140
 
127
- export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[]): AnalyzeResult {
141
+ export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[], options: AnalyzeOptions = {}): AnalyzeResult {
128
142
  const edits = normalizeEdits(rawEdits);
129
143
  for (let i = 0; i < edits.length; i++) {
130
- if (edits[i].oldText.length === 0) {
144
+ // Fuzzy normalization can erase whitespace-only needles. Allowing an
145
+ // empty normalized needle makes String#indexOf match at offset zero and
146
+ // turns an intended replacement into an insertion.
147
+ if (edits[i].oldText.length === 0 || normalizeForFuzzyMatch(edits[i].oldText).length === 0) {
131
148
  return { ok: false, failure: { kind: "empty-old-text", editIndex: i } };
132
149
  }
133
150
  }
@@ -146,17 +163,35 @@ export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[]): Ana
146
163
  return { ok: false, failure: { kind: "not-found", editIndex: i } };
147
164
  }
148
165
  const occurrences = countFuzzyOccurrences(fuzzyBase, edit.oldText);
166
+ let selectedMatch = matchResult;
149
167
  if (occurrences > 1) {
150
- const occurrenceOffsets = findAllOccurrences(fuzzyBase, normalizeForFuzzyMatch(edit.oldText));
151
- return {
152
- ok: false,
153
- failure: { kind: "ambiguous", editIndex: i, occurrenceOffsets },
168
+ const occurrenceOffsets = findAllOccurrences(
169
+ fuzzyBase,
170
+ normalizeForFuzzyMatch(edit.oldText),
171
+ MAX_TRACKED_OCCURRENCE_OFFSETS,
172
+ );
173
+ const selectedOffset = options.ambiguousSelections?.get(i);
174
+ if (
175
+ selectedOffset === undefined ||
176
+ base.slice(selectedOffset, selectedOffset + edit.oldText.length) !== edit.oldText
177
+ ) {
178
+ return {
179
+ ok: false,
180
+ failure: { kind: "ambiguous", editIndex: i, occurrenceCount: occurrences, occurrenceOffsets },
181
+ };
182
+ }
183
+ selectedMatch = {
184
+ found: true,
185
+ index: selectedOffset,
186
+ matchLength: edit.oldText.length,
187
+ usedFuzzyMatch: false,
188
+ contentForReplacement: base,
154
189
  };
155
190
  }
156
191
  replacements.push({
157
192
  editIndex: i,
158
- matchIndex: matchResult.index,
159
- matchLength: matchResult.matchLength,
193
+ matchIndex: selectedMatch.index,
194
+ matchLength: selectedMatch.matchLength,
160
195
  newText: edit.newText,
161
196
  });
162
197
  }
@@ -232,10 +267,10 @@ function applyReplacementsPreservingUnchangedLines(
232
267
  baseContent: string,
233
268
  replacements: Replacement[],
234
269
  ): string {
235
- const originalLines = splitLinesWithEndings(originalContent);
236
- const baseLines = getLineSpans(baseContent);
270
+ const originalLines = splitLogicalLinesWithEndings(originalContent);
271
+ const baseLines = getLogicalLineSpans(baseContent);
237
272
  if (originalLines.length !== baseLines.length) {
238
- throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
273
+ throw new Error("Cannot preserve unchanged lines because the base content has a different logical line count.");
239
274
  }
240
275
 
241
276
  const groups: Array<InternalLineWindow & { replacements: Replacement[] }> = [];