@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 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.
@@ -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:` prefix on every line. Line numbers may be left-padded within each returned block so the `#HASH:` columns align:
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:function hello() {
41
- 9#3F: console.log("world");
42
- 10#B2:}
40
+ 8#A4function 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:function hello() {
84
- -9 : console.log("world");
85
- +9#B1: console.log("hashline");
86
- 10#B2:}
83
+ 8#A4function hello() {
84
+ -9 console.log("world");
85
+ +9#B1 console.log("hashline");
86
+ 10#B2}
87
87
  ```
88
88
 
89
- - Context lines: ` NN#HH:content` (space prefix)
90
- - Removed lines: `-NN :content` (no hash, aligned colon)
91
- - Added lines: `+NN#HH:content` (hash for new anchors)
89
+ - Context lines: ` NN#HHcontent` (space prefix)
90
+ - Removed lines: `-NN content` (no hash, aligned separator)
91
+ - Added lines: `+NN#HHcontent` (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:` 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.
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.2",
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
+ }
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(3); // align with `#HH:`
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}:${text}`);
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}#${hash}:${text}`);
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}#${hash}:${text}`);
276
+ output.push(` ${padded}${ANCHOR_SEP}${hash}${CONTENT_SEP}${text}`);
276
277
  oldLineNum++;
277
278
  newLineNum++;
278
279
  }
@@ -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
- 'LINE#HASH anchor pair [start, end] copied from a recent `read` or `--- Anchors ---` block. Use the same anchor twice for single-line: ["42#A4", "42#A4"].',
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: "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
+ 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("../prompts/edit.md", import.meta.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("../prompts/edit-snippet.md", import.meta.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 UTF-8 text files.`,
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 UTF-8 text files.`,
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 UTF-8 text files.`,
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 UTF-8 text files.`,
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
- 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 {
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
- /^\s*(?:>>>|>>)?\s*(?:\d+\s*#\s*|#\s*)[0-9A-F]{2}:/;
53
- const HASHLINE_PREFIX_PLUS_RE =
54
- /^\+\s*(?:\d+\s*#\s*|#\s*)[0-9A-F]{2}:/;
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#HASH" (e.g. "5#MQ").`;
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#HASH" from read output (e.g. "5#MQ").`;
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 (/^\d+\s*:/.test(core)) {
99
- return `[E_BAD_REF] Invalid line reference "${ref}": wrong separator, use "LINE#HASH" instead of "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(/^(\d+)\s*#\s*([^\s:]+)(?:\s*:.*)?$/);
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(/^(\d+)\s*#\s*$/);
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 "#", use "LINE#HASH" from read output.`;
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 (/^0+\s*#/.test(core)) {
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#HASH" (e.g. "5#MQ").`;
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(/^([0-9]+)\s*#\s*([^\s:]+)(?:\s*:(.*))?$/s);
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}#${m.expected}`).join(", ");
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#HASH lines below; keep both endpoints for range replaces.`,
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, " ")}#${hash}`;
221
+ const prefix = `${String(num).padStart(lineNumberWidth, " ")}${ANCHOR_SEP}${hash}`;
219
222
  out.push(
220
223
  retryLineSet.has(num)
221
- ? `>>> ${prefix}:${content}`
222
- : ` ${prefix}:${content}`,
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#HASH:" or diff "+/-" prefixes. Offending line: ${JSON.stringify(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}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}`
439
- : `replace ${edit.pos.line}#${edit.pos.hash}`;
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}#${edit.pos.hash}`
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}#${edit.pos.hash}`
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}#${edit.pos.hash}`,
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}#${edit.pos.hash}` : "EOF",
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}#${edit.pos.hash}` : "BOF",
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}#${computeLineHash(lineNumber, line)}:${line}`;
994
+ return `${paddedLineNumber}${ANCHOR_SEP}${computeLineHash(lineNumber, line)}${CONTENT_SEP}${line}`;
992
995
  })
993
996
  .join("\n");
994
997
  }
@@ -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
+ }
@@ -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-prefixed output. Match indentation exactly.
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:content` — copy those anchors verbatim into `edit`.
1
+ Read a UTF-8 text file or a supported image. Text lines are prefixed `LINE#HASHcontent` — 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