@jerryan/pi-hashline-edit 0.7.2 → 0.7.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 +13 -13
- package/package.json +53 -53
- package/src/edit-diff.ts +6 -5
- package/src/edit-response.ts +3 -0
- package/src/edit.ts +10 -8
- package/src/file-kind.ts +10 -47
- package/src/hashline.ts +31 -28
- package/src/package-info.ts +4 -0
- package/src/read.ts +241 -230
- package/{prompts → tool-descriptions}/edit.md +1 -1
- package/{prompts → tool-descriptions}/read.md +1 -1
- /package/{prompts → tool-descriptions}/edit-snippet.md +0 -0
- /package/{prompts → tool-descriptions}/read-guidelines.md +0 -0
- /package/{prompts → tool-descriptions}/read-snippet.md +0 -0
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ This is a fork of the original [pi-hashline-edit](https://github.com/earendil-wo
|
|
|
15
15
|
- **Single edit shape.** One entry type: `{ range: [start, end], lines: [...] }`. No `op` field, no `append`/`prepend`/`replace_text` ops, no `after`/`before`. The tuple enforces explicit endpoint anchors, eliminating the common "forgot `end`" failure mode.
|
|
16
16
|
- **Standard hex hash alphabet.** `0-9 A-F` instead of `ZPMQVRWSNKTXJBYH`. Hex pairs are more likely to be single tokens.
|
|
17
17
|
- **Symmetric boundary-duplication detection.** Runtime warnings catch duplicated boundary lines on both sides of a replacement, not just trailing.
|
|
18
|
-
- **`read` raw mode.** `raw: true` returns plain text without `LINE#HASH
|
|
18
|
+
- **`read` raw mode.** `raw: true` returns plain text without `LINE#HASH│` anchors, for reads that don't plan to edit.
|
|
19
19
|
- **Inline FNV-1a hashing.** Replaces `xxhashjs` dependency. Always incorporates line index.
|
|
20
20
|
- **Minimal prompt surface.** Prompt text describes what the model needs to use the tool; return-format documentation and error catalogues are omitted.
|
|
21
21
|
- **No legacy compatibility.** The `{ oldText, newText }` substring-replace format is not accepted. The schema is hashline-only.
|
|
@@ -34,12 +34,12 @@ pi install /path/to/pi-hashline-edit
|
|
|
34
34
|
|
|
35
35
|
### `read` — tagged line output
|
|
36
36
|
|
|
37
|
-
Text files are returned with a `LINE#HASH
|
|
37
|
+
Text files are returned with a `LINE#HASH│` prefix on every line. Line numbers may be left-padded within each returned block so the `#HASH│` columns align:
|
|
38
38
|
|
|
39
39
|
```text
|
|
40
|
-
8#A4
|
|
41
|
-
9#3F
|
|
42
|
-
10#B2
|
|
40
|
+
8#A4│function hello() {
|
|
41
|
+
9#3F│ console.log("world");
|
|
42
|
+
10#B2│}
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
- `LINE` — 1-indexed line number.
|
|
@@ -80,22 +80,22 @@ After a successful edit, the response contains a unified diff where context and
|
|
|
80
80
|
Each edit result shows a unified diff with hashline-formatted lines:
|
|
81
81
|
|
|
82
82
|
```text
|
|
83
|
-
8#A4
|
|
84
|
-
-9
|
|
85
|
-
+9#B1
|
|
86
|
-
10#B2
|
|
83
|
+
8#A4│function hello() {
|
|
84
|
+
-9 │ console.log("world");
|
|
85
|
+
+9#B1│ console.log("hashline");
|
|
86
|
+
10#B2│}
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
- Context lines: ` NN#HH
|
|
90
|
-
- Removed lines: `-NN
|
|
91
|
-
- Added lines: `+NN#HH
|
|
89
|
+
- Context lines: ` NN#HH│content` (space prefix)
|
|
90
|
+
- Removed lines: `-NN │content` (no hash, aligned separator)
|
|
91
|
+
- Added lines: `+NN#HH│content` (hash for new anchors)
|
|
92
92
|
- Multiple hunks are shown when edits are far apart.
|
|
93
93
|
|
|
94
94
|
## Design Decisions
|
|
95
95
|
|
|
96
96
|
- **Stale anchors fail.** A hash mismatch means the file has changed since the last `read`. The error includes a snippet with fresh `LINE#HASH` references for the affected lines for immediate retry.
|
|
97
97
|
- **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
|
|
98
|
-
- **Strict patch content.** If `lines` contains `LINE#HASH
|
|
98
|
+
- **Strict patch content.** If `lines` contains `LINE#HASH│` display prefixes or diff `+`/`-` markers, the edit is rejected with `[E_INVALID_PATCH]`. The model must send literal file content; the runtime does not silently strip accidental prefixes.
|
|
99
99
|
- **Full-file deletion guardrail.** Edits that would empty a file with more than 50 lines are rejected with `[E_WOULD_EMPTY]`. Small files show the full diff normally; large deletions are almost always mistakes.
|
|
100
100
|
- **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
|
|
101
101
|
- **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
|
package/package.json
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@jerryan/pi-hashline-edit",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "Hashline read/edit tool override for pi-coding-agent",
|
|
5
|
-
"repository": {
|
|
6
|
-
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/JerryAZR/pi-hashline-edit.git"
|
|
8
|
-
},
|
|
9
|
-
"author": "JerryAZR",
|
|
10
|
-
"publishConfig": {
|
|
11
|
-
"registry": "https://registry.npmjs.org/"
|
|
12
|
-
},
|
|
13
|
-
"keywords": [
|
|
14
|
-
"pi-package",
|
|
15
|
-
"pi",
|
|
16
|
-
"coding-agent",
|
|
17
|
-
"extension",
|
|
18
|
-
"hashline"
|
|
19
|
-
],
|
|
20
|
-
"license": "MIT",
|
|
21
|
-
"files": [
|
|
22
|
-
"index.ts",
|
|
23
|
-
"src",
|
|
24
|
-
"
|
|
25
|
-
"README.md",
|
|
26
|
-
"LICENSE"
|
|
27
|
-
],
|
|
28
|
-
"pi": {
|
|
29
|
-
"extensions": [
|
|
30
|
-
"./index.ts"
|
|
31
|
-
]
|
|
32
|
-
},
|
|
33
|
-
"dependencies": {
|
|
34
|
-
"diff": "^8.0.2",
|
|
35
|
-
"file-type": "^21.3.0"
|
|
36
|
-
},
|
|
37
|
-
"peerDependencies": {
|
|
38
|
-
"@earendil-works/pi-ai": ">=0.74.0",
|
|
39
|
-
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
40
|
-
"@earendil-works/pi-tui": "*",
|
|
41
|
-
"@sinclair/typebox": "*"
|
|
42
|
-
},
|
|
43
|
-
"scripts": {
|
|
44
|
-
"test": "vitest run",
|
|
45
|
-
"test:watch": "vitest"
|
|
46
|
-
},
|
|
47
|
-
"devDependencies": {
|
|
48
|
-
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
49
|
-
"@types/node": "^22.0.0",
|
|
50
|
-
"ajv": "^8.20.0",
|
|
51
|
-
"vitest": "^3.0.0"
|
|
52
|
-
}
|
|
53
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@jerryan/pi-hashline-edit",
|
|
3
|
+
"version": "0.7.4",
|
|
4
|
+
"description": "Hashline read/edit tool override for pi-coding-agent",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/JerryAZR/pi-hashline-edit.git"
|
|
8
|
+
},
|
|
9
|
+
"author": "JerryAZR",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"registry": "https://registry.npmjs.org/"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"pi",
|
|
16
|
+
"coding-agent",
|
|
17
|
+
"extension",
|
|
18
|
+
"hashline"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"files": [
|
|
22
|
+
"index.ts",
|
|
23
|
+
"src",
|
|
24
|
+
"tool-descriptions",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./index.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"diff": "^8.0.2",
|
|
35
|
+
"file-type": "^21.3.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-ai": ">=0.74.0",
|
|
39
|
+
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
40
|
+
"@earendil-works/pi-tui": "*",
|
|
41
|
+
"@sinclair/typebox": "*"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:watch": "vitest"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
49
|
+
"@types/node": "^22.0.0",
|
|
50
|
+
"ajv": "^8.20.0",
|
|
51
|
+
"vitest": "^3.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/edit-diff.ts
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
FUZZY_DOUBLE_QUOTES_RE,
|
|
6
6
|
FUZZY_SINGLE_QUOTES_RE,
|
|
7
7
|
FUZZY_UNICODE_SPACES_RE,
|
|
8
|
+
ANCHOR_SEP,
|
|
9
|
+
CONTENT_SEP,
|
|
8
10
|
} from "./hashline";
|
|
9
11
|
|
|
10
12
|
// ─── Line ending normalization ──────────────────────────────────────────
|
|
@@ -241,8 +243,7 @@ export function generateDiffString(
|
|
|
241
243
|
newContent.split("\n").length,
|
|
242
244
|
);
|
|
243
245
|
const lineNumWidth = String(maxLineNum).length;
|
|
244
|
-
const hashPad = " ".repeat(
|
|
245
|
-
|
|
246
|
+
const hashPad = " ".repeat(ANCHOR_SEP.length + 2); // align with `${ANCHOR_SEP}HH${CONTENT_SEP}`
|
|
246
247
|
const output: string[] = [];
|
|
247
248
|
|
|
248
249
|
for (let h = 0; h < patch.hunks.length; h++) {
|
|
@@ -262,17 +263,17 @@ export function generateDiffString(
|
|
|
262
263
|
|
|
263
264
|
if (prefix === "-") {
|
|
264
265
|
const padded = String(oldLineNum).padStart(lineNumWidth, " ");
|
|
265
|
-
output.push(`-${padded}${hashPad}
|
|
266
|
+
output.push(`-${padded}${hashPad}${CONTENT_SEP}${text}`);
|
|
266
267
|
oldLineNum++;
|
|
267
268
|
} else if (prefix === "+") {
|
|
268
269
|
const padded = String(newLineNum).padStart(lineNumWidth, " ");
|
|
269
270
|
const hash = computeLineHash(newLineNum, text);
|
|
270
|
-
output.push(`+${padded}
|
|
271
|
+
output.push(`+${padded}${ANCHOR_SEP}${hash}${CONTENT_SEP}${text}`);
|
|
271
272
|
newLineNum++;
|
|
272
273
|
} else {
|
|
273
274
|
const padded = String(newLineNum).padStart(lineNumWidth, " ");
|
|
274
275
|
const hash = computeLineHash(newLineNum, text);
|
|
275
|
-
output.push(` ${padded}
|
|
276
|
+
output.push(` ${padded}${ANCHOR_SEP}${hash}${CONTENT_SEP}${text}`);
|
|
276
277
|
oldLineNum++;
|
|
277
278
|
newLineNum++;
|
|
278
279
|
}
|
package/src/edit-response.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { generateDiffString } from "./edit-diff";
|
|
9
|
+
import { PACKAGE_INFO } from "./package-info";
|
|
9
10
|
|
|
10
11
|
// ─── Public types ───────────────────────────────────────────────────────
|
|
11
12
|
|
|
@@ -117,6 +118,7 @@ export function buildNoopResponse(input: NoopResponseInput): ToolResult {
|
|
|
117
118
|
snapshotId,
|
|
118
119
|
classification: "noop" as const,
|
|
119
120
|
metrics,
|
|
121
|
+
package: PACKAGE_INFO,
|
|
120
122
|
},
|
|
121
123
|
};
|
|
122
124
|
}
|
|
@@ -149,6 +151,7 @@ export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
|
|
|
149
151
|
diff: diffResult.diff,
|
|
150
152
|
snapshotId,
|
|
151
153
|
metrics,
|
|
154
|
+
package: PACKAGE_INFO,
|
|
152
155
|
},
|
|
153
156
|
};
|
|
154
157
|
}
|
package/src/edit.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
applyHashlineEdits,
|
|
18
18
|
resolveEditAnchors,
|
|
19
19
|
type HashlineToolEdit,
|
|
20
|
+
ANCHOR_SEP,
|
|
20
21
|
} from "./hashline";
|
|
21
22
|
import { loadFileKindAndText } from "./file-kind";
|
|
22
23
|
import { resolveToCwd } from "./path-utils";
|
|
@@ -29,7 +30,7 @@ const editEntrySchema = Type.Object(
|
|
|
29
30
|
{
|
|
30
31
|
range: Type.Tuple([Type.String(), Type.String()], {
|
|
31
32
|
description:
|
|
32
|
-
|
|
33
|
+
`LINE${ANCHOR_SEP}HASH anchor pair [start, end] copied from a recent \`read\` or diff output. Use the same anchor twice for single-line: ["42${ANCHOR_SEP}A4", "42${ANCHOR_SEP}A4"].`,
|
|
33
34
|
}),
|
|
34
35
|
lines: Type.Array(Type.String(), {
|
|
35
36
|
description: "New content lines. Use [] to delete.",
|
|
@@ -41,7 +42,7 @@ export const hashlineEditToolSchema = Type.Object(
|
|
|
41
42
|
{
|
|
42
43
|
path: Type.String({ description: "path" }),
|
|
43
44
|
edits: Type.Array(editEntrySchema, {
|
|
44
|
-
description:
|
|
45
|
+
description: `Edits to apply to $path. Each edit replaces the range [start, end] with lines. Use the same anchor twice for single-line; use [] to delete.`,
|
|
45
46
|
}),
|
|
46
47
|
},
|
|
47
48
|
{ additionalProperties: false },
|
|
@@ -67,15 +68,16 @@ type HashlineEditToolDetails = {
|
|
|
67
68
|
snapshotId?: string;
|
|
68
69
|
classification?: "noop";
|
|
69
70
|
metrics?: EditMetrics;
|
|
71
|
+
package: { name: string; version: string };
|
|
70
72
|
};
|
|
71
73
|
|
|
72
74
|
const EDIT_DESC = readFileSync(
|
|
73
|
-
new URL("../
|
|
75
|
+
new URL("../tool-descriptions/edit.md", import.meta.url),
|
|
74
76
|
"utf-8",
|
|
75
77
|
).trim();
|
|
76
78
|
|
|
77
79
|
const EDIT_PROMPT_SNIPPET = readFileSync(
|
|
78
|
-
new URL("../
|
|
80
|
+
new URL("../tool-descriptions/edit-snippet.md", import.meta.url),
|
|
79
81
|
"utf-8",
|
|
80
82
|
).trim();
|
|
81
83
|
|
|
@@ -268,12 +270,12 @@ export async function computeEditPreview(
|
|
|
268
270
|
}
|
|
269
271
|
if (file.kind === "image") {
|
|
270
272
|
return {
|
|
271
|
-
error: `Path is an image file: ${path}. Hashline edit only supports
|
|
273
|
+
error: `Path is an image file: ${path}. Hashline edit only supports text files.`,
|
|
272
274
|
};
|
|
273
275
|
}
|
|
274
276
|
if (file.kind === "binary") {
|
|
275
277
|
return {
|
|
276
|
-
error: `Path is a binary file: ${path} (${file.description}). Hashline edit only supports
|
|
278
|
+
error: `Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`,
|
|
277
279
|
};
|
|
278
280
|
}
|
|
279
281
|
|
|
@@ -452,12 +454,12 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
452
454
|
}
|
|
453
455
|
if (file.kind === "image") {
|
|
454
456
|
throw new Error(
|
|
455
|
-
`Path is an image file: ${path}. Hashline edit only supports
|
|
457
|
+
`Path is an image file: ${path}. Hashline edit only supports text files.`,
|
|
456
458
|
);
|
|
457
459
|
}
|
|
458
460
|
if (file.kind === "binary") {
|
|
459
461
|
throw new Error(
|
|
460
|
-
`Path is a binary file: ${path} (${file.description}). Hashline edit only supports
|
|
462
|
+
`Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`,
|
|
461
463
|
);
|
|
462
464
|
}
|
|
463
465
|
|
package/src/file-kind.ts
CHANGED
|
@@ -36,27 +36,6 @@ function hasNullByte(buffer: Uint8Array): boolean {
|
|
|
36
36
|
return buffer.includes(0);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function decodeUtf8Chunk(decoder: TextDecoder, buffer: Uint8Array): string | null {
|
|
40
|
-
try {
|
|
41
|
-
return decoder.decode(buffer, { stream: true });
|
|
42
|
-
} catch (error: unknown) {
|
|
43
|
-
if (error instanceof TypeError) {
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
throw error;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function finishUtf8(decoder: TextDecoder): string | null {
|
|
51
|
-
try {
|
|
52
|
-
return decoder.decode();
|
|
53
|
-
} catch (error: unknown) {
|
|
54
|
-
if (error instanceof TypeError) {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
throw error;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
39
|
|
|
61
40
|
export async function loadFileKindAndText(filePath: string): Promise<LoadedFile> {
|
|
62
41
|
const pathStat = await fsStat(filePath);
|
|
@@ -96,16 +75,12 @@ export async function loadFileKindAndText(filePath: string): Promise<LoadedFile>
|
|
|
96
75
|
};
|
|
97
76
|
}
|
|
98
77
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
description: "invalid UTF-8",
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
parts.push(sampleText);
|
|
78
|
+
// Non-fatal decode, matching pi's built-in tools: invalid UTF-8 becomes
|
|
79
|
+
// U+FFFD rather than rejecting the file. The null-byte guard above is the
|
|
80
|
+
// only signal we treat as binary, so non-UTF-8 text (CP1251, GBK, …) reads
|
|
81
|
+
// instead of forcing the model to bypass hashline with raw shell edits.
|
|
82
|
+
const decoder = new TextDecoder("utf-8");
|
|
83
|
+
const parts: string[] = [decoder.decode(sample, { stream: true })];
|
|
109
84
|
|
|
110
85
|
let position = bytesRead;
|
|
111
86
|
while (true) {
|
|
@@ -126,25 +101,13 @@ export async function loadFileKindAndText(filePath: string): Promise<LoadedFile>
|
|
|
126
101
|
description: "null bytes detected",
|
|
127
102
|
};
|
|
128
103
|
}
|
|
129
|
-
|
|
130
|
-
if (chunkText === null) {
|
|
131
|
-
return {
|
|
132
|
-
kind: "binary",
|
|
133
|
-
description: "invalid UTF-8",
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
parts.push(chunkText);
|
|
104
|
+
parts.push(decoder.decode(chunk, { stream: true }));
|
|
137
105
|
position += chunkBytesRead;
|
|
138
106
|
}
|
|
139
107
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
kind: "binary",
|
|
144
|
-
description: "invalid UTF-8",
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
parts.push(tail);
|
|
108
|
+
parts.push(decoder.decode());
|
|
109
|
+
|
|
110
|
+
return { kind: "text", text: parts.join("") };
|
|
148
111
|
|
|
149
112
|
return { kind: "text", text: parts.join("") };
|
|
150
113
|
} finally {
|
package/src/hashline.ts
CHANGED
|
@@ -39,6 +39,9 @@ const DICT = Array.from({ length: 256 }, (_, i) => {
|
|
|
39
39
|
return `${HEX[h]}${HEX[l]}`;
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
export const ANCHOR_SEP = "#";
|
|
43
|
+
export const CONTENT_SEP = "│";
|
|
44
|
+
|
|
42
45
|
// FNV-1a 32-bit constants
|
|
43
46
|
const FNV_OFFSET = 0x811c9dc5;
|
|
44
47
|
const FNV_PRIME = 0x01000193;
|
|
@@ -48,10 +51,10 @@ const FNV_PRIME = 0x01000193;
|
|
|
48
51
|
* payloads. The runtime no longer strips them — the model must send literal
|
|
49
52
|
* file content. Matching any of these triggers `[E_INVALID_PATCH]`.
|
|
50
53
|
*/
|
|
51
|
-
const HASHLINE_PREFIX_RE =
|
|
52
|
-
|
|
53
|
-
const HASHLINE_PREFIX_PLUS_RE =
|
|
54
|
-
|
|
54
|
+
const HASHLINE_PREFIX_RE = new RegExp(
|
|
55
|
+
`^\\s*(?:>>>|>>)?\\s*(?:\\d+\\s*${ANCHOR_SEP}\\s*|${ANCHOR_SEP}\\s*)?[0-9A-F]{2}${CONTENT_SEP}`);
|
|
56
|
+
const HASHLINE_PREFIX_PLUS_RE = new RegExp(
|
|
57
|
+
`^\\+\\s*(?:\\d+\\s*${ANCHOR_SEP}\\s*|${ANCHOR_SEP}\\s*)?[0-9A-F]{2}${CONTENT_SEP}`);
|
|
55
58
|
const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
|
|
56
59
|
|
|
57
60
|
export function computeLineHash(idx: number, line: string): string {
|
|
@@ -90,16 +93,16 @@ function diagnoseLineRef(ref: string): string {
|
|
|
90
93
|
const core = ref.replace(/^\s*[>+-]*\s*/, "").trim();
|
|
91
94
|
|
|
92
95
|
if (!core.length) {
|
|
93
|
-
return `[E_BAD_REF] Invalid line reference "${ref}". Expected "LINE
|
|
96
|
+
return `[E_BAD_REF] Invalid line reference "${ref}". Expected "LINE${ANCHOR_SEP}HASH" (e.g. "5${ANCHOR_SEP}MQ").`;
|
|
94
97
|
}
|
|
95
98
|
if (/^\d+\s*$/.test(core)) {
|
|
96
|
-
return `[E_BAD_REF] Invalid line reference "${ref}": missing hash, use "LINE
|
|
99
|
+
return `[E_BAD_REF] Invalid line reference "${ref}": missing hash, use "LINE${ANCHOR_SEP}HASH" from read output (e.g. "5${ANCHOR_SEP}MQ").`;
|
|
97
100
|
}
|
|
98
|
-
if (
|
|
99
|
-
return `[E_BAD_REF] Invalid line reference "${ref}": wrong separator, use "LINE
|
|
101
|
+
if (new RegExp(`^\d+\s*[:${CONTENT_SEP}]`).test(core)) {
|
|
102
|
+
return `[E_BAD_REF] Invalid line reference "${ref}": wrong separator, use "LINE${ANCHOR_SEP}HASH" instead of "LINE:..." or "LINE${CONTENT_SEP}...".`;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
const hashMatch = core.match(
|
|
105
|
+
const hashMatch = core.match(new RegExp(`^(\d+)\s*${ANCHOR_SEP}\s*([^\s${CONTENT_SEP}]+)(?:\s*${CONTENT_SEP}.*)?$`));
|
|
103
106
|
if (hashMatch) {
|
|
104
107
|
const line = Number.parseInt(hashMatch[1]!, 10);
|
|
105
108
|
const hash = hashMatch[2]!;
|
|
@@ -114,16 +117,16 @@ function diagnoseLineRef(ref: string): string {
|
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
const missingHashMatch = core.match(
|
|
120
|
+
const missingHashMatch = core.match(new RegExp(`^(\d+)\s*${ANCHOR_SEP}\s*$`));
|
|
118
121
|
if (missingHashMatch) {
|
|
119
|
-
return `[E_BAD_REF] Invalid line reference "${ref}": missing hash after "
|
|
122
|
+
return `[E_BAD_REF] Invalid line reference "${ref}": missing hash after "${ANCHOR_SEP}", use "LINE${ANCHOR_SEP}HASH" from read output.`;
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
if (
|
|
125
|
+
if (new RegExp(`^0+\s*${ANCHOR_SEP}`).test(core)) {
|
|
123
126
|
return `[E_BAD_REF] Line number must be >= 1, got 0 in "${ref}".`;
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
return `[E_BAD_REF] Invalid line reference "${trimmed || ref}". Expected "LINE
|
|
129
|
+
return `[E_BAD_REF] Invalid line reference "${trimmed || ref}". Expected "LINE${ANCHOR_SEP}HASH" (e.g. "5${ANCHOR_SEP}MQ").`;
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
export function parseLineRef(ref: string): { line: number; hash: string } {
|
|
@@ -136,7 +139,7 @@ export function parseLineRef(ref: string): { line: number; hash: string } {
|
|
|
136
139
|
|
|
137
140
|
function parseAnchorRef(ref: string): Anchor {
|
|
138
141
|
const core = ref.replace(/^\s*[>+-]*\s*/, "").trimEnd();
|
|
139
|
-
const match = core.match(
|
|
142
|
+
const match = core.match(new RegExp(`^([0-9]+)\s*${ANCHOR_SEP}\s*([^\s${CONTENT_SEP}]+)(?:\s*${CONTENT_SEP}(.*))?$`, 's'));
|
|
140
143
|
if (!match) {
|
|
141
144
|
throw new Error(diagnoseLineRef(ref));
|
|
142
145
|
}
|
|
@@ -203,9 +206,9 @@ function formatMismatchError(
|
|
|
203
206
|
const sorted = [...displayLines].sort((a, b) => a - b);
|
|
204
207
|
const maxDisplayLine = sorted[sorted.length - 1] ?? 1;
|
|
205
208
|
const lineNumberWidth = String(maxDisplayLine).length;
|
|
206
|
-
const anchorList = uniqueMismatches.map((m) => `${m.line}
|
|
209
|
+
const anchorList = uniqueMismatches.map((m) => `${m.line}${ANCHOR_SEP}${m.expected}`).join(", ");
|
|
207
210
|
const out: string[] = [
|
|
208
|
-
`[E_STALE_ANCHOR] ${uniqueMismatches.length} stale anchor${uniqueMismatches.length > 1 ? "s" : ""}: ${anchorList}. Retry with the >>> LINE
|
|
211
|
+
`[E_STALE_ANCHOR] ${uniqueMismatches.length} stale anchor${uniqueMismatches.length > 1 ? "s" : ""}: ${anchorList}. Retry with the >>> LINE${ANCHOR_SEP}HASH lines below; keep both endpoints for range replaces.`,
|
|
209
212
|
"",
|
|
210
213
|
];
|
|
211
214
|
|
|
@@ -215,11 +218,11 @@ function formatMismatchError(
|
|
|
215
218
|
prev = num;
|
|
216
219
|
const content = fileLines[num - 1];
|
|
217
220
|
const hash = computeLineHash(num, content);
|
|
218
|
-
const prefix = `${String(num).padStart(lineNumberWidth, " ")}
|
|
221
|
+
const prefix = `${String(num).padStart(lineNumberWidth, " ")}${ANCHOR_SEP}${hash}`;
|
|
219
222
|
out.push(
|
|
220
223
|
retryLineSet.has(num)
|
|
221
|
-
? `>>> ${prefix}
|
|
222
|
-
: ` ${prefix}
|
|
224
|
+
? `>>> ${prefix}${CONTENT_SEP}${content}`
|
|
225
|
+
: ` ${prefix}${CONTENT_SEP}${content}`,
|
|
223
226
|
);
|
|
224
227
|
}
|
|
225
228
|
|
|
@@ -242,7 +245,7 @@ function assertNoDisplayPrefixes(lines: string[]): void {
|
|
|
242
245
|
DIFF_MINUS_RE.test(line)
|
|
243
246
|
) {
|
|
244
247
|
throw new Error(
|
|
245
|
-
`[E_INVALID_PATCH] "lines" must contain literal file content, not rendered "LINE
|
|
248
|
+
`[E_INVALID_PATCH] "lines" must contain literal file content, not rendered "LINE${ANCHOR_SEP}HASH${CONTENT_SEP}" or diff "+/-" prefixes. Offending line: ${JSON.stringify(line)}`,
|
|
246
249
|
);
|
|
247
250
|
}
|
|
248
251
|
}
|
|
@@ -435,15 +438,15 @@ function describeEdit(edit: HashlineEdit): string {
|
|
|
435
438
|
switch (edit.op) {
|
|
436
439
|
case "replace":
|
|
437
440
|
return edit.end
|
|
438
|
-
? `replace ${edit.pos.line}
|
|
439
|
-
: `replace ${edit.pos.line}
|
|
441
|
+
? `replace ${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}-${edit.end.line}${ANCHOR_SEP}${edit.end.hash}`
|
|
442
|
+
: `replace ${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}`;
|
|
440
443
|
case "append":
|
|
441
444
|
return edit.pos
|
|
442
|
-
? `append after ${edit.pos.line}
|
|
445
|
+
? `append after ${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}`
|
|
443
446
|
: "append at EOF";
|
|
444
447
|
case "prepend":
|
|
445
448
|
return edit.pos
|
|
446
|
-
? `prepend before ${edit.pos.line}
|
|
449
|
+
? `prepend before ${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}`
|
|
447
450
|
: "prepend at BOF";
|
|
448
451
|
case "replace_text":
|
|
449
452
|
return `replace_text \"${previewText(edit.oldText)}\"`;
|
|
@@ -575,7 +578,7 @@ function resolveEditToSpan(
|
|
|
575
578
|
) {
|
|
576
579
|
noopEdits.push({
|
|
577
580
|
editIndex: index,
|
|
578
|
-
loc: `${edit.pos.line}
|
|
581
|
+
loc: `${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}`,
|
|
579
582
|
currentContent: originalLines.join("\n"),
|
|
580
583
|
});
|
|
581
584
|
return null;
|
|
@@ -627,7 +630,7 @@ function resolveEditToSpan(
|
|
|
627
630
|
if (edit.lines.length === 0) {
|
|
628
631
|
noopEdits.push({
|
|
629
632
|
editIndex: index,
|
|
630
|
-
loc: edit.pos ? `${edit.pos.line}
|
|
633
|
+
loc: edit.pos ? `${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}` : "EOF",
|
|
631
634
|
currentContent: edit.pos ? fileLines[edit.pos.line - 1] ?? "" : "",
|
|
632
635
|
});
|
|
633
636
|
return null;
|
|
@@ -678,7 +681,7 @@ function resolveEditToSpan(
|
|
|
678
681
|
if (edit.lines.length === 0) {
|
|
679
682
|
noopEdits.push({
|
|
680
683
|
editIndex: index,
|
|
681
|
-
loc: edit.pos ? `${edit.pos.line}
|
|
684
|
+
loc: edit.pos ? `${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}` : "BOF",
|
|
682
685
|
currentContent: edit.pos ? fileLines[edit.pos.line - 1] ?? "" : "",
|
|
683
686
|
});
|
|
684
687
|
return null;
|
|
@@ -988,7 +991,7 @@ export function formatHashlineRegion(
|
|
|
988
991
|
.map((line, index) => {
|
|
989
992
|
const lineNumber = startLine + index;
|
|
990
993
|
const paddedLineNumber = String(lineNumber).padStart(lineNumberWidth, " ");
|
|
991
|
-
return `${paddedLineNumber}
|
|
994
|
+
return `${paddedLineNumber}${ANCHOR_SEP}${computeLineHash(lineNumber, line)}${CONTENT_SEP}${line}`;
|
|
992
995
|
})
|
|
993
996
|
.join("\n");
|
|
994
997
|
}
|
package/src/read.ts
CHANGED
|
@@ -1,230 +1,241 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
createReadTool,
|
|
4
|
-
formatSize,
|
|
5
|
-
DEFAULT_MAX_BYTES,
|
|
6
|
-
DEFAULT_MAX_LINES,
|
|
7
|
-
truncateHead,
|
|
8
|
-
type TruncationResult,
|
|
9
|
-
} from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { Type } from "@sinclair/typebox";
|
|
11
|
-
import { readFileSync } from "fs";
|
|
12
|
-
import { access as fsAccess, readdir as fsReaddir } from "fs/promises";
|
|
13
|
-
import { constants } from "fs";
|
|
14
|
-
import { normalizeToLF, stripBom } from "./edit-diff";
|
|
15
|
-
import { loadFileKindAndText } from "./file-kind";
|
|
16
|
-
import { formatHashlineRegion } from "./hashline";
|
|
17
|
-
import { resolveToCwd } from "./path-utils";
|
|
18
|
-
import { throwIfAborted } from "./runtime";
|
|
19
|
-
import { getFileSnapshot } from "./snapshot";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.replaceAll("{{
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
.
|
|
41
|
-
.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
let
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
.
|
|
187
|
-
.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
createReadTool,
|
|
4
|
+
formatSize,
|
|
5
|
+
DEFAULT_MAX_BYTES,
|
|
6
|
+
DEFAULT_MAX_LINES,
|
|
7
|
+
truncateHead,
|
|
8
|
+
type TruncationResult,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Type } from "@sinclair/typebox";
|
|
11
|
+
import { readFileSync } from "fs";
|
|
12
|
+
import { access as fsAccess, readdir as fsReaddir } from "fs/promises";
|
|
13
|
+
import { constants } from "fs";
|
|
14
|
+
import { normalizeToLF, stripBom } from "./edit-diff";
|
|
15
|
+
import { loadFileKindAndText } from "./file-kind";
|
|
16
|
+
import { formatHashlineRegion } from "./hashline";
|
|
17
|
+
import { resolveToCwd } from "./path-utils";
|
|
18
|
+
import { throwIfAborted } from "./runtime";
|
|
19
|
+
import { getFileSnapshot } from "./snapshot";
|
|
20
|
+
import { PACKAGE_INFO } from "./package-info";
|
|
21
|
+
|
|
22
|
+
const READ_DESC = readFileSync(
|
|
23
|
+
new URL("../tool-descriptions/read.md", import.meta.url),
|
|
24
|
+
"utf-8",
|
|
25
|
+
)
|
|
26
|
+
.replaceAll("{{DEFAULT_MAX_LINES}}", String(DEFAULT_MAX_LINES))
|
|
27
|
+
.replaceAll("{{DEFAULT_MAX_BYTES}}", formatSize(DEFAULT_MAX_BYTES))
|
|
28
|
+
.trim();
|
|
29
|
+
|
|
30
|
+
const READ_PROMPT_SNIPPET = readFileSync(
|
|
31
|
+
new URL("../tool-descriptions/read-snippet.md", import.meta.url),
|
|
32
|
+
"utf-8",
|
|
33
|
+
).trim();
|
|
34
|
+
|
|
35
|
+
const READ_PROMPT_GUIDELINES = readFileSync(
|
|
36
|
+
new URL("../tool-descriptions/read-guidelines.md", import.meta.url),
|
|
37
|
+
"utf-8",
|
|
38
|
+
)
|
|
39
|
+
.split("\n")
|
|
40
|
+
.map((line) => line.trim())
|
|
41
|
+
.filter((line) => line.startsWith("- "))
|
|
42
|
+
.map((line) => line.slice(2));
|
|
43
|
+
|
|
44
|
+
function normalizePositiveInteger(
|
|
45
|
+
value: number | undefined,
|
|
46
|
+
name: "offset" | "limit",
|
|
47
|
+
): number | undefined {
|
|
48
|
+
if (value === undefined) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
53
|
+
throw new Error(`Read request field "${name}" must be a positive integer.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getPreviewLines(text: string): string[] {
|
|
60
|
+
if (text.length === 0) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lines = text.split("\n");
|
|
65
|
+
return text.endsWith("\n") ? lines.slice(0, -1) : lines;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatHashlineReadPreview(
|
|
69
|
+
text: string,
|
|
70
|
+
options: { offset?: number; limit?: number; raw?: boolean },
|
|
71
|
+
): { text: string; truncation?: TruncationResult; nextOffset?: number } {
|
|
72
|
+
const allLines = getPreviewLines(text);
|
|
73
|
+
const totalLines = allLines.length;
|
|
74
|
+
const startLine = normalizePositiveInteger(options.offset, "offset") ?? 1;
|
|
75
|
+
if (totalLines === 0) {
|
|
76
|
+
if (startLine === 1) {
|
|
77
|
+
return {
|
|
78
|
+
text: "File is empty. Use edit with prepend or append and omit pos to insert content.",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use edit with prepend or append and omit pos to insert content.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (startLine > totalLines) {
|
|
88
|
+
return {
|
|
89
|
+
text: `Offset ${startLine} is beyond end of file (${totalLines} lines total). Use offset=1 to read from the start, or offset=${totalLines} to read the last line.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const limit = normalizePositiveInteger(options.limit, "limit");
|
|
94
|
+
const endIdx = limit
|
|
95
|
+
? Math.min(startLine - 1 + limit, totalLines)
|
|
96
|
+
: totalLines;
|
|
97
|
+
const selected = allLines.slice(startLine - 1, endIdx);
|
|
98
|
+
const formatted = options.raw ? selected.join("\n") : formatHashlineRegion(selected, startLine);
|
|
99
|
+
|
|
100
|
+
const truncation = truncateHead(formatted);
|
|
101
|
+
if (truncation.firstLineExceedsLimit) {
|
|
102
|
+
return {
|
|
103
|
+
text: `[Line ${startLine} exceeds ${formatSize(truncation.maxBytes)}.${options.raw ? "" : " Hashline output requires full lines; cannot compute hashes for a truncated preview."}]`,
|
|
104
|
+
|
|
105
|
+
truncation,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let preview = truncation.content;
|
|
110
|
+
let nextOffset: number | undefined;
|
|
111
|
+
if (truncation.truncated) {
|
|
112
|
+
const endLineDisplay = startLine + truncation.outputLines - 1;
|
|
113
|
+
nextOffset = endLineDisplay + 1;
|
|
114
|
+
if (truncation.truncatedBy === "lines") {
|
|
115
|
+
preview += `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${totalLines}. Use offset=${nextOffset} to continue.]`;
|
|
116
|
+
} else {
|
|
117
|
+
preview += `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${totalLines} (${formatSize(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue.]`;
|
|
118
|
+
}
|
|
119
|
+
} else if (endIdx < totalLines) {
|
|
120
|
+
nextOffset = endIdx + 1;
|
|
121
|
+
preview += `\n\n[Showing lines ${startLine}-${endIdx} of ${totalLines}. Use offset=${nextOffset} to continue.]`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
text: preview,
|
|
126
|
+
truncation: truncation.truncated ? truncation : undefined,
|
|
127
|
+
...(nextOffset !== undefined ? { nextOffset } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function registerReadTool(pi: ExtensionAPI): void {
|
|
132
|
+
pi.registerTool({
|
|
133
|
+
name: "read",
|
|
134
|
+
label: "Read",
|
|
135
|
+
description: READ_DESC,
|
|
136
|
+
promptSnippet: READ_PROMPT_SNIPPET,
|
|
137
|
+
promptGuidelines: READ_PROMPT_GUIDELINES,
|
|
138
|
+
parameters: Type.Object({
|
|
139
|
+
path: Type.String({
|
|
140
|
+
description: "Path to the file to read (relative or absolute)",
|
|
141
|
+
}),
|
|
142
|
+
offset: Type.Optional(
|
|
143
|
+
Type.Integer({
|
|
144
|
+
minimum: 1,
|
|
145
|
+
description: "Line number to start reading from (1-indexed)",
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
limit: Type.Optional(
|
|
149
|
+
Type.Integer({
|
|
150
|
+
minimum: 1,
|
|
151
|
+
description: "Maximum number of lines to read",
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
raw: Type.Optional(
|
|
155
|
+
Type.Boolean({
|
|
156
|
+
description: "Return raw text without LINE#HASH anchors, saving tokens. Don't use if you plan to edit this file.",
|
|
157
|
+
}),
|
|
158
|
+
),
|
|
159
|
+
}),
|
|
160
|
+
|
|
161
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
162
|
+
const rawPath = params.path;
|
|
163
|
+
const absolutePath = resolveToCwd(rawPath, ctx.cwd);
|
|
164
|
+
|
|
165
|
+
throwIfAborted(signal);
|
|
166
|
+
try {
|
|
167
|
+
await fsAccess(absolutePath, constants.R_OK);
|
|
168
|
+
} catch (error: unknown) {
|
|
169
|
+
const code = error instanceof Error
|
|
170
|
+
? (error as NodeJS.ErrnoException).code
|
|
171
|
+
: undefined;
|
|
172
|
+
if (code === "ENOENT") {
|
|
173
|
+
throw new Error(`File not found: ${rawPath}`);
|
|
174
|
+
}
|
|
175
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
176
|
+
throw new Error(`File is not readable: ${rawPath}`);
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Cannot access file: ${rawPath}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
throwIfAborted(signal);
|
|
182
|
+
const file = await loadFileKindAndText(absolutePath);
|
|
183
|
+
if (file.kind === "directory") {
|
|
184
|
+
const entries = await fsReaddir(absolutePath);
|
|
185
|
+
const listing = entries
|
|
186
|
+
.slice(0, 50)
|
|
187
|
+
.map((name) => ` ${name}`)
|
|
188
|
+
.join("\n");
|
|
189
|
+
const cap = entries.length > 50 ? `\n ... and ${entries.length - 50} more` : "";
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Path is a directory: ${rawPath}\n${listing}${cap}\n\nUse ls to explore further or read a specific file.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (file.kind === "binary") {
|
|
196
|
+
throw new Error(`Path is a binary file: ${rawPath} (${file.description}). Read only supports text files and supported images.`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (file.kind === "image") {
|
|
200
|
+
const builtinRead = createReadTool(ctx.cwd);
|
|
201
|
+
return builtinRead.execute(_toolCallId, params, signal, _onUpdate, ctx);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
throwIfAborted(signal);
|
|
205
|
+
const normalized = normalizeToLF(stripBom(file.text).text);
|
|
206
|
+
const preview = formatHashlineReadPreview(normalized, {
|
|
207
|
+
offset: params.offset,
|
|
208
|
+
limit: params.limit,
|
|
209
|
+
raw: params.raw,
|
|
210
|
+
});
|
|
211
|
+
const snapshot = await getFileSnapshot(absolutePath);
|
|
212
|
+
|
|
213
|
+
// A U+FFFD anywhere in the decoded text means the file held bytes that
|
|
214
|
+
// are not valid UTF-8 (CP1251, GBK, …). Editing rewrites the whole file
|
|
215
|
+
// as UTF-8, so those bytes are lost. Warn once on read — the model can
|
|
216
|
+
// then iconv the file back afterwards. Detect on the full text, not the
|
|
217
|
+
// paged slice, so an out-of-view bad byte still surfaces.
|
|
218
|
+
const previewText = normalized.includes("\uFFFD")
|
|
219
|
+
? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
|
|
220
|
+
: preview.text;
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
content: [{ type: "text", text: previewText }],
|
|
224
|
+
details: {
|
|
225
|
+
truncation: preview.truncation,
|
|
226
|
+
// snapshotId remains in details for host UI (e.g. "file changed since
|
|
227
|
+
// last view"). It is NOT echoed in text — the LLM no longer needs it.
|
|
228
|
+
snapshotId: snapshot.snapshotId,
|
|
229
|
+
...(preview.nextOffset !== undefined ? { nextOffset: preview.nextOffset } : {}),
|
|
230
|
+
// Phase 2 C — host-only observability. Truncated reads usually mean
|
|
231
|
+
// a follow-up read with `offset = next_offset` is coming.
|
|
232
|
+
metrics: {
|
|
233
|
+
truncated: !!preview.truncation,
|
|
234
|
+
...(preview.nextOffset !== undefined ? { next_offset: preview.nextOffset } : {}),
|
|
235
|
+
},
|
|
236
|
+
package: PACKAGE_INFO,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
|
@@ -9,7 +9,7 @@ Each edit entry replaces an inclusive anchor range:
|
|
|
9
9
|
- `range` — `[start, end]` pair of LINE#HASH anchors from the most recent `read` or diff output.
|
|
10
10
|
Use the same anchor twice for single-line: `["42#A4", "42#A4"]`.
|
|
11
11
|
- `lines` — new content replacing the range (string array). Use `[]` to delete.
|
|
12
|
-
Must be literal file content, not LINE#HASH
|
|
12
|
+
Must be literal file content, not LINE#HASH│-prefixed output. Match indentation exactly.
|
|
13
13
|
|
|
14
14
|
Example:
|
|
15
15
|
```json
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Read a UTF-8 text file or a supported image. Text lines are prefixed `LINE#HASH
|
|
1
|
+
Read a UTF-8 text file or a supported image. Text lines are prefixed `LINE#HASH│content` — copy those anchors verbatim into `edit`.
|
|
2
2
|
|
|
3
3
|
Use `offset` and `limit` to page through. Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}; when truncated, the tail of the output tells you the next `offset`.
|
|
4
4
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|