@jerryan/pi-hashline-edit 0.7.3 → 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 +1 -1
- package/package.json +53 -53
- package/src/edit-response.ts +3 -0
- package/src/edit.ts +7 -6
- package/src/file-kind.ts +10 -47
- package/src/package-info.ts +4 -0
- package/src/read.ts +241 -230
- /package/{prompts → tool-descriptions}/edit-snippet.md +0 -0
- /package/{prompts → tool-descriptions}/edit.md +0 -0
- /package/{prompts → tool-descriptions}/read-guidelines.md +0 -0
- /package/{prompts → tool-descriptions}/read-snippet.md +0 -0
- /package/{prompts → tool-descriptions}/read.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.
|
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-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
|
@@ -68,15 +68,16 @@ type HashlineEditToolDetails = {
|
|
|
68
68
|
snapshotId?: string;
|
|
69
69
|
classification?: "noop";
|
|
70
70
|
metrics?: EditMetrics;
|
|
71
|
+
package: { name: string; version: string };
|
|
71
72
|
};
|
|
72
73
|
|
|
73
74
|
const EDIT_DESC = readFileSync(
|
|
74
|
-
new URL("../
|
|
75
|
+
new URL("../tool-descriptions/edit.md", import.meta.url),
|
|
75
76
|
"utf-8",
|
|
76
77
|
).trim();
|
|
77
78
|
|
|
78
79
|
const EDIT_PROMPT_SNIPPET = readFileSync(
|
|
79
|
-
new URL("../
|
|
80
|
+
new URL("../tool-descriptions/edit-snippet.md", import.meta.url),
|
|
80
81
|
"utf-8",
|
|
81
82
|
).trim();
|
|
82
83
|
|
|
@@ -269,12 +270,12 @@ export async function computeEditPreview(
|
|
|
269
270
|
}
|
|
270
271
|
if (file.kind === "image") {
|
|
271
272
|
return {
|
|
272
|
-
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.`,
|
|
273
274
|
};
|
|
274
275
|
}
|
|
275
276
|
if (file.kind === "binary") {
|
|
276
277
|
return {
|
|
277
|
-
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.`,
|
|
278
279
|
};
|
|
279
280
|
}
|
|
280
281
|
|
|
@@ -453,12 +454,12 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
453
454
|
}
|
|
454
455
|
if (file.kind === "image") {
|
|
455
456
|
throw new Error(
|
|
456
|
-
`Path is an image file: ${path}. Hashline edit only supports
|
|
457
|
+
`Path is an image file: ${path}. Hashline edit only supports text files.`,
|
|
457
458
|
);
|
|
458
459
|
}
|
|
459
460
|
if (file.kind === "binary") {
|
|
460
461
|
throw new Error(
|
|
461
|
-
`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.`,
|
|
462
463
|
);
|
|
463
464
|
}
|
|
464
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/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
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|