@khanhicetea/pi-better-tool 0.2.2 → 0.2.3
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 +108 -83
- package/package.json +34 -5
- package/src/diagnostics.ts +39 -9
- package/src/index.ts +9 -4
- package/src/paths.ts +19 -0
- package/src/read-evidence.ts +21 -4
- package/src/read-symbol.ts +214 -0
- package/src/source-file.ts +60 -0
- package/src/symbols.ts +228 -0
- package/src/tool.ts +3 -29
package/README.md
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
# pi-better-tool
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Context-aware tools for the [Pi coding agent](https://github.com/earendil-works/pi-mono):
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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`** is a new tool. Read a whole function, method, class, or type by containing line or exact name. With only a path, get a symbol outline.
|
|
7
|
+
- **`read` stays unchanged** for ordinary text, images, and explicit line ranges.
|
|
14
8
|
|
|
15
9
|
## Install
|
|
16
10
|
|
|
@@ -18,127 +12,158 @@ Low-confidence, competing, stale, oversized, or omitted candidates tell the mode
|
|
|
18
12
|
pi install npm:@khanhicetea/pi-better-tool
|
|
19
13
|
```
|
|
20
14
|
|
|
21
|
-
|
|
15
|
+
From this monorepo:
|
|
22
16
|
|
|
23
17
|
```bash
|
|
24
18
|
pi install /absolute/path/to/pi-kit/packages/pi-better-tool
|
|
25
19
|
```
|
|
26
20
|
|
|
27
|
-
|
|
21
|
+
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
22
|
|
|
29
|
-
##
|
|
23
|
+
## Read the symbol, not guessed line windows
|
|
30
24
|
|
|
31
|
-
|
|
32
|
-
Found 2 occurrences of the text in dup.go. The text must be unique. Please provide more context to make it unique.
|
|
25
|
+
After grep identifies a location:
|
|
33
26
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
```json
|
|
28
|
+
{"path":"src/server.ts","line":142}
|
|
29
|
+
```
|
|
37
30
|
|
|
38
|
-
|
|
31
|
+
Call `read_symbol` with an exact name instead:
|
|
39
32
|
|
|
40
|
-
|
|
33
|
+
```json
|
|
34
|
+
{"path":"src/server.ts","symbol":"Server.handleRequest"}
|
|
41
35
|
```
|
|
42
|
-
log()
|
|
43
|
-
}
|
|
44
36
|
|
|
45
|
-
|
|
37
|
+
Get the enclosing class or function:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{"path":"src/server.ts","line":142,"parent":1}
|
|
46
41
|
```
|
|
47
42
|
|
|
48
|
-
|
|
43
|
+
Discover names and ranges without reading every body:
|
|
49
44
|
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
```json
|
|
46
|
+
{"path":"src/server.ts"}
|
|
47
|
+
```
|
|
52
48
|
|
|
53
|
-
|
|
49
|
+
### Arguments
|
|
54
50
|
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
| Argument | Meaning |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `path` | Local relative/absolute path; supports `@path`, `~/path`, and file URLs. |
|
|
54
|
+
| `line` | 1-based file line. Select the innermost declaration containing it. |
|
|
55
|
+
| `column` | Optional 1-based UTF-16 column with `line`, to distinguish same-line symbols. |
|
|
56
|
+
| `symbol` | Exact, case-sensitive name or qualified name such as `Server.run`. Combine with `line` for duplicate names. |
|
|
57
|
+
| `parent` | Move outward through enclosing declarations; default 0, maximum 20. |
|
|
58
|
+
| `context` | Extra whole lines before/after the declaration; default 0, maximum 20. |
|
|
59
|
+
| `offset` | 1-based position **within the selection**, not a file line. For an outline, the entry position. |
|
|
60
|
+
| `limit` | Maximum source lines (default 1000) or outline entries (default 50); maximum 1800. |
|
|
57
61
|
|
|
58
|
-
|
|
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
|
-
```
|
|
62
|
+
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.
|
|
66
63
|
|
|
67
|
-
|
|
68
|
-
````
|
|
64
|
+
### Languages and boundaries
|
|
69
65
|
|
|
70
|
-
|
|
66
|
+
Syntax parsing uses Tree-sitter through `@ast-grep/napi`, not indentation or brace-counting guesses:
|
|
71
67
|
|
|
72
|
-
|
|
68
|
+
- JavaScript, JSX, TypeScript, TSX, and their module extensions
|
|
69
|
+
- Bash-compatible shell scripts (`.sh`, `.bash`, `.zsh`, and related extensions)
|
|
70
|
+
- C and C++ (including common header and CUDA/Arduino extensions)
|
|
71
|
+
- C# and Java
|
|
72
|
+
- Kotlin
|
|
73
|
+
- PHP
|
|
74
|
+
- Python and `.pyi`
|
|
75
|
+
- Ruby (including `Gemfile` and `Rakefile`)
|
|
76
|
+
- Rust
|
|
77
|
+
- Swift
|
|
73
78
|
|
|
74
|
-
The
|
|
79
|
+
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.
|
|
75
80
|
|
|
76
|
-
|
|
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
|
|
81
|
+
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.
|
|
83
82
|
|
|
84
|
-
|
|
83
|
+
### Output and pagination
|
|
85
84
|
|
|
86
|
-
-
|
|
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
|
|
85
|
+
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`.
|
|
91
86
|
|
|
92
|
-
|
|
87
|
+
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.
|
|
93
88
|
|
|
94
|
-
|
|
89
|
+
Limits:
|
|
95
90
|
|
|
96
|
-
|
|
91
|
+
- Source analysis: 2 MiB UTF-8, 100,000 syntax nodes, 10,000 declarations, 1,024 characters per qualified symbol name.
|
|
92
|
+
- Complete read output: 48 KiB / 1,950 lines, including metadata and fences.
|
|
93
|
+
- No clipped copyable source lines or broken fences. A line too large to display gets preview/read guidance instead.
|
|
94
|
+
- Invalid UTF-8, NUL-containing input, and non-regular files are rejected.
|
|
97
95
|
|
|
98
|
-
|
|
96
|
+
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.
|
|
99
97
|
|
|
100
|
-
|
|
98
|
+
## Recover from an edit failure in the next call
|
|
101
99
|
|
|
102
|
-
|
|
100
|
+
The `edit` input remains:
|
|
103
101
|
|
|
104
|
-
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"path": "src/server.ts",
|
|
105
|
+
"edits": [
|
|
106
|
+
{ "oldText": "exact current text", "newText": "replacement text" }
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
```
|
|
105
110
|
|
|
106
|
-
|
|
111
|
+
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**.
|
|
107
112
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
| Failure | Returned context |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| Repeated `oldText` | Occurrence ranges, bounded unique anchor expansions, and concrete context-read calls. |
|
|
116
|
+
| Text not found | Closest-region comparison with original whitespace, likely causes, and exact retry text only when unique and clearly better than competing candidates. |
|
|
117
|
+
| Overlapping entries | Both ranges plus a unique merged source anchor when it fits. Apply both intended changes to one `newText`. |
|
|
118
|
+
| Empty anchor | Explain insertion anchoring and give a context-read call. |
|
|
119
|
+
| No change | Explain that the output is identical; do not repeat the same call. |
|
|
120
|
+
| Replacement already appears | Report its locations as a clue, not proof that the intended change is complete. |
|
|
112
121
|
|
|
113
|
-
A
|
|
122
|
+
A failed batch starts with, for example:
|
|
114
123
|
|
|
115
|
-
|
|
124
|
+
```text
|
|
125
|
+
[edit failure: not-found]
|
|
126
|
+
Batch status: 0/2 replacements written. Fix edits[1] and resubmit the complete batch against the original file; no earlier replacement was applied.
|
|
127
|
+
```
|
|
116
128
|
|
|
117
|
-
|
|
129
|
+
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.
|
|
118
130
|
|
|
119
|
-
|
|
131
|
+
### Verified read evidence
|
|
120
132
|
|
|
121
|
-
|
|
133
|
+
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**.
|
|
122
134
|
|
|
123
|
-
|
|
135
|
+
Both readers participate:
|
|
124
136
|
|
|
125
|
-
|
|
137
|
+
- Built-in `read`: reproduce its current LF-normalized output and truncation format.
|
|
138
|
+
- `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.
|
|
126
139
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
140
|
+
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.
|
|
141
|
+
|
|
142
|
+
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.
|
|
143
|
+
|
|
144
|
+
## Edit safety and compatibility
|
|
145
|
+
|
|
146
|
+
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.
|
|
131
147
|
|
|
132
|
-
|
|
148
|
+
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.
|
|
133
149
|
|
|
134
|
-
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
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.
|
|
153
|
+
|
|
154
|
+
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.
|
|
155
|
+
|
|
156
|
+
## Development
|
|
157
|
+
|
|
158
|
+
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
159
|
|
|
136
160
|
```bash
|
|
137
|
-
npm run check --workspace
|
|
138
|
-
npm pack --dry-run --workspace
|
|
139
|
-
npm publish --workspace=@khanhicetea/pi-better-tool
|
|
161
|
+
npm run check --workspace @khanhicetea/pi-better-tool
|
|
162
|
+
npm pack --dry-run --workspace @khanhicetea/pi-better-tool
|
|
140
163
|
```
|
|
141
164
|
|
|
165
|
+
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.
|
|
166
|
+
|
|
142
167
|
## License
|
|
143
168
|
|
|
144
169
|
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanhicetea/pi-better-tool",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "Context-aware tools for pi: safe edit recovery and syntax-aware read_symbol for whole functions, methods, and classes by line or name",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -13,8 +13,20 @@
|
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/khanhicetea/pi-kit/issues"
|
|
15
15
|
},
|
|
16
|
-
"keywords": [
|
|
17
|
-
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"edit",
|
|
20
|
+
"read",
|
|
21
|
+
"symbols",
|
|
22
|
+
"tree-sitter",
|
|
23
|
+
"tools"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
18
30
|
"scripts": {
|
|
19
31
|
"test": "vitest run",
|
|
20
32
|
"typecheck": "tsc --noEmit",
|
|
@@ -33,12 +45,29 @@
|
|
|
33
45
|
"vitest": "^3.2.0"
|
|
34
46
|
},
|
|
35
47
|
"pi": {
|
|
36
|
-
"extensions": [
|
|
48
|
+
"extensions": [
|
|
49
|
+
"./src/index.ts"
|
|
50
|
+
]
|
|
37
51
|
},
|
|
38
52
|
"engines": {
|
|
39
53
|
"node": ">=20"
|
|
40
54
|
},
|
|
41
55
|
"publishConfig": {
|
|
42
56
|
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@ast-grep/lang-bash": "0.0.8",
|
|
60
|
+
"@ast-grep/lang-c": "0.0.6",
|
|
61
|
+
"@ast-grep/lang-cpp": "0.0.6",
|
|
62
|
+
"@ast-grep/lang-csharp": "0.0.6",
|
|
63
|
+
"@ast-grep/lang-go": "0.0.6",
|
|
64
|
+
"@ast-grep/lang-java": "0.0.7",
|
|
65
|
+
"@ast-grep/lang-kotlin": "0.0.7",
|
|
66
|
+
"@ast-grep/lang-php": "0.0.7",
|
|
67
|
+
"@ast-grep/lang-python": "0.0.6",
|
|
68
|
+
"@ast-grep/lang-ruby": "0.0.7",
|
|
69
|
+
"@ast-grep/lang-rust": "0.0.7",
|
|
70
|
+
"@ast-grep/lang-swift": "0.0.8",
|
|
71
|
+
"@ast-grep/napi": "0.45.3"
|
|
43
72
|
}
|
|
44
73
|
}
|
package/src/diagnostics.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-better-tool — better built-in tools for the pi coding agent.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - `edit` —
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Ships a safe edit override and a separate syntax-aware source reader:
|
|
5
|
+
* - `edit` — exact replacement with actionable recovery context.
|
|
6
|
+
* - `read_symbol` — whole symbols by containing line or exact name.
|
|
7
|
+
* Built-in `read` remains available for text, images, and explicit line ranges.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { registerBetterEditTool } from "./tool.ts";
|
|
12
|
+
import { registerReadSymbolTool } from "./read-symbol.ts";
|
|
13
|
+
|
|
14
|
+
export { executeReadSymbol, registerReadSymbolTool, readSymbolSchema } from "./read-symbol.ts";
|
|
15
|
+
export type { ReadSymbolInput, ReadSymbolResult } from "./read-symbol.ts";
|
|
12
16
|
|
|
13
17
|
export { registerBetterEditTool, executeBetterEdit, prepareEditArguments, betterEditSchema } from "./tool.ts";
|
|
14
18
|
export type {
|
|
@@ -29,4 +33,5 @@ export type { AnalyzeOptions, EditFailure, EditOp, EditAnalysis } from "./apply.
|
|
|
29
33
|
|
|
30
34
|
export default function (pi: ExtensionAPI) {
|
|
31
35
|
registerBetterEditTool(pi);
|
|
36
|
+
registerReadSymbolTool(pi);
|
|
32
37
|
}
|
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
|
+
}
|
package/src/read-evidence.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type ExtensionContext,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { normalizeToLF } from "./text.ts";
|
|
9
|
+
import { buildSymbolRead, type ReadSymbolInput } from "./read-symbol.ts";
|
|
9
10
|
|
|
10
11
|
export interface ReadEvidence {
|
|
11
12
|
/** 0-based, end-exclusive offsets in LF-normalized, BOM-stripped content. */
|
|
@@ -18,6 +19,8 @@ export interface ReadEvidence {
|
|
|
18
19
|
|
|
19
20
|
interface ReadCall {
|
|
20
21
|
id: string;
|
|
22
|
+
name: "read" | "read_symbol";
|
|
23
|
+
arguments: Record<string, unknown>;
|
|
21
24
|
path: string;
|
|
22
25
|
offset?: number;
|
|
23
26
|
limit?: number;
|
|
@@ -69,11 +72,13 @@ export async function findLatestReadEvidence(
|
|
|
69
72
|
for (const message of messages) {
|
|
70
73
|
if (message.role === "assistant") {
|
|
71
74
|
for (const item of (message as StoredAssistant).content) {
|
|
72
|
-
if (item.type !== "toolCall" || item.name !== "read" || typeof item.id !== "string") continue;
|
|
75
|
+
if (item.type !== "toolCall" || (item.name !== "read" && item.name !== "read_symbol") || typeof item.id !== "string") continue;
|
|
73
76
|
const args = item.arguments as Record<string, unknown> | undefined;
|
|
74
77
|
if (!args || typeof args.path !== "string") continue;
|
|
75
78
|
calls.push({
|
|
76
79
|
id: item.id,
|
|
80
|
+
name: item.name,
|
|
81
|
+
arguments: args,
|
|
77
82
|
path: args.path,
|
|
78
83
|
offset: typeof args.offset === "number" ? args.offset : undefined,
|
|
79
84
|
limit: typeof args.limit === "number" ? args.limit : undefined,
|
|
@@ -81,7 +86,7 @@ export async function findLatestReadEvidence(
|
|
|
81
86
|
}
|
|
82
87
|
} else if (message.role === "toolResult") {
|
|
83
88
|
const result = message as StoredToolResult;
|
|
84
|
-
if (result.toolName === "read") results.set(result.toolCallId, result);
|
|
89
|
+
if (result.toolName === "read" || result.toolName === "read_symbol") results.set(result.toolCallId, result);
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
|
|
@@ -100,7 +105,17 @@ export async function findLatestReadEvidence(
|
|
|
100
105
|
// Never fall back to older intent when the newest same-file read is
|
|
101
106
|
// missing, failed, malformed, or stale.
|
|
102
107
|
const result = results.get(call.id);
|
|
103
|
-
if (!result || result.isError) return null;
|
|
108
|
+
if (!result || result.isError || result.toolName !== call.name) return null;
|
|
109
|
+
if (call.name === "read_symbol") {
|
|
110
|
+
if (result.content.length !== 1 || result.content[0].type !== "text") return null;
|
|
111
|
+
try {
|
|
112
|
+
// Regenerate from current source and original arguments, not untrusted
|
|
113
|
+
// details. Snapshot, selector, envelope, and visible bytes must agree.
|
|
114
|
+
const expected = await buildSymbolRead(call.arguments as ReadSymbolInput, normalizedContent);
|
|
115
|
+
if (result.content[0].text !== expected.content[0].text) return null;
|
|
116
|
+
return expected.details.visible ?? null;
|
|
117
|
+
} catch { return null; }
|
|
118
|
+
}
|
|
104
119
|
return evidenceFromBuiltinRead(normalizedContent, call, result.content);
|
|
105
120
|
}
|
|
106
121
|
return null;
|
|
@@ -155,7 +170,9 @@ export function evidenceFromBuiltinRead(
|
|
|
155
170
|
const startLine = startIndex + 1;
|
|
156
171
|
const endLine = startLine + visibleLines - 1;
|
|
157
172
|
const startOffset = offsetAtLine(content, startLine);
|
|
158
|
-
|
|
173
|
+
// A line-limited/truncated result omits the separator after its final
|
|
174
|
+
// displayed line. Do not authorize an edit anchor through unseen bytes.
|
|
175
|
+
const endOffset = startOffset + truncation.content.length;
|
|
159
176
|
return { startOffset, endOffset, startLine, endLine };
|
|
160
177
|
}
|
|
161
178
|
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type, type Static } from "typebox";
|
|
4
|
+
import { boundCompleteOutput, fenceFor } from "./diagnostics.ts";
|
|
5
|
+
import { resolveToolPath } from "./paths.ts";
|
|
6
|
+
import { readSource, sourceReadError } from "./source-file.ts";
|
|
7
|
+
import { indexSymbols, languageForPath, SUPPORTED_LANGUAGES, type SourceSymbol } from "./symbols.ts";
|
|
8
|
+
import { getLineSpans } from "./text.ts";
|
|
9
|
+
|
|
10
|
+
export const readSymbolSchema = Type.Object({
|
|
11
|
+
path: Type.String({ minLength: 1, maxLength: 4096, description: "Local source file path (relative, absolute, @path, ~/path, or file URL)." }),
|
|
12
|
+
line: Type.Optional(Type.Integer({ minimum: 1, description: "Read the innermost symbol containing this 1-based line. Combine with symbol to disambiguate duplicate names." })),
|
|
13
|
+
column: Type.Optional(Type.Integer({ minimum: 1, description: "Optional 1-based UTF-16 column with line, to distinguish symbols on the same line." })),
|
|
14
|
+
symbol: Type.Optional(Type.String({ minLength: 1, maxLength: 256, description: "Exact symbol name or qualified name, for example Server.run. Omit both symbol and line to list a symbol outline." })),
|
|
15
|
+
parent: Type.Optional(Type.Integer({ minimum: 0, maximum: 20, description: "Move outward this many enclosing symbols after selection (default 0)." })),
|
|
16
|
+
context: Type.Optional(Type.Integer({ minimum: 0, maximum: 20, description: "Extra whole lines before and after the selected symbol (default 0)." })),
|
|
17
|
+
offset: Type.Optional(Type.Integer({ minimum: 1, description: "1-based position within the selected symbol/context or outline, not a file line. Use the exact continuation call from a partial result." })),
|
|
18
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1800, description: "Maximum source lines (default 1000) or outline entries (default 50). Output also has a 48 KiB hard limit." })),
|
|
19
|
+
});
|
|
20
|
+
export type ReadSymbolInput = Static<typeof readSymbolSchema>;
|
|
21
|
+
|
|
22
|
+
export interface VisibleSource {
|
|
23
|
+
startLine: number;
|
|
24
|
+
endLine: number;
|
|
25
|
+
startOffset: number;
|
|
26
|
+
endOffset: number;
|
|
27
|
+
}
|
|
28
|
+
export interface ReadSymbolResult {
|
|
29
|
+
content: Array<{ type: "text"; text: string }>;
|
|
30
|
+
details: {
|
|
31
|
+
mode: "symbol" | "outline";
|
|
32
|
+
snapshot: string;
|
|
33
|
+
symbol?: SourceSymbol;
|
|
34
|
+
visible?: VisibleSource;
|
|
35
|
+
complete: boolean;
|
|
36
|
+
nextCall?: ReadSymbolInput;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const MAX_OUTPUT_BYTES = 48 * 1024;
|
|
40
|
+
const MAX_OUTPUT_LINES = 1950;
|
|
41
|
+
const label = (text: string) => JSON.stringify(text.length > 300 ? `${text.slice(0, 297)}…` : text);
|
|
42
|
+
const callText = (input: ReadSymbolInput) => `read_symbol ${JSON.stringify(input)}`;
|
|
43
|
+
|
|
44
|
+
export function validateReadSymbolInput(input: ReadSymbolInput): void {
|
|
45
|
+
if (!input || typeof input.path !== "string" || !input.path.length || input.path.length > 4096) throw new Error("read_symbol requires a non-empty path of at most 4096 characters.");
|
|
46
|
+
if (input.symbol !== undefined && (typeof input.symbol !== "string" || !input.symbol.trim() || input.symbol.length > 256)) throw new Error("read_symbol symbol must be a non-empty name of at most 256 characters.");
|
|
47
|
+
for (const [key, min, max] of [["line", 1, Number.MAX_SAFE_INTEGER], ["column", 1, Number.MAX_SAFE_INTEGER], ["parent", 0, 20], ["context", 0, 20], ["offset", 1, Number.MAX_SAFE_INTEGER], ["limit", 1, 1800]] as const) {
|
|
48
|
+
const value = input[key];
|
|
49
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < min || value > max)) throw new Error(`read_symbol ${key} must be an integer from ${min} to ${max}.`);
|
|
50
|
+
}
|
|
51
|
+
if (input.column !== undefined && input.line === undefined) throw new Error("read_symbol column requires line.");
|
|
52
|
+
if ((input.parent || input.context) && input.line === undefined && input.symbol === undefined) throw new Error("read_symbol parent/context requires a line or symbol selector; omit them to get an outline.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function candidateLine(path: string, symbol: SourceSymbol): string {
|
|
56
|
+
return `- ${label(symbol.qualifiedName)} (${symbol.kind}), lines ${symbol.startLine}-${symbol.endLine}. ${callText({ path, line: symbol.startLine, column: symbol.startColumn, symbol: symbol.qualifiedName.length <= 256 ? symbol.qualifiedName : undefined })}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Non-copyable preview for failed selection; never presented as a complete symbol. */
|
|
60
|
+
function fallbackContext(input: ReadSymbolInput, content: string, reason: string, atLine = input.line ?? 1): Error {
|
|
61
|
+
const lines = content.split("\n");
|
|
62
|
+
const center = Math.min(Math.max(atLine, 1), lines.length);
|
|
63
|
+
const start = Math.max(1, center - 5);
|
|
64
|
+
const end = Math.min(lines.length, center + 10);
|
|
65
|
+
const preview = lines.slice(start - 1, end).map((line, index) => `${start + index}: ${line.length > 240 ? `${line.slice(0, 240)}… [line clipped]` : line}`).join("\n");
|
|
66
|
+
return new Error(`${reason}\nFile has ${lines.length} lines. Preview only, not a complete symbol or retryable edit snippet:\n${preview}\nNext: read ${JSON.stringify({ path: input.path, offset: start, limit: end - start + 1 })}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function inside(symbol: SourceSymbol, line: number, column?: number): boolean {
|
|
70
|
+
if (line < symbol.startLine || line > symbol.endLine) return false;
|
|
71
|
+
if (column !== undefined && ((line === symbol.startLine && column < symbol.startColumn) || (line === symbol.endLine && column >= symbol.endColumn))) return false;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function selectSymbol(input: ReadSymbolInput, symbols: SourceSymbol[]): SourceSymbol {
|
|
76
|
+
let matches = symbols.filter((symbol) =>
|
|
77
|
+
(input.symbol === undefined || symbol.name === input.symbol || symbol.qualifiedName === input.symbol) &&
|
|
78
|
+
(input.line === undefined || inside(symbol, input.line, input.column)),
|
|
79
|
+
);
|
|
80
|
+
if (input.symbol === undefined) {
|
|
81
|
+
// Keep innermost declarations, but never choose arbitrarily between siblings.
|
|
82
|
+
const matched = new Set(matches);
|
|
83
|
+
const ancestors = new Set<SourceSymbol>();
|
|
84
|
+
// Parents precede children in the index. Propagate once in reverse,
|
|
85
|
+
// rather than walking every ancestor chain in deeply nested source.
|
|
86
|
+
for (let index = symbols.length - 1; index >= 0; index--) {
|
|
87
|
+
const symbol = symbols[index];
|
|
88
|
+
if (symbol.parent !== undefined && (matched.has(symbol) || ancestors.has(symbol))) ancestors.add(symbols[symbol.parent]);
|
|
89
|
+
}
|
|
90
|
+
matches = matches.filter((symbol) => !ancestors.has(symbol));
|
|
91
|
+
}
|
|
92
|
+
if (matches.length !== 1) {
|
|
93
|
+
const candidates = matches.length ? matches : [...symbols].sort((a, b) => {
|
|
94
|
+
if (input.line !== undefined) return Math.abs(a.startLine - input.line) - Math.abs(b.startLine - input.line);
|
|
95
|
+
const query = input.symbol?.toLowerCase() ?? "";
|
|
96
|
+
return Number(b.qualifiedName.toLowerCase().includes(query)) - Number(a.qualifiedName.toLowerCase().includes(query));
|
|
97
|
+
});
|
|
98
|
+
throw new Error([
|
|
99
|
+
matches.length ? `Ambiguous symbol selection: ${matches.length} candidates. No symbol was selected.` : "No matching symbol. Names are exact and case-sensitive; no nearby symbol was selected automatically.",
|
|
100
|
+
...candidates.slice(0, 8).map((symbol) => candidateLine(input.path, symbol)),
|
|
101
|
+
...(candidates.length > 8 ? [`${candidates.length - 8} more candidates omitted.`] : []),
|
|
102
|
+
`Next: choose a candidate call above, or list the outline with ${callText({ path: input.path })}.`,
|
|
103
|
+
].join("\n"));
|
|
104
|
+
}
|
|
105
|
+
let selected = matches[0];
|
|
106
|
+
for (let depth = 0; depth < (input.parent ?? 0); depth++) {
|
|
107
|
+
if (selected.parent === undefined) throw new Error(`No enclosing symbol at parent=${input.parent}. Outermost available: ${candidateLine(input.path, selected)}\nRetry with a smaller parent value.`);
|
|
108
|
+
selected = symbols[selected.parent];
|
|
109
|
+
}
|
|
110
|
+
return selected;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fits(text: string): boolean {
|
|
114
|
+
return Buffer.byteLength(text, "utf8") <= MAX_OUTPUT_BYTES && text.split("\n").length <= MAX_OUTPUT_LINES;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Pure snapshot-to-result path, also used to verify stored read evidence. */
|
|
118
|
+
export async function buildSymbolRead(input: ReadSymbolInput, content: string, signal?: AbortSignal): Promise<ReadSymbolResult> {
|
|
119
|
+
validateReadSymbolInput(input);
|
|
120
|
+
const lines = content.split("\n");
|
|
121
|
+
if (input.line !== undefined && input.line > lines.length) throw fallbackContext(input, content, `line=${input.line} is beyond EOF. Valid lines: 1-${lines.length}.`);
|
|
122
|
+
if (input.column !== undefined && input.column > lines[input.line! - 1].length + 1) throw fallbackContext(input, content, `column=${input.column} is outside line ${input.line}.`);
|
|
123
|
+
const parserPath = resolveToolPath(input.path, "/");
|
|
124
|
+
if (!languageForPath(parserPath)) throw fallbackContext(input, content, `Unsupported source type. read_symbol supports ${SUPPORTED_LANGUAGES}. Use read for this file.`);
|
|
125
|
+
let index: Awaited<ReturnType<typeof indexSymbols>>;
|
|
126
|
+
try { index = await indexSymbols(parserPath, content, signal); }
|
|
127
|
+
catch (error) {
|
|
128
|
+
signal?.throwIfAborted();
|
|
129
|
+
throw fallbackContext(input, content, `Symbol parser unavailable or analysis limit reached: ${error instanceof Error ? error.message.slice(0, 500) : "unknown parser error"}. Use read instead.`);
|
|
130
|
+
}
|
|
131
|
+
signal?.throwIfAborted();
|
|
132
|
+
if (index.errorLine !== undefined) throw fallbackContext(input, content, `Syntax error or incomplete syntax near line ${index.errorLine}; complete symbol boundaries are not reliable.`, input.line ?? index.errorLine);
|
|
133
|
+
const snapshot = createHash("sha256").update(content).digest("hex");
|
|
134
|
+
const header = `Source ${label(input.path)} (${lines.length} lines). Snapshot sha256:${snapshot}`;
|
|
135
|
+
const offset = input.offset ?? 1;
|
|
136
|
+
if (input.line === undefined && input.symbol === undefined) {
|
|
137
|
+
const total = index.symbols.length;
|
|
138
|
+
if (offset > Math.max(1, total)) throw new Error(`Outline offset=${offset} is beyond ${total} entries. Next: ${callText({ ...input, offset: Math.max(1, total - 49) })}`);
|
|
139
|
+
const entries: string[] = [];
|
|
140
|
+
let cursor = offset - 1;
|
|
141
|
+
for (; cursor < Math.min(total, offset - 1 + (input.limit ?? 50)); cursor++) {
|
|
142
|
+
const entry = candidateLine(input.path, index.symbols[cursor]);
|
|
143
|
+
if (!fits(`${header}\n${entries.join("\n")}\n${entry}\n${" ".repeat(6000)}`)) break;
|
|
144
|
+
entries.push(entry);
|
|
145
|
+
}
|
|
146
|
+
const nextCall = cursor < total ? { ...input, offset: cursor + 1 } : undefined;
|
|
147
|
+
const text = `${header}\nSymbol outline: ${total} declarations, ${entries.length} shown.${total === 0 ? " No symbols found; use read for source text." : ""}\n${entries.join("\n")}${nextCall ? `\nMore entries. Next: ${callText(nextCall)}` : ""}`;
|
|
148
|
+
return { content: [{ type: "text", text }], details: { mode: "outline", snapshot, complete: !nextCall, nextCall } };
|
|
149
|
+
}
|
|
150
|
+
if (!index.symbols.length) throw fallbackContext(input, content, "No symbol declarations found in this file.");
|
|
151
|
+
const selected = selectSymbol(input, index.symbols);
|
|
152
|
+
const start = Math.max(1, selected.startLine - (input.context ?? 0));
|
|
153
|
+
const end = Math.min(lines.length, selected.endLine + (input.context ?? 0));
|
|
154
|
+
const count = end - start + 1;
|
|
155
|
+
if (offset > count) throw new Error(`Symbol ${label(selected.qualifiedName)} spans lines ${selected.startLine}-${selected.endLine}; selected region has ${count} lines. offset=${offset} is outside it. Next: ${callText({ ...input, offset: 1 })}`);
|
|
156
|
+
const firstLine = start + offset - 1;
|
|
157
|
+
let lastLine = Math.min(end, firstLine + (input.limit ?? 1000) - 1);
|
|
158
|
+
const parents: string[] = [];
|
|
159
|
+
let parent = selected.parent;
|
|
160
|
+
while (parent !== undefined && parents.length < 20) { parents.unshift(label(index.symbols[parent].qualifiedName)); parent = index.symbols[parent].parent; }
|
|
161
|
+
const prefix = `${header}\nSymbol ${label(selected.qualifiedName)} (${selected.kind}), lines ${selected.startLine}-${selected.endLine}.${parents.length ? `\nEnclosing: ${parents.join(" > ")}` : ""}`;
|
|
162
|
+
let text = "";
|
|
163
|
+
let snippet = "";
|
|
164
|
+
let nextCall: ReadSymbolInput | undefined;
|
|
165
|
+
// Whole-line pages only. Account for fences, metadata, and continuation JSON.
|
|
166
|
+
while (lastLine >= firstLine) {
|
|
167
|
+
snippet = lines.slice(firstLine - 1, lastLine).join("\n");
|
|
168
|
+
const fence = fenceFor(snippet);
|
|
169
|
+
nextCall = lastLine < end ? { ...input, offset: lastLine - start + 2 } : undefined;
|
|
170
|
+
text = `${prefix}\nShowing file lines ${firstLine}-${lastLine} of selected lines ${start}-${end}. ${firstLine === start && lastLine === end ? "Complete selection." : "Partial selection; do not treat this page as the whole symbol."}\n\n${fence}\n${snippet}\n${fence}${nextCall ? `\n\nNext: ${callText(nextCall)}` : ""}`;
|
|
171
|
+
if (fits(text)) break;
|
|
172
|
+
// Remove a proportional chunk first; small pages shrink one line at a time.
|
|
173
|
+
lastLine -= Math.max(1, Math.floor((lastLine - firstLine + 1) / 4));
|
|
174
|
+
}
|
|
175
|
+
if (lastLine < firstLine) throw fallbackContext(input, content, `File line ${firstLine} cannot fit as a complete line in the 48 KiB output budget. No partial edit snippet was returned.`, firstLine);
|
|
176
|
+
const spans = getLineSpans(content);
|
|
177
|
+
const startOffset = spans[firstLine - 1]?.start ?? content.length;
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: "text", text }],
|
|
180
|
+
details: {
|
|
181
|
+
mode: "symbol", snapshot, symbol: selected,
|
|
182
|
+
visible: { startLine: firstLine, endLine: lastLine, startOffset, endOffset: startOffset + snippet.length },
|
|
183
|
+
complete: firstLine === start && lastLine === end, nextCall,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function executeReadSymbol(input: ReadSymbolInput, signal: AbortSignal | undefined, ctx: Pick<ExtensionContext, "cwd">): Promise<ReadSymbolResult> {
|
|
189
|
+
validateReadSymbolInput(input);
|
|
190
|
+
const path = resolveToolPath(input.path, ctx.cwd);
|
|
191
|
+
let content: string;
|
|
192
|
+
try { content = await readSource(path, signal); }
|
|
193
|
+
catch (error) { signal?.throwIfAborted(); throw new Error(boundCompleteOutput(await sourceReadError(path, error))); }
|
|
194
|
+
try { return await buildSymbolRead(input, content, signal); }
|
|
195
|
+
catch (error) {
|
|
196
|
+
signal?.throwIfAborted();
|
|
197
|
+
throw new Error(boundCompleteOutput(error instanceof Error ? error.message : String(error)));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function registerReadSymbolTool(pi: ExtensionAPI): void {
|
|
202
|
+
pi.registerTool({
|
|
203
|
+
name: "read_symbol", label: "read_symbol",
|
|
204
|
+
description: `Read a complete function, method, class, or type from a local source file by containing line or exact symbol name. Supports ${SUPPORTED_LANGUAGES}. With path only, lists a symbol outline. Includes enclosing names, exact source ranges, and actionable failure context. Output is bounded to 48 KiB/1950 lines; partial results include an exact continuation call. Does not replace read for ordinary text or images.`,
|
|
205
|
+
promptSnippet: "Read whole source symbols by line/name, or list a file's symbol outline",
|
|
206
|
+
promptGuidelines: [
|
|
207
|
+
"Use read_symbol with path and line after grep/edit identifies a code location, instead of guessing successive read offsets to find the function boundary.",
|
|
208
|
+
"Use read_symbol with symbol for an exact name or qualified name; use path alone for an outline. Use parent to include an enclosing function or class.",
|
|
209
|
+
"For read_symbol partial output, use the supplied continuation arguments. offset is relative to the selection, not a file line. Only displayed source is evidence for edit.",
|
|
210
|
+
],
|
|
211
|
+
parameters: readSymbolSchema,
|
|
212
|
+
async execute(_id, input, signal, _onUpdate, ctx) { return executeReadSymbol(input, signal, ctx); },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, opendir } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { MAX_SOURCE_BYTES } from "./symbols.ts";
|
|
5
|
+
import { normalizeToLF, splitBom } from "./text.ts";
|
|
6
|
+
|
|
7
|
+
/** Bounded local source read; no silent decoding loss and no special files. */
|
|
8
|
+
export async function readSource(path: string, signal?: AbortSignal): Promise<string> {
|
|
9
|
+
signal?.throwIfAborted();
|
|
10
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
11
|
+
try {
|
|
12
|
+
const stat = await handle.stat();
|
|
13
|
+
if (!stat.isFile()) throw new Error("The path is not a regular source file.");
|
|
14
|
+
if (stat.size > MAX_SOURCE_BYTES) throw new Error("Source exceeds the 2 MiB symbol analysis limit; use read with offset/limit.");
|
|
15
|
+
const buffer = Buffer.alloc(Math.min(stat.size + 1, MAX_SOURCE_BYTES + 1));
|
|
16
|
+
let length = 0;
|
|
17
|
+
while (length < buffer.length) {
|
|
18
|
+
signal?.throwIfAborted();
|
|
19
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
|
|
20
|
+
if (!bytesRead) break;
|
|
21
|
+
length += bytesRead;
|
|
22
|
+
}
|
|
23
|
+
const after = await handle.stat();
|
|
24
|
+
if (length !== stat.size || stat.size !== after.size || stat.mtimeMs !== after.mtimeMs || stat.ctimeMs !== after.ctimeMs) throw new Error("The source changed during the read; retry read_symbol.");
|
|
25
|
+
const bytes = buffer.subarray(0, length);
|
|
26
|
+
let content: string;
|
|
27
|
+
try { content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
|
|
28
|
+
catch { throw new Error("The source is not valid UTF-8; convert its encoding before symbol parsing."); }
|
|
29
|
+
if (bytes.includes(0)) throw new Error("The source contains NUL bytes; binary/UTF-16 input is not supported.");
|
|
30
|
+
signal?.throwIfAborted();
|
|
31
|
+
return splitBom(normalizeToLF(content)).text;
|
|
32
|
+
} finally {
|
|
33
|
+
await handle.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Only inspect a bounded number of siblings; never perform a hidden repo scan. */
|
|
38
|
+
export async function sourceReadError(path: string, error: unknown): Promise<string> {
|
|
39
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
40
|
+
const message = error instanceof Error ? error.message.slice(0, 1000) : String(error).slice(0, 1000);
|
|
41
|
+
const lines = [`Could not read source ${JSON.stringify(path)}: ${message}`];
|
|
42
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
43
|
+
const names: string[] = [];
|
|
44
|
+
try {
|
|
45
|
+
const directory = await opendir(dirname(path));
|
|
46
|
+
let inspected = 0;
|
|
47
|
+
for await (const entry of directory) {
|
|
48
|
+
if (entry.isFile()) names.push(entry.name);
|
|
49
|
+
if (++inspected >= 100) break;
|
|
50
|
+
}
|
|
51
|
+
} catch { /* Parent may also be missing or inaccessible. */ }
|
|
52
|
+
const stem = basename(path).split(".")[0].toLowerCase();
|
|
53
|
+
names.sort((a, b) => Number(b.toLowerCase().includes(stem)) - Number(a.toLowerCase().includes(stem)) || a.localeCompare(b));
|
|
54
|
+
if (names.length) lines.push("Nearby file candidates (bounded listing, not automatic path corrections):", ...names.slice(0, 8).map((name) => ` ${JSON.stringify(join(dirname(path), name))}`));
|
|
55
|
+
lines.push("Verify the path and retry read_symbol; use find/ls if the parent path is wrong.");
|
|
56
|
+
} else if (code === "EACCES" || code === "EPERM") {
|
|
57
|
+
lines.push("Check file permissions. Do not retry the same call until access changes.");
|
|
58
|
+
} else lines.push("Use read for ordinary text, images, or bounded line ranges; no symbol boundaries were returned.");
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
package/src/symbols.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { basename, extname } from "node:path";
|
|
2
|
+
import type { SgNode } from "@ast-grep/napi";
|
|
3
|
+
|
|
4
|
+
export const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
const MAX_NODES = 100_000;
|
|
6
|
+
const MAX_SYMBOLS = 10_000;
|
|
7
|
+
|
|
8
|
+
const LANGUAGES: Record<string, string> = {
|
|
9
|
+
".js": "JavaScript", ".jsx": "Tsx", ".mjs": "JavaScript", ".cjs": "JavaScript",
|
|
10
|
+
".ts": "TypeScript", ".tsx": "Tsx", ".mts": "TypeScript", ".cts": "TypeScript",
|
|
11
|
+
".py": "python", ".pyi": "python", ".go": "go", ".rs": "rust",
|
|
12
|
+
".bash": "bash", ".bats": "bash", ".command": "bash", ".ksh": "bash", ".sh": "bash", ".zsh": "bash",
|
|
13
|
+
".c": "c", ".h": "c", ".cc": "cpp", ".cp": "cpp", ".cpp": "cpp", ".cxx": "cpp", ".c++": "cpp", ".cu": "cpp", ".hh": "cpp", ".hpp": "cpp", ".hxx": "cpp", ".ino": "cpp",
|
|
14
|
+
".cs": "csharp", ".java": "java", ".kt": "kotlin", ".ktm": "kotlin", ".kts": "kotlin",
|
|
15
|
+
".php": "php", ".phtml": "php", ".rb": "ruby", ".rbw": "ruby", ".rake": "ruby", ".gemspec": "ruby", ".swift": "swift",
|
|
16
|
+
};
|
|
17
|
+
const FILENAMES: Record<string, string> = { Gemfile: "ruby", Rakefile: "ruby" };
|
|
18
|
+
export const SUPPORTED_LANGUAGES = "Bash, C, C++, C#, Java, JavaScript/JSX, TypeScript/TSX, Kotlin, PHP, Python, Ruby, Rust, Swift, and Go";
|
|
19
|
+
export function languageForPath(path: string): string | undefined {
|
|
20
|
+
return LANGUAGES[extname(path).toLowerCase()] ?? FILENAMES[basename(path)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SourceSymbol {
|
|
24
|
+
name: string;
|
|
25
|
+
qualifiedName: string;
|
|
26
|
+
kind: string;
|
|
27
|
+
startLine: number;
|
|
28
|
+
endLine: number;
|
|
29
|
+
/** 1-based UTF-16 columns, end-exclusive. */
|
|
30
|
+
startColumn: number;
|
|
31
|
+
endColumn: number;
|
|
32
|
+
parent?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SymbolIndex {
|
|
36
|
+
symbols: SourceSymbol[];
|
|
37
|
+
/** Never claim complete symbol boundaries for a malformed tree. */
|
|
38
|
+
errorLine?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Parser = typeof import("@ast-grep/napi");
|
|
42
|
+
type DynamicLanguageModule = { default: import("@ast-grep/napi").DynamicLangRegistrations[string] };
|
|
43
|
+
|
|
44
|
+
// Native dynamic language registration is process-wide. Keep only parser setup
|
|
45
|
+
// across /reload, never project content or session evidence. ast-grep initializes
|
|
46
|
+
// its registry only once, so upgrading from a smaller grammar set requires a Pi
|
|
47
|
+
// restart before the newly shipped grammars can be registered.
|
|
48
|
+
const parserKey = Symbol.for("pi-better-tool.parser.0.45.3.v2");
|
|
49
|
+
const parserState = globalThis as typeof globalThis & { [parserKey]?: Promise<Parser> };
|
|
50
|
+
const DYNAMIC_LANGUAGE_MODULES: ReadonlyArray<readonly [string, () => Promise<DynamicLanguageModule>]> = [
|
|
51
|
+
["bash", () => import("@ast-grep/lang-bash")],
|
|
52
|
+
["c", () => import("@ast-grep/lang-c")],
|
|
53
|
+
["cpp", () => import("@ast-grep/lang-cpp")],
|
|
54
|
+
["csharp", () => import("@ast-grep/lang-csharp")],
|
|
55
|
+
["java", () => import("@ast-grep/lang-java")],
|
|
56
|
+
["kotlin", () => import("@ast-grep/lang-kotlin")],
|
|
57
|
+
["php", () => import("@ast-grep/lang-php")],
|
|
58
|
+
["python", () => import("@ast-grep/lang-python")],
|
|
59
|
+
["ruby", () => import("@ast-grep/lang-ruby")],
|
|
60
|
+
["rust", () => import("@ast-grep/lang-rust")],
|
|
61
|
+
["swift", () => import("@ast-grep/lang-swift")],
|
|
62
|
+
["go", () => import("@ast-grep/lang-go")],
|
|
63
|
+
];
|
|
64
|
+
async function loadParser(): Promise<Parser> {
|
|
65
|
+
return parserState[parserKey] ??= (async () => {
|
|
66
|
+
const api = await import("@ast-grep/napi");
|
|
67
|
+
const languages: import("@ast-grep/napi").DynamicLangRegistrations = {};
|
|
68
|
+
const modules = await Promise.allSettled(DYNAMIC_LANGUAGE_MODULES.map(([, load]) => load()));
|
|
69
|
+
for (const [index, [name]] of DYNAMIC_LANGUAGE_MODULES.entries()) {
|
|
70
|
+
const module = modules[index];
|
|
71
|
+
if (module.status !== "fulfilled") continue;
|
|
72
|
+
try {
|
|
73
|
+
// Resolve lazy prebuild getters here. One unavailable grammar must
|
|
74
|
+
// not prevent the built-in JS/TS parsers from working.
|
|
75
|
+
languages[name] = { ...module.value.default };
|
|
76
|
+
} catch { /* Report unavailable language at parse time. */ }
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
if (Object.keys(languages).length) api.registerDynamicLanguage(languages);
|
|
80
|
+
} catch { /* An incompatible native grammar must not disable built-in JS/TS. */ }
|
|
81
|
+
return api;
|
|
82
|
+
})();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const JS_DECLARATIONS = [
|
|
86
|
+
"function_declaration", "generator_function_declaration", "function_expression", "generator_function", "arrow_function",
|
|
87
|
+
"method_definition", "class_declaration", "abstract_class_declaration", "class",
|
|
88
|
+
"interface_declaration", "type_alias_declaration", "enum_declaration", "internal_module",
|
|
89
|
+
"function_signature", "method_signature", "abstract_method_signature",
|
|
90
|
+
];
|
|
91
|
+
const DECLARATIONS_BY_LANGUAGE: Record<string, ReadonlySet<string>> = {
|
|
92
|
+
JavaScript: new Set(JS_DECLARATIONS), TypeScript: new Set(JS_DECLARATIONS), Tsx: new Set(JS_DECLARATIONS),
|
|
93
|
+
python: new Set(["function_definition", "class_definition"]),
|
|
94
|
+
go: new Set(["function_declaration", "method_declaration", "type_spec", "func_literal"]),
|
|
95
|
+
rust: new Set(["function_item", "function_signature_item", "struct_item", "enum_item", "trait_item", "impl_item", "mod_item", "type_item", "closure_expression"]),
|
|
96
|
+
bash: new Set(["function_definition"]),
|
|
97
|
+
c: new Set(["function_definition", "struct_specifier", "union_specifier", "enum_specifier", "type_definition"]),
|
|
98
|
+
cpp: new Set(["function_definition", "class_specifier", "struct_specifier", "union_specifier", "enum_specifier", "namespace_definition", "alias_declaration"]),
|
|
99
|
+
csharp: new Set(["namespace_declaration", "file_scoped_namespace_declaration", "class_declaration", "struct_declaration", "interface_declaration", "enum_declaration", "record_declaration", "delegate_declaration", "method_declaration", "constructor_declaration", "destructor_declaration", "local_function_statement"]),
|
|
100
|
+
java: new Set(["class_declaration", "interface_declaration", "enum_declaration", "annotation_type_declaration", "record_declaration", "method_declaration", "constructor_declaration", "compact_constructor_declaration"]),
|
|
101
|
+
kotlin: new Set(["class_declaration", "object_declaration", "function_declaration", "secondary_constructor"]),
|
|
102
|
+
php: new Set(["class_declaration", "interface_declaration", "trait_declaration", "enum_declaration", "function_definition", "method_declaration"]),
|
|
103
|
+
ruby: new Set(["class", "module", "method", "singleton_method"]),
|
|
104
|
+
swift: new Set(["class_declaration", "protocol_declaration", "struct_declaration", "enum_declaration", "extension_declaration", "actor_declaration", "function_declaration", "initializer_declaration", "deinitializer_declaration"]),
|
|
105
|
+
};
|
|
106
|
+
const ANONYMOUS_FUNCTIONS = new Set(["arrow_function", "function_expression", "generator_function", "func_literal", "closure_expression"]);
|
|
107
|
+
const BINDINGS = new Set(["variable_declarator", "pair", "public_field_definition", "field_definition", "assignment", "let_declaration"]);
|
|
108
|
+
const LIFECYCLE_NAMES: Record<string, string> = {
|
|
109
|
+
constructor_declaration: "constructor", compact_constructor_declaration: "constructor", secondary_constructor: "constructor",
|
|
110
|
+
destructor_declaration: "destructor", initializer_declaration: "init", deinitializer_declaration: "deinit",
|
|
111
|
+
};
|
|
112
|
+
const NAME_KINDS_BY_LANGUAGE: Record<string, readonly string[]> = {
|
|
113
|
+
c: ["identifier", "field_identifier", "type_identifier"],
|
|
114
|
+
cpp: ["identifier", "field_identifier", "type_identifier", "namespace_identifier", "operator_name"],
|
|
115
|
+
kotlin: ["simple_identifier", "type_identifier"],
|
|
116
|
+
swift: ["simple_identifier", "type_identifier"],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function firstNamedText(node: SgNode, kinds: readonly string[]): string | undefined {
|
|
120
|
+
if (kinds.includes(String(node.kind()))) return node.text();
|
|
121
|
+
for (const kind of kinds) {
|
|
122
|
+
const found = node.find({ rule: { kind } });
|
|
123
|
+
if (found) return found.text();
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function nameForNode(node: SgNode, language: string): string | undefined {
|
|
129
|
+
const directName = node.field("name")?.text();
|
|
130
|
+
if (directName) return directName;
|
|
131
|
+
const lifecycleName = LIFECYCLE_NAMES[String(node.kind())];
|
|
132
|
+
if (lifecycleName) return lifecycleName;
|
|
133
|
+
// Kotlin classes/objects use type_identifier while functions use
|
|
134
|
+
// simple_identifier. Searching for the latter first would accidentally take
|
|
135
|
+
// a nested method name as the enclosing class name.
|
|
136
|
+
const kinds = language === "kotlin"
|
|
137
|
+
? (node.kind() === "function_declaration" ? ["simple_identifier"] : ["type_identifier"])
|
|
138
|
+
: NAME_KINDS_BY_LANGUAGE[language];
|
|
139
|
+
if (!kinds) return undefined;
|
|
140
|
+
const declarator = node.field("declarator");
|
|
141
|
+
return (declarator && firstNamedText(declarator, kinds)) ?? firstNamedText(node, kinds);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isDeclaration(node: SgNode, language: string): boolean {
|
|
145
|
+
if (!DECLARATIONS_BY_LANGUAGE[language]?.has(String(node.kind()))) return false;
|
|
146
|
+
// A typedef that wraps a named C struct/union/enum is one declaration. Keep
|
|
147
|
+
// the outer typedef's full range instead of emitting a duplicate child name.
|
|
148
|
+
return !(language === "c" && ["struct_specifier", "union_specifier", "enum_specifier"].includes(String(node.kind())) && node.parent()?.kind() === "type_definition");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function describeNode(node: SgNode, language: string): { name: string; start: SgNode; end: SgNode } {
|
|
152
|
+
let start = node;
|
|
153
|
+
let end = node;
|
|
154
|
+
let name = nameForNode(node, language);
|
|
155
|
+
const parent = node.parent();
|
|
156
|
+
if (ANONYMOUS_FUNCTIONS.has(String(node.kind())) && parent && BINDINGS.has(String(parent.kind()))) {
|
|
157
|
+
name = parent.field("name")?.text() ?? parent.field("key")?.text() ?? parent.field("left")?.text() ?? parent.field("pattern")?.text() ?? name;
|
|
158
|
+
start = end = parent;
|
|
159
|
+
const declaration = parent.parent();
|
|
160
|
+
if (declaration && ["lexical_declaration", "variable_declaration"].includes(String(declaration.kind())) &&
|
|
161
|
+
declaration.children().filter((child) => child.kind() === "variable_declarator").length === 1) {
|
|
162
|
+
start = end = declaration;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (node.kind() === "impl_item") name = node.field("type")?.text();
|
|
166
|
+
if (node.kind() === "method_declaration") {
|
|
167
|
+
const receiver = node.field("receiver")?.find({ rule: { kind: "type_identifier" } })?.text();
|
|
168
|
+
if (receiver && name) name = `${receiver}.${name}`;
|
|
169
|
+
}
|
|
170
|
+
const wrapper = start.parent();
|
|
171
|
+
if (wrapper && ["export_statement", "decorated_definition"].includes(String(wrapper.kind()))) start = end = wrapper;
|
|
172
|
+
// Rust attributes are siblings, not part of the declaration node.
|
|
173
|
+
let previous = start.prev();
|
|
174
|
+
while (previous?.kind() === "attribute_item") {
|
|
175
|
+
start = previous;
|
|
176
|
+
previous = previous.prev();
|
|
177
|
+
}
|
|
178
|
+
return { name: name ?? `<anonymous@${node.range().start.line + 1}:${node.range().start.column + 1}>`, start, end };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Parse syntax, not regex/braces. No repository code, config, or shell runs. */
|
|
182
|
+
export async function indexSymbols(path: string, content: string, signal?: AbortSignal): Promise<SymbolIndex> {
|
|
183
|
+
signal?.throwIfAborted();
|
|
184
|
+
if (Buffer.byteLength(content, "utf8") > MAX_SOURCE_BYTES) throw new Error("Source exceeds the 2 MiB symbol analysis limit.");
|
|
185
|
+
const language = languageForPath(path);
|
|
186
|
+
if (!language) throw new Error(`Unsupported source type. Symbol parsing supports ${SUPPORTED_LANGUAGES}.`);
|
|
187
|
+
const api = await loadParser();
|
|
188
|
+
signal?.throwIfAborted();
|
|
189
|
+
const root = (await api.parseAsync(language, content)).root();
|
|
190
|
+
signal?.throwIfAborted();
|
|
191
|
+
const symbols: SourceSymbol[] = [];
|
|
192
|
+
const stack: Array<{ node: SgNode; parent?: number }> = [{ node: root }];
|
|
193
|
+
let visited = 0;
|
|
194
|
+
let errorLine: number | undefined;
|
|
195
|
+
while (stack.length) {
|
|
196
|
+
if (++visited > MAX_NODES || symbols.length > MAX_SYMBOLS) throw new Error("Source exceeds the symbol traversal budget; use a bounded read instead.");
|
|
197
|
+
const { node, parent } = stack.pop()!;
|
|
198
|
+
const range = node.range();
|
|
199
|
+
if (node.kind() === "ERROR" || (node.isLeaf() && node.id() !== root.id() && range.start.index === range.end.index)) {
|
|
200
|
+
errorLine ??= range.start.line + 1;
|
|
201
|
+
}
|
|
202
|
+
let enclosing = parent;
|
|
203
|
+
if (isDeclaration(node, language)) {
|
|
204
|
+
const described = describeNode(node, language);
|
|
205
|
+
const start = described.start.range().start;
|
|
206
|
+
const end = described.end.range().end;
|
|
207
|
+
const owner = parent === undefined ? undefined : symbols[parent];
|
|
208
|
+
const qualifiedName = owner ? `${owner.qualifiedName}.${described.name}` : described.name;
|
|
209
|
+
if (qualifiedName.length > 1024) throw new Error("Source exceeds the 1024-character qualified symbol name budget; use a bounded read instead.");
|
|
210
|
+
enclosing = symbols.length;
|
|
211
|
+
symbols.push({
|
|
212
|
+
name: described.name,
|
|
213
|
+
qualifiedName,
|
|
214
|
+
kind: String(node.kind()), startLine: start.line + 1,
|
|
215
|
+
endLine: end.line + (end.column === 0 ? 0 : 1),
|
|
216
|
+
startColumn: start.column + 1, endColumn: end.column + 1, parent,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const children = node.children();
|
|
220
|
+
for (let index = children.length - 1; index >= 0; index--) stack.push({ node: children[index], parent: enclosing });
|
|
221
|
+
// Give cancellation a chance during large traversals.
|
|
222
|
+
if (visited % 2048 === 0) {
|
|
223
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
224
|
+
signal?.throwIfAborted();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { symbols, errorLine };
|
|
228
|
+
}
|
package/src/tool.ts
CHANGED
|
@@ -31,9 +31,7 @@ import {
|
|
|
31
31
|
stat as fsStat,
|
|
32
32
|
writeFile as fsWriteFile,
|
|
33
33
|
} from "node:fs/promises";
|
|
34
|
-
import {
|
|
35
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
36
|
-
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { resolveToolPath as resolveToCwd } from "./paths.ts";
|
|
37
35
|
import { type Static, Type } from "typebox";
|
|
38
36
|
import { analyzeEdits, applyAnalysis, fuzzyFindText, normalizeEdits, type EditOp } from "./apply.ts";
|
|
39
37
|
import {
|
|
@@ -72,31 +70,6 @@ export const betterEditSchema = Type.Object({
|
|
|
72
70
|
|
|
73
71
|
export type BetterEditInput = Static<typeof betterEditSchema>;
|
|
74
72
|
|
|
75
|
-
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
76
|
-
|
|
77
|
-
/** Match pi's built-in path normalization for tool arguments. */
|
|
78
|
-
function normalizeToolPath(input: string): string {
|
|
79
|
-
let path = input.replace(UNICODE_SPACES, " ");
|
|
80
|
-
if (path.startsWith("@")) path = path.slice(1);
|
|
81
|
-
|
|
82
|
-
if (process.platform === "win32" && path.startsWith("/") && !path.startsWith("//") && !path.includes("\\")) {
|
|
83
|
-
const match = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
84
|
-
if (match) path = `${match[1].toUpperCase()}:\\${match[2]?.replaceAll("/", "\\") ?? ""}`;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (path === "~") return homedir();
|
|
88
|
-
if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) {
|
|
89
|
-
return join(homedir(), path.slice(2));
|
|
90
|
-
}
|
|
91
|
-
if (/^file:\/\//.test(path)) return fileURLToPath(path);
|
|
92
|
-
return path;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function resolveToCwd(filePath: string, cwd: string): string {
|
|
96
|
-
const path = normalizeToolPath(filePath);
|
|
97
|
-
return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
73
|
function isSingleEditInput(value: unknown): value is { oldText: string; newText: string } {
|
|
101
74
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
102
75
|
return false;
|
|
@@ -391,7 +364,8 @@ export function registerBetterEditTool(pi: ExtensionAPI, options: BetterEditExec
|
|
|
391
364
|
"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls.",
|
|
392
365
|
"In edit, each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits; merge nearby changes into one edit.",
|
|
393
366
|
"Keep edit edits[].oldText as small as possible while still being unique in the file; do not pad with large unchanged regions.",
|
|
394
|
-
"When edit safely auto-disambiguates repeated text from the latest verified stored-context read, its success message lists bounded remaining candidates for an optional follow-up edit.",
|
|
367
|
+
"When edit safely auto-disambiguates repeated text from the latest verified stored-context read or read_symbol result, its success message lists bounded remaining candidates for an optional follow-up edit.",
|
|
368
|
+
"An edit matching failure applies none of the batch. Fix the reported entries and resubmit the complete batch, not only the failed replacement. Use suggested read_symbol calls when you need the whole enclosing function.",
|
|
395
369
|
"When edit fails, reuse only a fenced snippet explicitly marked retryable. If edit reports low confidence, competing candidates, omitted output, stale evidence, or a write that may have modified the file, read the referenced file/range before retrying.",
|
|
396
370
|
],
|
|
397
371
|
parameters: betterEditSchema,
|