@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 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:` anchors, for reads that don't plan to edit.
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.3",
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
- "prompts",
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
+ }
@@ -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("../prompts/edit.md", import.meta.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("../prompts/edit-snippet.md", import.meta.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 UTF-8 text files.`,
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 UTF-8 text files.`,
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 UTF-8 text files.`,
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 UTF-8 text files.`,
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
- const decoder = new TextDecoder("utf-8", { fatal: true });
100
- const parts: string[] = [];
101
- const sampleText = decodeUtf8Chunk(decoder, sample);
102
- if (sampleText === null) {
103
- return {
104
- kind: "binary",
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
- const chunkText = decodeUtf8Chunk(decoder, chunk);
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
- const tail = finishUtf8(decoder);
141
- if (tail === null) {
142
- return {
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 {
@@ -0,0 +1,4 @@
1
+ export const PACKAGE_INFO = {
2
+ name: "@jerryan/pi-hashline-edit",
3
+ version: "0.7.4",
4
+ };
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
- const READ_DESC = readFileSync(
22
- new URL("../prompts/read.md", import.meta.url),
23
- "utf-8",
24
- )
25
- .replaceAll("{{DEFAULT_MAX_LINES}}", String(DEFAULT_MAX_LINES))
26
- .replaceAll("{{DEFAULT_MAX_BYTES}}", formatSize(DEFAULT_MAX_BYTES))
27
- .trim();
28
-
29
- const READ_PROMPT_SNIPPET = readFileSync(
30
- new URL("../prompts/read-snippet.md", import.meta.url),
31
- "utf-8",
32
- ).trim();
33
-
34
- const READ_PROMPT_GUIDELINES = readFileSync(
35
- new URL("../prompts/read-guidelines.md", import.meta.url),
36
- "utf-8",
37
- )
38
- .split("\n")
39
- .map((line) => line.trim())
40
- .filter((line) => line.startsWith("- "))
41
- .map((line) => line.slice(2));
42
-
43
- function normalizePositiveInteger(
44
- value: number | undefined,
45
- name: "offset" | "limit",
46
- ): number | undefined {
47
- if (value === undefined) {
48
- return undefined;
49
- }
50
-
51
- if (!Number.isInteger(value) || value < 1) {
52
- throw new Error(`Read request field "${name}" must be a positive integer.`);
53
- }
54
-
55
- return value;
56
- }
57
-
58
- function getPreviewLines(text: string): string[] {
59
- if (text.length === 0) {
60
- return [];
61
- }
62
-
63
- const lines = text.split("\n");
64
- return text.endsWith("\n") ? lines.slice(0, -1) : lines;
65
- }
66
-
67
- export function formatHashlineReadPreview(
68
- text: string,
69
- options: { offset?: number; limit?: number; raw?: boolean },
70
- ): { text: string; truncation?: TruncationResult; nextOffset?: number } {
71
- const allLines = getPreviewLines(text);
72
- const totalLines = allLines.length;
73
- const startLine = normalizePositiveInteger(options.offset, "offset") ?? 1;
74
- if (totalLines === 0) {
75
- if (startLine === 1) {
76
- return {
77
- text: "File is empty. Use edit with prepend or append and omit pos to insert content.",
78
- };
79
- }
80
-
81
- return {
82
- 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.`,
83
- };
84
- }
85
-
86
- if (startLine > totalLines) {
87
- return {
88
- 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.`,
89
- };
90
- }
91
-
92
- const limit = normalizePositiveInteger(options.limit, "limit");
93
- const endIdx = limit
94
- ? Math.min(startLine - 1 + limit, totalLines)
95
- : totalLines;
96
- const selected = allLines.slice(startLine - 1, endIdx);
97
- const formatted = options.raw ? selected.join("\n") : formatHashlineRegion(selected, startLine);
98
-
99
- const truncation = truncateHead(formatted);
100
- if (truncation.firstLineExceedsLimit) {
101
- return {
102
- text: `[Line ${startLine} exceeds ${formatSize(truncation.maxBytes)}.${options.raw ? "" : " Hashline output requires full lines; cannot compute hashes for a truncated preview."}]`,
103
-
104
- truncation,
105
- };
106
- }
107
-
108
- let preview = truncation.content;
109
- let nextOffset: number | undefined;
110
- if (truncation.truncated) {
111
- const endLineDisplay = startLine + truncation.outputLines - 1;
112
- nextOffset = endLineDisplay + 1;
113
- if (truncation.truncatedBy === "lines") {
114
- preview += `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${totalLines}. Use offset=${nextOffset} to continue.]`;
115
- } else {
116
- preview += `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${totalLines} (${formatSize(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue.]`;
117
- }
118
- } else if (endIdx < totalLines) {
119
- nextOffset = endIdx + 1;
120
- preview += `\n\n[Showing lines ${startLine}-${endIdx} of ${totalLines}. Use offset=${nextOffset} to continue.]`;
121
- }
122
-
123
- return {
124
- text: preview,
125
- truncation: truncation.truncated ? truncation : undefined,
126
- ...(nextOffset !== undefined ? { nextOffset } : {}),
127
- };
128
- }
129
-
130
- export function registerReadTool(pi: ExtensionAPI): void {
131
- pi.registerTool({
132
- name: "read",
133
- label: "Read",
134
- description: READ_DESC,
135
- promptSnippet: READ_PROMPT_SNIPPET,
136
- promptGuidelines: READ_PROMPT_GUIDELINES,
137
- parameters: Type.Object({
138
- path: Type.String({
139
- description: "Path to the file to read (relative or absolute)",
140
- }),
141
- offset: Type.Optional(
142
- Type.Integer({
143
- minimum: 1,
144
- description: "Line number to start reading from (1-indexed)",
145
- }),
146
- ),
147
- limit: Type.Optional(
148
- Type.Integer({
149
- minimum: 1,
150
- description: "Maximum number of lines to read",
151
- }),
152
- ),
153
- raw: Type.Optional(
154
- Type.Boolean({
155
- description: "Return raw text without LINE#HASH anchors, saving tokens. Don't use if you plan to edit this file.",
156
- }),
157
- ),
158
- }),
159
-
160
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
161
- const rawPath = params.path;
162
- const absolutePath = resolveToCwd(rawPath, ctx.cwd);
163
-
164
- throwIfAborted(signal);
165
- try {
166
- await fsAccess(absolutePath, constants.R_OK);
167
- } catch (error: unknown) {
168
- const code = error instanceof Error
169
- ? (error as NodeJS.ErrnoException).code
170
- : undefined;
171
- if (code === "ENOENT") {
172
- throw new Error(`File not found: ${rawPath}`);
173
- }
174
- if (code === "EACCES" || code === "EPERM") {
175
- throw new Error(`File is not readable: ${rawPath}`);
176
- }
177
- throw new Error(`Cannot access file: ${rawPath}`);
178
- }
179
-
180
- throwIfAborted(signal);
181
- const file = await loadFileKindAndText(absolutePath);
182
- if (file.kind === "directory") {
183
- const entries = await fsReaddir(absolutePath);
184
- const listing = entries
185
- .slice(0, 50)
186
- .map((name) => ` ${name}`)
187
- .join("\n");
188
- const cap = entries.length > 50 ? `\n ... and ${entries.length - 50} more` : "";
189
- throw new Error(
190
- `Path is a directory: ${rawPath}\n${listing}${cap}\n\nUse ls to explore further or read a specific file.`,
191
- );
192
- }
193
-
194
- if (file.kind === "binary") {
195
- throw new Error(`Path is a binary file: ${rawPath} (${file.description}). Read only supports UTF-8 text files and supported images.`);
196
- }
197
-
198
- if (file.kind === "image") {
199
- const builtinRead = createReadTool(ctx.cwd);
200
- return builtinRead.execute(_toolCallId, params, signal, _onUpdate, ctx);
201
- }
202
-
203
- throwIfAborted(signal);
204
- const normalized = normalizeToLF(stripBom(file.text).text);
205
- const preview = formatHashlineReadPreview(normalized, {
206
- offset: params.offset,
207
- limit: params.limit,
208
- raw: params.raw,
209
- });
210
- const snapshot = await getFileSnapshot(absolutePath);
211
-
212
- return {
213
- content: [{ type: "text", text: preview.text }],
214
- details: {
215
- truncation: preview.truncation,
216
- // snapshotId remains in details for host UI (e.g. "file changed since
217
- // last view"). It is NOT echoed in text — the LLM no longer needs it.
218
- snapshotId: snapshot.snapshotId,
219
- ...(preview.nextOffset !== undefined ? { nextOffset: preview.nextOffset } : {}),
220
- // Phase 2 C — host-only observability. Truncated reads usually mean
221
- // a follow-up read with `offset = next_offset` is coming.
222
- metrics: {
223
- truncated: !!preview.truncation,
224
- ...(preview.nextOffset !== undefined ? { next_offset: preview.nextOffset } : {}),
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