@khanhicetea/pi-better-tool 0.2.2 → 0.2.4

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,16 +1,11 @@
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 many failed edits into recoverable ones.
3
+ Context-aware tools for the [Pi coding agent](https://github.com/earendil-works/pi-mono):
4
4
 
5
- ## Why
6
-
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
-
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
-
13
- Low-confidence, competing, stale, oversized, or omitted candidates tell the model to read the referenced range instead of retrying blindly.
5
+ - **`edit`** replaces the built-in edit tool. Failures explain what failed, what was not written, and what to do next.
6
+ - **`read_symbol`** reads a whole function, method, class, or type by containing line or exact name. With only a path, it gets a symbol outline.
7
+ - **`read_code_imports`** reads the first dependency import zone as exact raw source lines, including comments and blank lines between adjacent imports.
8
+ - **`read` stays unchanged** for ordinary text, images, and explicit line ranges.
14
9
 
15
10
  ## Install
16
11
 
@@ -18,127 +13,170 @@ Low-confidence, competing, stale, oversized, or omitted candidates tell the mode
18
13
  pi install npm:@khanhicetea/pi-better-tool
19
14
  ```
20
15
 
21
- For local development from this monorepo:
16
+ From this monorepo:
22
17
 
23
18
  ```bash
24
19
  pi install /absolute/path/to/pi-kit/packages/pi-better-tool
25
20
  ```
26
21
 
27
- It is also registered in the root `package.json` under `pi.extensions`.
22
+ The root `package.json` also registers the extension. After local changes, use `/reload` in Pi to load the new tool definition. After upgrading to a release that adds grammar packages, restart Pi once: ast-grep’s native language registry cannot be extended by `/reload` in an already-running process.
28
23
 
29
- ## Example: ambiguous oldText
24
+ ## Read the symbol, not guessed line windows
30
25
 
31
- ````text
32
- Found 2 occurrences of the text in dup.go. The text must be unique. Please provide more context to make it unique.
26
+ After grep identifies a location:
33
27
 
34
- Occurrences:
35
- 1. lines 2-3
36
- 2. lines 6-7
28
+ ```json
29
+ {"path":"src/server.ts","line":142}
30
+ ```
37
31
 
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:
32
+ Call `read_symbol` with an exact name instead:
39
33
 
40
- Occurrence 1 (lines 2-3) — minimum context: 0 lines before, 1 line after:
34
+ ```json
35
+ {"path":"src/server.ts","symbol":"Server.handleRequest"}
41
36
  ```
42
- log()
43
- }
44
37
 
45
- func second() {
46
- ```
38
+ Get the enclosing class or function:
47
39
 
48
- Tip: only fenced snippets explicitly presented as retryable should be copied into oldText.
40
+ ```json
41
+ {"path":"src/server.ts","line":142,"parent":1}
42
+ ```
49
43
 
50
- No changes were written — the file was not modified.
51
- ````
44
+ Discover names and ranges without reading every body:
52
45
 
53
- ## Example: competing closest matches
46
+ ```json
47
+ {"path":"src/server.ts"}
48
+ ```
54
49
 
55
- ````text
56
- Could not find the exact text in handlers.ts. The old text must match exactly including all whitespace and newlines.
50
+ ### Arguments
57
51
 
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.
61
- ```
62
- function firstHandler() {
63
- work();
64
- }
65
- ```
52
+ | Argument | Meaning |
53
+ | --- | --- |
54
+ | `path` | Local relative/absolute path; supports `@path`, `~/path`, and file URLs. |
55
+ | `line` | 1-based file line. Select the innermost declaration containing it. |
56
+ | `column` | Optional 1-based UTF-16 column with `line`, to distinguish same-line symbols. |
57
+ | `symbol` | Exact, case-sensitive name or qualified name such as `Server.run`. Combine with `line` for duplicate names. |
58
+ | `parent` | Move outward through enclosing declarations; default 0, maximum 20. |
59
+ | `context` | Extra whole lines before/after the declaration; default 0, maximum 20. |
60
+ | `offset` | 1-based position **within the selection**, not a file line. For an outline, the entry position. |
61
+ | `limit` | Maximum source lines (default 1000) or outline entries (default 50); maximum 1800. |
66
62
 
67
- No changes were written — the file was not modified.
68
- ````
63
+ Omit both `line` and `symbol` for an outline. Named selection never silently chooses the first duplicate. Line selection never silently chooses between same-line siblings. Candidate lists include concrete calls with names and positions.
69
64
 
70
- Similarity scores are heuristics, not probabilities.
65
+ ### Languages and boundaries
71
66
 
72
- ## Behavior and compatibility
67
+ Syntax parsing uses Tree-sitter through `@ast-grep/napi`, not indentation or brace-counting guesses:
73
68
 
74
- The normal matching path follows Pi's built-in edit implementation:
69
+ - JavaScript, JSX, TypeScript, TSX, and their module extensions
70
+ - Bash-compatible shell scripts (`.sh`, `.bash`, `.zsh`, and related extensions)
71
+ - C and C++ (including common header and CUDA/Arduino extensions)
72
+ - C# and Java
73
+ - Kotlin
74
+ - PHP
75
+ - Python and `.pyi`
76
+ - Ruby (including `Gemfile` and `Rakefile`)
77
+ - Rust
78
+ - Swift
75
79
 
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
80
+ The reader handles named declarations, nested functions, JS/TS arrow functions and methods, Python decorators, Rust attributes, and containing classes/types. It includes export/declaration wrappers when applicable. It returns **whole source lines**, so a line can also contain adjacent code. It does not resolve imports, references, overload implementations, macros, or runtime bindings. Leading standalone comments are not automatically attached to a declaration.
83
81
 
84
- Intentional safety/compatibility differences are:
82
+ Incomplete syntax, unsupported languages, unavailable parsers, and locations without declarations do not produce guessed symbol boundaries. Failures give a bounded source preview or candidate list and concrete next-call arguments. Missing paths include a bounded list of nearby files when the parent directory is accessible.
85
83
 
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
84
+ ### Output and pagination
91
85
 
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.
86
+ Results identify the selected symbol, enclosing names, full declaration range, displayed file range, and a SHA-256 source snapshot. Source appears in an unnumbered fenced block, so line-number prefixes cannot accidentally enter `oldText`.
93
87
 
94
- ### Read-evidence boundary
88
+ Large selections return a whole-line page, explicitly marked **partial**, with the complete next `read_symbol` arguments. Follow that continuation instead of calculating file offsets. Each call reads a fresh snapshot; do not combine pages whose snapshot hashes differ.
95
89
 
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.
90
+ Limits:
97
91
 
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.
92
+ - Source analysis: 2 MiB UTF-8, 100,000 syntax nodes, 10,000 declarations, 1,024 characters per qualified symbol name.
93
+ - Complete read output: 48 KiB / 1,950 lines, including metadata and fences.
94
+ - No clipped copyable source lines or broken fences. A line too large to display gets preview/read guidance instead.
95
+ - Invalid UTF-8, NUL-containing input, and non-regular files are rejected.
99
96
 
100
- ### Local filesystem and commit guarantees
97
+ Parser packages are runtime dependencies. Common platforms use prebuilt native binaries. If a grammar is unavailable on a platform, the tool gives read guidance; it never runs repository code, installs a compiler, or builds a grammar during a tool call. The edit tool can still work without loading symbol parsers.
101
98
 
102
- This override uses local Node.js filesystem operations. It does **not** inherit an SSH, container, sandbox, or other custom edit backend.
99
+ ## Read imports before editing them
103
100
 
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.
101
+ Call `read_code_imports` with a source path:
105
102
 
106
- The final write is still an in-place filesystem overwrite:
103
+ ```json
104
+ {"path":"src/server.ts"}
105
+ ```
107
106
 
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
107
+ The tool uses bounded regular-expression detection and returns the first contiguous import zone in an unnumbered source fence. It supports JavaScript/TypeScript, Python, Go, Rust, C/C++, C#, Java, Kotlin, PHP, Ruby, Swift, and shell files. It recognizes each language's common `import`, `include`, `use`, `require`, or `source` form, including common multiline forms.
112
108
 
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.
109
+ The result includes the exact file-line range and a SHA-256 snapshot. Comments and blank lines between adjacent imports remain in the raw output. The zone stops when code separates later import statements. The tool does not resolve dependencies, execute code, or claim syntax-level accuracy. Use `read` when conditional imports, generated files, uncommon macros, or unsupported syntax need more context.
114
110
 
115
- ### Output and resource bounds
111
+ ## Recover from an edit failure in the next call
116
112
 
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.
113
+ The `edit` input remains:
118
114
 
119
- `details.diff` and `details.patch` remain complete for renderer compatibility and are not blindly truncated as diagnostic text.
115
+ ```json
116
+ {
117
+ "path": "src/server.ts",
118
+ "edits": [
119
+ { "oldText": "exact current text", "newText": "replacement text" }
120
+ ]
121
+ }
122
+ ```
120
123
 
121
- ## Host compatibility
124
+ All entries match the **original file**, not the output of earlier entries. Matching/overlap failures apply none of the batch. Fix the reported entries and resubmit the **complete batch**.
122
125
 
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.
126
+ | Failure | Returned context |
127
+ | --- | --- |
128
+ | Repeated `oldText` | Occurrence ranges, bounded unique anchor expansions, and concrete context-read calls. |
129
+ | Text not found | Closest-region comparison with original whitespace, likely causes, and exact retry text only when unique and clearly better than competing candidates. |
130
+ | Overlapping entries | Both ranges plus a unique merged source anchor when it fits. Apply both intended changes to one `newText`. |
131
+ | Empty anchor | Explain insertion anchoring and give a context-read call. |
132
+ | No change | Explain that the output is identical; do not repeat the same call. |
133
+ | Replacement already appears | Report its locations as a clue, not proof that the intended change is complete. |
124
134
 
125
- ## Development
135
+ A failed batch starts with, for example:
126
136
 
127
- ```bash
128
- npm run check # typecheck + tests
129
- npm test # vitest only
137
+ ```text
138
+ [edit failure: not-found]
139
+ Batch status: 0/2 replacements written. Fix edits[1] and resubmit the complete batch against the original file; no earlier replacement was applied.
130
140
  ```
131
141
 
132
- ## Publishing
142
+ Only snippets explicitly presented as retryable may be copied directly into `oldText`. Low-confidence, competing, stale, oversized, or omitted candidates require verification. Suggested `read_symbol` calls get the enclosing source declaration without another search for its boundary. Similarity scores are heuristics, not probabilities.
133
143
 
134
- From the repository root:
144
+ ### Verified read evidence
145
+
146
+ For repeated **literal** text, edit can select an occurrence only when exactly one tracked occurrence is fully contained in the newest verified same-file read result in Pi's active, compaction-aware **stored session context**.
147
+
148
+ Both readers participate:
149
+
150
+ - Built-in `read`: reproduce its current LF-normalized output and truncation format.
151
+ - `read_symbol`: regenerate the result from the original arguments and current source. Snapshot, selection, envelope, and displayed source must all agree. Result `details` alone are never trusted.
152
+
153
+ Only displayed source is evidence. Outline entries, unseen parts of partial symbols, and omitted final newline separators are not. The newest same-file failed, missing, malformed, or stale result blocks fallback to an older read. Retained-tail compaction messages are supported when the host exposes them. Canonical paths support symlink aliases.
154
+
155
+ This verifies stored context, not the final provider payload: other extensions can remove messages or rewrite requests. Built-in BOM-bearing read output remains conservatively rejected; `read_symbol` deliberately strips a UTF-8 BOM and normalizes CRLF before parsing and snapshotting. Fuzzy-equivalent ambiguity and highly repetitive files fail closed.
156
+
157
+ ## Edit safety and compatibility
158
+
159
+ The matching engine preserves exact-first/fuzzy-fallback behavior, fuzzy-space uniqueness, original-file batch matching, overlap/no-change checks, CRLF restoration, and UTF-8 BOM preservation. Success details keep Pi's `diff`, `patch`, and `firstChangedLine` shape; the built-in edit renderer is inherited.
160
+
161
+ Intentional safeguards include 1–100 replacements per call, rejection of empty/fuzzy-empty anchors, and rejection of invalid UTF-8 or NUL-containing files. Self-overlapping strings retain Pi's non-overlapping occurrence-counting policy. The pure compatibility shim accepts array, JSON-string, single-object, and legacy top-level edit arguments.
162
+
163
+ Both tools use the **local filesystem**. They do not inherit an SSH, container, or sandbox backend. File mutations use Pi's shared mutation queue. Immediately before writing, edit rechecks file identity and content to catch many external changes.
164
+
165
+ The final write remains an **in-place overwrite**, not a cross-process lock, race-free compare-and-swap, or crash-safe atomic transaction. Another process can change a file after the check. A rejected write may leave the file unchanged, partially written, or fully written: inspect it before retrying. A resolved write is the commit boundary; later cancellation cannot roll it back. In-place writes preserve existing symlink/hard-link semantics.
166
+
167
+ Edit diagnostics stay below Pi's 50 KiB / 2,000-line limits. Snippets are omitted atomically when needed, and similarity work is bounded. Renderer `details.diff` and `details.patch` remain complete rather than being blindly truncated.
168
+
169
+ ## Development
170
+
171
+ Tested against `@earendil-works/pi-coding-agent` 0.82.1. Pi/typebox peer dependencies remain wildcard ranges, as Pi's packaging guidance requires. Re-run the read-format, session-evidence, and renderer compatibility tests on host upgrades.
135
172
 
136
173
  ```bash
137
- npm run check --workspace=@khanhicetea/pi-better-tool
138
- npm pack --dry-run --workspace=@khanhicetea/pi-better-tool
139
- npm publish --workspace=@khanhicetea/pi-better-tool
174
+ npm run check --workspace @khanhicetea/pi-better-tool
175
+ npm pack --dry-run --workspace @khanhicetea/pi-better-tool
140
176
  ```
141
177
 
178
+ Tests cover existing edit behavior, recovery round-trips, syntax boundaries across languages, nested/duplicate symbols, pagination, encoding/size failures, cancellation, and read-to-edit evidence.
179
+
142
180
  ## License
143
181
 
144
182
  MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@khanhicetea/pi-better-tool",
3
- "version": "0.2.2",
4
- "description": "Better built-in tools for pi: an edit tool override that returns recovery context (closest match + disambiguation snippets) instead of bare failures",
3
+ "version": "0.2.4",
4
+ "description": "Context-aware tools for pi: safe edits, syntax-aware symbol reads, and raw source import-zone reads",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -13,8 +13,21 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/khanhicetea/pi-kit/issues"
15
15
  },
16
- "keywords": ["pi-package", "pi-extension", "edit", "tools"],
17
- "files": ["src", "README.md", "LICENSE"],
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-extension",
19
+ "edit",
20
+ "read",
21
+ "symbols",
22
+ "imports",
23
+ "tree-sitter",
24
+ "tools"
25
+ ],
26
+ "files": [
27
+ "src",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
18
31
  "scripts": {
19
32
  "test": "vitest run",
20
33
  "typecheck": "tsc --noEmit",
@@ -33,12 +46,29 @@
33
46
  "vitest": "^3.2.0"
34
47
  },
35
48
  "pi": {
36
- "extensions": ["./src/index.ts"]
49
+ "extensions": [
50
+ "./src/index.ts"
51
+ ]
37
52
  },
38
53
  "engines": {
39
54
  "node": ">=20"
40
55
  },
41
56
  "publishConfig": {
42
57
  "access": "public"
58
+ },
59
+ "dependencies": {
60
+ "@ast-grep/lang-bash": "0.0.8",
61
+ "@ast-grep/lang-c": "0.0.6",
62
+ "@ast-grep/lang-cpp": "0.0.6",
63
+ "@ast-grep/lang-csharp": "0.0.6",
64
+ "@ast-grep/lang-go": "0.0.6",
65
+ "@ast-grep/lang-java": "0.0.7",
66
+ "@ast-grep/lang-kotlin": "0.0.7",
67
+ "@ast-grep/lang-php": "0.0.7",
68
+ "@ast-grep/lang-python": "0.0.6",
69
+ "@ast-grep/lang-ruby": "0.0.7",
70
+ "@ast-grep/lang-rust": "0.0.7",
71
+ "@ast-grep/lang-swift": "0.0.8",
72
+ "@ast-grep/napi": "0.45.3"
43
73
  }
44
74
  }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type { EditFailure, EditOp, LineRange } from "./apply.ts";
15
15
  import { normalizeEdits } from "./apply.ts";
16
+ import { languageForPath } from "./symbols.ts";
16
17
  import { findClosestRegion, lineSimilarity, probeMatchCauses } from "./similarity.ts";
17
18
  import {
18
19
  countFuzzyOccurrences,
@@ -134,7 +135,20 @@ export function formatAutoDisambiguationSuccess(
134
135
  }
135
136
 
136
137
  export function formatEditFailure(opts: FormatFailureOptions): string {
137
- return boundCompleteOutput(formatEditFailureUnbounded(opts));
138
+ const { failure, edits } = opts;
139
+ const target = "editIndex" in failure ? `edits[${failure.editIndex}]` : failure.kind === "overlap" ? `edits[${failure.firstEditIndex}] and edits[${failure.secondEditIndex}]` : "the replacement text";
140
+ const batch = edits.length > 1
141
+ ? `Batch status: 0/${edits.length} replacements written. Fix ${target} and resubmit the complete batch against the original file; no earlier replacement was applied.`
142
+ : "Write status: no changes were written by this call.";
143
+ return boundCompleteOutput(`[edit failure: ${failure.kind}]\n${batch}\n\n${formatEditFailureUnbounded(opts)}`);
144
+ }
145
+
146
+ /** Concrete next arguments prevent another call just to discover boundaries. */
147
+ function nextContextCall(path: string, start: number, end = start): string {
148
+ const read = `read ${JSON.stringify({ path, offset: Math.max(1, start - 3), limit: Math.min(1800, end - start + 7) })}`;
149
+ return languageForPath(path)
150
+ ? `Next context call: read_symbol ${JSON.stringify({ path, line: start })} for the enclosing symbol; or ${read} for exact line context.`
151
+ : `Next context call: ${read}.`;
138
152
  }
139
153
 
140
154
  function formatEditFailureUnbounded(opts: FormatFailureOptions): string {
@@ -143,9 +157,7 @@ function formatEditFailureUnbounded(opts: FormatFailureOptions): string {
143
157
 
144
158
  switch (failure.kind) {
145
159
  case "empty-old-text": {
146
- return total === 1
147
- ? `oldText must not be empty in ${path}.`
148
- : `edits[${failure.editIndex}].oldText must not be empty in ${path}.`;
160
+ return `${total === 1 ? "oldText" : `edits[${failure.editIndex}].oldText`} must not be empty in ${path}. Copy non-empty source text as the anchor; for insertion, retain that anchor in newText.\n${nextContextCall(path, 1)}`;
149
161
  }
150
162
 
151
163
  case "not-found": {
@@ -175,13 +187,19 @@ function formatEditFailureUnbounded(opts: FormatFailureOptions): string {
175
187
 
176
188
  case "overlap": {
177
189
  const { firstEditIndex, secondEditIndex, firstRange, secondRange } = failure;
178
- return `edits[${firstEditIndex}] and edits[${secondEditIndex}] overlap in ${path} (edits[${firstEditIndex}] covers lines ${firstRange.start}-${firstRange.end}, edits[${secondEditIndex}] covers lines ${secondRange.start}-${secondRange.end}). Merge them into one edit or target disjoint regions.`;
190
+ const start = Math.min(firstRange.start, secondRange.start);
191
+ const end = Math.max(firstRange.end, secondRange.end);
192
+ const head = `edits[${firstEditIndex}] and edits[${secondEditIndex}] overlap in ${path} (edits[${firstEditIndex}] covers lines ${firstRange.start}-${firstRange.end}, edits[${secondEditIndex}] covers lines ${secondRange.start}-${secondRange.end}). Merge them into one edit or target disjoint regions.`;
193
+ const expansion = normalizedContent.length <= MAX_CONTENT_FOR_DIAGNOSTICS
194
+ ? findMinimalUniqueExpansion(normalizedContent, normalizeForFuzzyMatch(normalizedContent), getLineSpans(normalizedContent), { start, end }) : null;
195
+ if (expansion && isSnippetRenderable(expansion.text)) {
196
+ return `${head}\n\nRetryable merged oldText at lines ${expansion.startLine}-${expansion.endLine}. Apply BOTH intended changes to this snippet in one newText; do not concatenate the previous replacements.\n${renderSnippet(expansion.text).join("\n")}`;
197
+ }
198
+ return `${head}\nMerged source snippet omitted or not unique. ${nextContextCall(path, start, end)}`;
179
199
  }
180
200
 
181
201
  case "no-change": {
182
- return total === 1
183
- ? `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`
184
- : `No changes made to ${path}. The replacements produced identical content.`;
202
+ return `No changes made to ${path}. The replacement${total === 1 ? "" : "s"} produced identical content. Do not repeat the same call. If the intended change is already present, stop; otherwise change newText so it differs from the matched source.`;
185
203
  }
186
204
  }
187
205
  }
@@ -204,6 +222,7 @@ function formatAmbiguous(opts: FormatFailureOptions, failure: Extract<EditFailur
204
222
  listed.forEach((offset, i) => {
205
223
  const range = rangeFromOffset(fuzzySpans, offset, fuzzyOld.length);
206
224
  lines.push(` ${i + 1}. ${describeLines(range.start, range.end)}`);
225
+ lines.push(` ${nextContextCall(opts.path, range.start, range.end)}`);
207
226
  });
208
227
  if (failure.occurrenceCount > listed.length) {
209
228
  lines.push(` … and ${failure.occurrenceCount - listed.length} more`);
@@ -345,6 +364,14 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
345
364
  const causes = probeMatchCauses(normalizedContent, oldText);
346
365
 
347
366
  const lines: string[] = [];
367
+ const edit = "editIndex" in opts.failure ? normalizeEdits(opts.edits)[opts.failure.editIndex] : undefined;
368
+ if (edit?.newText && edit.newText !== oldText) {
369
+ const offsets = findAllOccurrences(normalizedContent, edit.newText, 4);
370
+ if (offsets.length) {
371
+ const spans = getLineSpans(normalizedContent);
372
+ lines.push(`Replacement text already appears at ${offsets.map((offset) => { const range = rangeFromOffset(spans, offset, edit.newText.length); return describeLines(range.start, range.end); }).join(", ")} (up to 4 shown). The change may already be applied; verify intent before choosing another target.`, "");
373
+ }
374
+ }
348
375
  if (closest) {
349
376
  lines.push(
350
377
  `Closest match in the file: ${describeLines(closest.startLine, closest.endLine)} (~${Math.round(closest.score * 100)}% line similarity${closest.truncated ? `, compared against the first ${closest.totalOldLines} lines of your oldText` : ""}).`,
@@ -422,11 +449,14 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
422
449
  `Candidate file content at ${describeLines(closest.startLine, closest.endLine)} is not safe for a direct retry (${reasons.join("; ")}). Read and verify this range before editing.`,
423
450
  );
424
451
  if (safelyRenderable) lines.push(...renderSnippet(uniqueCandidate ?? candidate));
452
+ lines.push(nextContextCall(opts.path, closest.startLine, closest.endLine));
453
+ if (closest.competitor && competitorGap < MIN_DIRECT_RETRY_GAP) lines.push(nextContextCall(opts.path, closest.competitor.startLine, closest.competitor.endLine));
425
454
  }
426
455
 
427
456
  } else {
428
457
  lines.push("No reliable similar region was found within the bounded diagnostic search.");
429
458
  lines.push("If you expected this text to exist, read the file around the expected location and retry.");
459
+ lines.push(languageForPath(opts.path) ? `Next context call: read_symbol ${JSON.stringify({ path: opts.path })} to locate the intended symbol without guessing line ranges.` : nextContextCall(opts.path, 1));
430
460
  }
431
461
 
432
462
  if (causes.length > 0) {
@@ -449,7 +479,7 @@ function truncateLine(line: string): string {
449
479
  * Enforce the complete output budget without ever cutting a generated fenced
450
480
  * snippet. Oversized snippets are omitted atomically and clearly marked.
451
481
  */
452
- function boundCompleteOutput(message: string): string {
482
+ export function boundCompleteOutput(message: string): string {
453
483
  const source = message.split("\n");
454
484
  const output: string[] = [];
455
485
  let bytes = 0;
package/src/index.ts CHANGED
@@ -1,14 +1,22 @@
1
1
  /**
2
2
  * pi-better-tool — better built-in tools for the pi coding agent.
3
3
  *
4
- * Currently ships one override:
5
- * - `edit` — built-in-compatible exact replacement with richer recovery
6
- * context and conservative read-based resolution when a recent verified
7
- * read contains exactly one of several literal occurrences.
4
+ * Ships a safe edit override and focused source readers:
5
+ * - `edit` — exact replacement with actionable recovery context.
6
+ * - `read_symbol` — whole symbols by containing line or exact name.
7
+ * - `read_code_imports` — raw dependency import zones for common languages.
8
+ * Built-in `read` remains available for text, images, and explicit line ranges.
8
9
  */
9
10
 
10
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
12
  import { registerBetterEditTool } from "./tool.ts";
13
+ import { registerReadSymbolTool } from "./read-symbol.ts";
14
+ import { registerReadCodeImportsTool } from "./read-code-imports.ts";
15
+
16
+ export { buildCodeImportsRead, executeReadCodeImports, registerReadCodeImportsTool, readCodeImportsSchema, validateReadCodeImportsInput } from "./read-code-imports.ts";
17
+ export type { ReadCodeImportsInput, ReadCodeImportsResult } from "./read-code-imports.ts";
18
+ export { executeReadSymbol, registerReadSymbolTool, readSymbolSchema } from "./read-symbol.ts";
19
+ export type { ReadSymbolInput, ReadSymbolResult } from "./read-symbol.ts";
12
20
 
13
21
  export { registerBetterEditTool, executeBetterEdit, prepareEditArguments, betterEditSchema } from "./tool.ts";
14
22
  export type {
@@ -29,4 +37,6 @@ export type { AnalyzeOptions, EditFailure, EditOp, EditAnalysis } from "./apply.
29
37
 
30
38
  export default function (pi: ExtensionAPI) {
31
39
  registerBetterEditTool(pi);
40
+ registerReadSymbolTool(pi);
41
+ registerReadCodeImportsTool(pi);
32
42
  }
package/src/paths.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
6
+
7
+ /** Match Pi's built-in path normalization for all local tools in this package. */
8
+ export function resolveToolPath(input: string, cwd: string): string {
9
+ let path = input.replace(UNICODE_SPACES, " ");
10
+ if (path.startsWith("@")) path = path.slice(1);
11
+ if (process.platform === "win32" && path.startsWith("/") && !path.startsWith("//") && !path.includes("\\")) {
12
+ const match = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
13
+ if (match) path = `${match[1].toUpperCase()}:\\${match[2]?.replaceAll("/", "\\") ?? ""}`;
14
+ }
15
+ if (path === "~") path = homedir();
16
+ else if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) path = join(homedir(), path.slice(2));
17
+ if (/^file:\/\//.test(path)) path = fileURLToPath(path);
18
+ return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
19
+ }