pi-hashline-edit-pro 0.9.4 → 0.9.6

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 RimuruW
3
+ Copyright (c) 2026 RimuruW and Yugimob
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/index.ts CHANGED
@@ -6,23 +6,20 @@ import { registerReplaceTool } from "./src/replace";
6
6
  import { registerReadTool } from "./src/read";
7
7
  import { normalizeToLF } from "./src/replace-diff";
8
8
  import { getVisibleLines } from "./src/utils";
9
+ import { AUTO_READ_MAX_LINES } from "./src/constants";
9
10
 
10
11
  export default function (pi: ExtensionAPI): void {
11
12
  registerReadTool(pi);
12
13
  registerReplaceTool(pi);
13
14
 
14
- // Disable the built-in `edit` tool so the model uses `replace` instead.
15
15
  pi.on("session_start", async (_event, ctx) => {
16
16
  const active = pi.getActiveTools();
17
17
  pi.setActiveTools(active.filter((t) => t !== "edit"));
18
18
  });
19
19
 
20
- // Auto-read after write state - controlled by PI_HASHLINE_AUTO_READ env var (default: disabled).
21
- // Can be toggled at runtime via /toggle-auto-read command.
22
20
  const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
23
21
  let autoReadEnabled = autoReadValue === "1" || autoReadValue === "true";
24
22
 
25
- // Register toggle-auto-read command
26
23
  pi.registerCommand("toggle-auto-read", {
27
24
  description: "Toggle automatic hashline anchors after write operations",
28
25
  handler: async (_args, ctx) => {
@@ -32,7 +29,6 @@ export default function (pi: ExtensionAPI): void {
32
29
  },
33
30
  });
34
31
 
35
- // Auto-read after write handler - always registered, but checks the flag
36
32
  pi.on("tool_result", async (event, ctx) => {
37
33
  if (!autoReadEnabled) return;
38
34
  if (event.toolName !== "write" || event.isError) return;
@@ -44,24 +40,20 @@ export default function (pi: ExtensionAPI): void {
44
40
  const absolutePath = isAbsolute(filePath) ? filePath : join(ctx.cwd, filePath);
45
41
  const content = await readFile(absolutePath, "utf-8");
46
42
 
47
- // Normalize and compute hashline output
48
43
  const normalized = normalizeToLF(content);
49
44
  const visibleLines = getVisibleLines(normalized);
50
45
 
51
46
  if (visibleLines.length === 0) return;
52
47
 
53
- // Truncate to a reasonable limit to avoid excessive token usage
54
- const MAX_LINES = 2000;
55
- const truncated = visibleLines.length > MAX_LINES;
56
- const displayLines = truncated ? visibleLines.slice(0, MAX_LINES) : visibleLines;
48
+ const truncated = visibleLines.length > AUTO_READ_MAX_LINES;
49
+ const displayLines = truncated ? visibleLines.slice(0, AUTO_READ_MAX_LINES) : visibleLines;
57
50
 
58
51
  const hashes = computeLineHashes(normalized);
59
52
  const selectedHashes = hashes.slice(0, displayLines.length);
60
53
  const hashlineOutput = formatHashlineRegion(selectedHashes, displayLines);
61
54
 
62
- // Add pagination hint if truncated
63
55
  const paginationHint = truncated
64
- ? `\n\n[Showing lines 1-${MAX_LINES} of ${visibleLines.length}. Use offset=${MAX_LINES + 1} to continue.]`
56
+ ? `\n\n[Showing lines 1-${AUTO_READ_MAX_LINES} of ${visibleLines.length}. Use offset=${AUTO_READ_MAX_LINES + 1} to continue.]`
65
57
  : "";
66
58
 
67
59
  if (hashlineOutput) {
@@ -73,7 +65,6 @@ export default function (pi: ExtensionAPI): void {
73
65
  };
74
66
  }
75
67
  } catch {
76
- // Auto-read failure should not affect write result
77
68
  }
78
69
  });
79
70
 
@@ -83,4 +74,4 @@ export default function (pi: ExtensionAPI): void {
83
74
  ctx.ui.notify("Hashline Edit mode active", "info");
84
75
  });
85
76
  }
86
- }
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -1,3 +1,3 @@
1
1
  - Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.
2
- - After a successful `replace`, the returned `--- Anchors ---` block has fresh hashes for nearby follow-up replaces.
3
- - On `[E_STALE_ANCHOR]`, retry with the `>>>` lines quoted in the error instead of re-reading the whole file.
2
+ - After a successful `replace`, the response text is empty (warnings only). Call `read` to get fresh anchors for follow-up edits.
3
+ - On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH from each line into `old_range`, and retry.
@@ -0,0 +1,5 @@
1
+ export const AUTO_READ_MAX_LINES = 2000;
2
+ export const CHANGED_ANCHOR_TEXT_BUDGET_BYTES = 50 * 1024;
3
+ export const ANCHOR_CONTEXT_LINES = 0;
4
+ export const ANCHOR_MAX_OUTPUT_LINES = 12;
5
+ export const FILE_TYPE_SNIFF_BYTES = 8192;
package/src/file-kind.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { open as fsOpen, stat as fsStat } from "fs/promises";
2
2
  import { fileTypeFromBuffer } from "file-type";
3
+ import { FILE_TYPE_SNIFF_BYTES } from "./constants";
3
4
 
4
5
  const IMAGE_MIME_TYPES = new Set<string>([
5
6
  "image/jpeg",
@@ -18,8 +19,6 @@ function isTextLikeMimeType(mimeType: string): boolean {
18
19
  return mimeType.startsWith("text/") || TEXT_LIKE_MIME_TYPES.has(mimeType);
19
20
  }
20
21
 
21
- const FILE_TYPE_SNIFF_BYTES = 8192;
22
-
23
22
  export type FileKind =
24
23
  | { kind: "directory" }
25
24
  | { kind: "image"; mimeType: string }
@@ -84,12 +83,6 @@ export async function loadFileKindAndText(
84
83
  };
85
84
  }
86
85
 
87
- // Non-fatal decode, matching pi's built-in tools: invalid UTF-8 becomes
88
- // U+FFFD rather than rejecting the file. The null-byte guard above is the
89
- // only signal we treat as binary, so non-UTF-8 text (CP1251, GBK, …) reads
90
- // instead of forcing the model to bypass hashline with raw shell edits.
91
- // Track fatal-decoder failures separately so a literal, valid U+FFFD in a
92
- // UTF-8 file does not get mistaken for lossy decoding.
93
86
  const decoder = new TextDecoder("utf-8");
94
87
  const fatalDecoder = new TextDecoder("utf-8", { fatal: true });
95
88
  let hadUtf8DecodeErrors = false;
@@ -158,4 +151,4 @@ export async function classifyFileKind(filePath: string): Promise<FileKind> {
158
151
  case "text":
159
152
  return { kind: "text" };
160
153
  }
161
- }
154
+ }
@@ -1,4 +1,3 @@
1
-
2
1
  import { throwIfAborted } from "../runtime";
3
2
  import { computeLineHashes } from "./hash";
4
3
  import {
@@ -13,7 +12,7 @@ import {
13
12
  type BoundaryDuplicationWarning,
14
13
  } from "./resolve";
15
14
  import { countVisibleLines } from "../utils";
16
-
15
+ import { ANCHOR_CONTEXT_LINES, ANCHOR_MAX_OUTPUT_LINES } from "../constants";
17
16
 
18
17
  type LineIndex = {
19
18
  fileLines: string[];
@@ -236,7 +235,6 @@ export function applyHashlineEdits(
236
235
  lastChangedLine: undefined,
237
236
  };
238
237
 
239
- // Normalize new_lines: [""] to new_lines: [] for deletion.
240
238
  edits = edits.map((edit) =>
241
239
  edit.new_lines.length === 1 &&
242
240
  edit.new_lines[0] === ""
@@ -278,9 +276,6 @@ export function applyHashlineEdits(
278
276
  assertDoesNotEmptyFile(content, result);
279
277
  const changedRange = computeChangedLineRange(content, result);
280
278
 
281
- // Reconstruct boundary duplication warnings with post-edit hashes.
282
- // Use position + occurrence to find the exact surviving line (handles
283
- // files with multiple identical lines like several `}` closings).
284
279
  const resultLines = result.split("\n");
285
280
  const resultHashes = computeLineHashes(result);
286
281
  for (const bw of boundaryWarnings) {
@@ -316,9 +311,6 @@ export function applyHashlineEdits(
316
311
  }
317
312
 
318
313
 
319
- const ANCHOR_CONTEXT_LINES = 0;
320
- const ANCHOR_MAX_OUTPUT_LINES = 12;
321
-
322
314
  export function computeAffectedLineRange(params: {
323
315
  firstChangedLine: number | undefined;
324
316
  lastChangedLine: number | undefined;
@@ -338,8 +330,6 @@ export function computeAffectedLineRange(params: {
338
330
  return null;
339
331
  }
340
332
 
341
- // When contextLines is 0, skip the anchor block entirely.
342
- // The LLM already knows what it changed and can call read for fresh anchors.
343
333
  if (contextLines === 0) {
344
334
  return null;
345
335
  }
@@ -405,6 +395,7 @@ export function computeChangedLineRange(
405
395
  }
406
396
  if (firstDiff === minLen && original.length === result.length) return null;
407
397
 
398
+
408
399
  let lastOrig = original.length - 1;
409
400
  let lastRes = result.length - 1;
410
401
  while (
@@ -439,5 +430,4 @@ export function computeChangedLineRange(
439
430
  }
440
431
 
441
432
  return { firstChangedLine, lastChangedLine };
442
- }
443
-
433
+ }
@@ -1,11 +1,8 @@
1
-
2
1
  import xxhash from "xxhash-wasm";
3
2
 
4
3
 
5
4
  export const HASH_LENGTH = 3;
6
-
7
5
  export const HASH_PREFIX = "";
8
-
9
6
  export const ANCHOR_LENGTH = HASH_LENGTH;
10
7
 
11
8
  const HASH_ALPHABET =
@@ -42,27 +39,20 @@ export const HASHLINE_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CHARS_CLASS})│
42
39
 
43
40
 
44
41
 
45
- // Lazy-initialized xxhash-wasm hasher. Initialization starts at module load
46
- // time and completes in ~2ms. By the time any tool calls xxh32(), the hasher
47
- // is ready.
48
42
  type Hasher = { h32(input: string, seed?: number): number };
49
43
  let hasherPromise: Promise<Hasher> | null = null;
50
44
  let hasherSync: Hasher | null = null;
51
45
 
52
46
  function getHasher(): Hasher {
53
47
  if (hasherSync) return hasherSync;
54
- // Fast path won't hit this in practice — the wasm init completes in ~2ms
55
- // and no tool call happens at import time. But if it does, throw clearly.
56
48
  throw new Error("xxhash-wasm not initialized yet. This should not happen.");
57
49
  }
58
50
 
59
- // Start initialization immediately at module load time.
60
51
  hasherPromise = xxhash().then((h) => {
61
52
  hasherSync = h;
62
53
  return h;
63
54
  });
64
55
 
65
- // Export for tests that need to await readiness.
66
56
  export function ensureHasherReady(): Promise<Hasher> {
67
57
  return hasherPromise!
68
58
  }
@@ -108,4 +98,4 @@ export const HASH_FORMAT = {
108
98
  };
109
99
 
110
100
 
111
- export { HASH_ALPHABET_RE };
101
+ export { HASH_ALPHABET_RE };
@@ -39,9 +39,7 @@ export interface NoopEdit {
39
39
  export type HashlineToolEdit = {
40
40
  old_range?: [string, string];
41
41
  new_lines?: string[];
42
- /** @deprecated Legacy field — rejected with [E_LEGACY_SHAPE] at validation time. */
43
42
  oldText?: string;
44
- /** @deprecated Legacy field — rejected with [E_LEGACY_SHAPE] at validation time. */
45
43
  newText?: string;
46
44
  };
47
45
 
@@ -136,7 +134,7 @@ function assertEditItem(edit: Record<string, unknown>, index: number): void {
136
134
  const unknownKeys = Object.keys(edit).filter((key) => !ITEM_KEYS.has(key));
137
135
  if (unknownKeys.length > 0) {
138
136
  throw new Error(
139
- `[E_BAD_SHAPE] Edit ${index} contains unknown or unsupported fields: ${unknownKeys.join(", ")}.`,
137
+ `[E_BAD_SHAPE] Edit ${index} contains unknown or unsupported fields: ${unknownKeys.join(", ")}. Each edit takes only { old_range, new_lines }.`,
140
138
  );
141
139
  }
142
140
 
@@ -146,7 +144,7 @@ function assertEditItem(edit: Record<string, unknown>, index: number): void {
146
144
  );
147
145
  }
148
146
  if (!("new_lines" in edit)) {
149
- throw new Error(`[E_BAD_SHAPE] Edit ${index} requires a "new_lines" field.`);
147
+ throw new Error(`[E_BAD_SHAPE] Edit ${index} requires a "new_lines" field. Provide the replacement lines (use [] to delete).`);
150
148
  }
151
149
  if ("new_lines" in edit && !isStringArray(edit.new_lines)) {
152
150
  throw new Error(`[E_BAD_SHAPE] Edit ${index} field "new_lines" must be a string array.`);
@@ -164,7 +162,6 @@ export function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
164
162
  for (const [index, edit] of edits.entries()) {
165
163
  assertEditItem(edit as Record<string, unknown>, index);
166
164
 
167
- // Normalize new_lines: [""] to new_lines: [] for deletion.
168
165
  const replaceLines = hashlineParseText(edit.new_lines ?? null);
169
166
  const normalizedLines =
170
167
  replaceLines.length === 1 && replaceLines[0] === ""
@@ -229,9 +226,6 @@ export function assertNoBareHashPrefixLines(
229
226
  }
230
227
 
231
228
 
232
- /**
233
- * Human-readable label for a resolved edit (used in warnings and conflict errors).
234
- */
235
229
  export function describeEdit(edit: ResolvedHashlineEdit): string {
236
230
  return `replace ${edit.old_range[0].hash}-${edit.old_range[1].hash}`;
237
231
  }
package/src/prompts.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from "fs";
2
+
3
+ export function loadPrompt(relativePath: string, replacements?: Record<string, string>): string {
4
+ let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
5
+ if (replacements) {
6
+ for (const [key, value] of Object.entries(replacements)) {
7
+ content = content.replaceAll(`{{${key}}}`, value);
8
+ }
9
+ }
10
+ return content;
11
+ }
12
+
13
+ export function loadPromptGuidelines(relativePath: string): string[] {
14
+ return readFileSync(new URL(relativePath, import.meta.url), "utf-8")
15
+ .split("\n")
16
+ .map((line) => line.trim())
17
+ .filter((line) => line.startsWith("- "))
18
+ .map((line) => line.slice(2));
19
+ }
package/src/read.ts CHANGED
@@ -8,9 +8,6 @@ import {
8
8
  type TruncationResult,
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import { Type } from "typebox";
11
- import { readFileSync } from "fs";
12
- import { access as fsAccess } from "fs/promises";
13
- import { constants } from "fs";
14
11
  import { normalizeToLF, stripBom } from "./replace-diff";
15
12
  import { loadFileKindAndText } from "./file-kind";
16
13
  import { computeLineHashes, formatHashlineRegion } from "./hashline";
@@ -18,28 +15,16 @@ import { resolveToCwd } from "./path-utils";
18
15
  import { throwIfAborted } from "./runtime";
19
16
  import { getFileSnapshot } from "./snapshot";
20
17
  import { getVisibleLines } from "./utils";
18
+ import { loadPrompt, loadPromptGuidelines } from "./prompts";
19
+ import { validateFileAccess, validateFileKind, isTextFile } from "./validation";
21
20
 
22
- const READ_DESC = readFileSync(
23
- new URL("../prompts/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("../prompts/read-snippet.md", import.meta.url),
32
- "utf-8",
33
- ).trim();
34
-
35
- const READ_PROMPT_GUIDELINES = readFileSync(
36
- new URL("../prompts/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));
21
+ const READ_DESC = loadPrompt("../prompts/read.md", {
22
+ DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
23
+ DEFAULT_MAX_BYTES: formatSize(DEFAULT_MAX_BYTES),
24
+ });
25
+
26
+ const READ_PROMPT_SNIPPET = loadPrompt("../prompts/read-snippet.md");
27
+ const READ_PROMPT_GUIDELINES = loadPromptGuidelines("../prompts/read-guidelines.md");
43
28
 
44
29
  function normalizePositiveInteger(
45
30
  value: number | undefined,
@@ -151,35 +136,11 @@ export function registerReadTool(pi: ExtensionAPI): void {
151
136
  const absolutePath = resolveToCwd(rawPath, ctx.cwd);
152
137
 
153
138
  throwIfAborted(signal);
154
- try {
155
- await fsAccess(absolutePath, constants.R_OK);
156
- } catch (error: unknown) {
157
- const code =
158
- error instanceof Error
159
- ? (error as NodeJS.ErrnoException).code
160
- : undefined;
161
- if (code === "ENOENT") {
162
- throw new Error(`File not found: ${rawPath}`);
163
- }
164
- if (code === "EACCES" || code === "EPERM") {
165
- throw new Error(`File is not readable: ${rawPath}`);
166
- }
167
- throw new Error(`Cannot access file: ${rawPath}`);
168
- }
139
+ await validateFileAccess(absolutePath, rawPath);
169
140
 
170
141
  throwIfAborted(signal);
171
142
  const file = await loadFileKindAndText(absolutePath);
172
- if (file.kind === "directory") {
173
- throw new Error(
174
- `Path is a directory: ${rawPath}. Use ls to inspect directories.`,
175
- );
176
- }
177
-
178
- if (file.kind === "binary") {
179
- throw new Error(
180
- `Path is a binary file: ${rawPath} (${file.description}). Hashline read only supports text files and supported images.`,
181
- );
182
- }
143
+ validateFileKind(file, rawPath);
183
144
 
184
145
  if (file.kind === "image") {
185
146
  const builtinRead = createReadTool(ctx.cwd);
@@ -195,9 +156,6 @@ export function registerReadTool(pi: ExtensionAPI): void {
195
156
 
196
157
  throwIfAborted(signal);
197
158
  const normalized = normalizeToLF(stripBom(file.text).text);
198
- // Compute hashes once for the whole file so the per-line anchors the
199
- // model sees here are byte-identical to what the replace tool will
200
- // compute when it later validates against this file.
201
159
  const fileHashes = computeLineHashes(normalized);
202
160
  const preview = formatHashlineReadPreview(
203
161
  normalized,
@@ -209,9 +167,6 @@ export function registerReadTool(pi: ExtensionAPI): void {
209
167
  );
210
168
  const snapshot = await getFileSnapshot(absolutePath);
211
169
 
212
- // Invalid UTF-8 bytes are decoded as U+FFFD, matching Pi's built-in
213
- // tools. Warn only when the decoder reported invalid bytes; a literal,
214
- // valid U+FFFD in a UTF-8 file should not be treated as lossy decoding.
215
170
  const previewText =
216
171
  file.hadUtf8DecodeErrors === true
217
172
  ? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
@@ -221,14 +176,10 @@ export function registerReadTool(pi: ExtensionAPI): void {
221
176
  content: [{ type: "text", text: previewText }],
222
177
  details: {
223
178
  truncation: preview.truncation,
224
- // snapshotId remains in details for host UI (e.g. "file changed since
225
- // last view"). It is NOT echoed in text — the LLM no longer needs it.
226
179
  snapshotId: snapshot.snapshotId,
227
180
  ...(preview.nextOffset !== undefined
228
181
  ? { nextOffset: preview.nextOffset }
229
182
  : {}),
230
- // Phase 2 C — host-only observability. Truncated reads usually mean
231
- // a follow-up read with `offset = next_offset` is coming.
232
183
  metrics: {
233
184
  truncated: !!preview.truncation,
234
185
  ...(preview.nextOffset !== undefined
@@ -239,4 +190,4 @@ export function registerReadTool(pi: ExtensionAPI): void {
239
190
  };
240
191
  },
241
192
  });
242
- }
193
+ }
@@ -85,19 +85,16 @@ export function generateDiffString(
85
85
  let linesToShow = raw;
86
86
  let skipStart = 0;
87
87
  let skipEnd = 0;
88
- let skipMiddle = 0; // lines skipped between head and tail context
88
+ let skipMiddle = 0;
89
89
 
90
90
  if (!lastWasChange) {
91
- // Before a change: show last contextLines only.
92
91
  skipStart = Math.max(0, raw.length - contextLines);
93
92
  linesToShow = raw.slice(skipStart);
94
93
  } else if (nextPartIsChange && raw.length > contextLines * 2) {
95
- // Between two changes: show first contextLines + last contextLines with ellipsis in between.
96
94
  const tail = raw.slice(-contextLines);
97
95
  linesToShow = [...raw.slice(0, contextLines), "__ELLIPSIS__", ...tail];
98
96
  skipMiddle = raw.length - contextLines * 2;
99
97
  } else if (linesToShow.length > contextLines) {
100
- // After a change with no next change nearby: show first contextLines only.
101
98
  skipEnd = linesToShow.length - contextLines;
102
99
  linesToShow = linesToShow.slice(0, contextLines);
103
100
  }
@@ -234,4 +231,4 @@ export function buildCompactHashlineDiffPreview(
234
231
  addedLines,
235
232
  removedLines,
236
233
  };
237
- }
234
+ }
@@ -1,4 +1,3 @@
1
-
2
1
  import { isRecord, hasOwn } from "./utils";
3
2
 
4
3
  function coerceEditsArray(edits: unknown): unknown {
@@ -21,7 +20,6 @@ export function normalizeReplaceRequest(input: unknown): unknown {
21
20
 
22
21
  const record: Record<string, unknown> = { ...input };
23
22
 
24
- // file_path → path alias.
25
23
  if (typeof record.path !== "string" && typeof record.file_path === "string") {
26
24
  record.path = record.file_path;
27
25
  delete record.file_path;
@@ -29,11 +27,9 @@ export function normalizeReplaceRequest(input: unknown): unknown {
29
27
 
30
28
  const hasEditsField = hasOwn(record, "edits");
31
29
 
32
- // edits-as-JSON-string → array.
33
30
  if (hasEditsField) {
34
31
  record.edits = coerceEditsArray(record.edits);
35
32
  }
36
33
 
37
34
  return record;
38
- }
39
-
35
+ }
@@ -1,17 +1,9 @@
1
- /**
2
- * TUI rendering helpers for the replace tool.
3
- *
4
- * Extracted from `src/replace.ts` to separate presentation (color themes, diff
5
- * formatting, Markdown rendering) from tool execution logic.
6
- */
7
1
 
8
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
9
3
  import { normalizeReplaceRequest } from "./replace-normalize";
10
4
  import type { ReplaceRequestParams, HashlineReplaceToolDetails } from "./replace";
11
5
  import { isRecord } from "./utils";
12
6
 
13
- // ─── Theme type aliases ─────────────────────────────────────────────────
14
-
15
7
  export type FgTheme = Pick<Theme, "fg">;
16
8
  export type CallTheme = Pick<Theme, "fg" | "bold">;
17
9
  export type RenderedMarkdownTheme = Pick<
@@ -19,8 +11,6 @@ export type RenderedMarkdownTheme = Pick<
19
11
  "fg" | "bold" | "italic" | "underline" | "strikethrough"
20
12
  >;
21
13
 
22
- // ─── Render state ───────────────────────────────────────────────────────
23
-
24
14
  export type ReplacePreview = { diff: string } | { error: string };
25
15
 
26
16
  export type ReplaceRenderState = {
@@ -29,8 +19,6 @@ export type ReplaceRenderState = {
29
19
  previewGeneration?: number;
30
20
  };
31
21
 
32
- // ─── Preview input extraction ───────────────────────────────────────────
33
-
34
22
 
35
23
  export function getRenderablePreviewInput(
36
24
  args: unknown,
@@ -53,8 +41,6 @@ export function getRenderablePreviewInput(
53
41
  return request.edits !== undefined ? request : null;
54
42
  }
55
43
 
56
- // ─── Diff formatting ────────────────────────────────────────────────────
57
-
58
44
  export function colorDiffLines(lines: string[], theme: FgTheme): string[] {
59
45
  return lines.map((line) => {
60
46
  if (line.startsWith("+") && !line.startsWith("+++")) {
@@ -88,8 +74,6 @@ export function formatResultDiff(diff: string, theme: FgTheme): string {
88
74
  return colorDiffLines(diff.split("\n"), theme).join("\n");
89
75
  }
90
76
 
91
- // ─── Edit call formatting ───────────────────────────────────────────────
92
-
93
77
  export function formatEditCall(
94
78
  args: ReplaceRequestParams | undefined,
95
79
  state: ReplaceRenderState,
@@ -118,8 +102,6 @@ export function formatEditCall(
118
102
  return text;
119
103
  }
120
104
 
121
- // ─── Result text extraction ─────────────────────────────────────────────
122
-
123
105
  export function getRenderedEditTextContent(result: {
124
106
  content?: Array<{ type: string; text?: string }>;
125
107
  }): string | undefined {
@@ -136,8 +118,6 @@ export function extractRenderedWarnings(
136
118
  return text?.match(/(?:^|\n)Warnings:\n[\s\S]*$/)?.[0]?.trimStart();
137
119
  }
138
120
 
139
- // ─── Result classification ──────────────────────────────────────────────
140
-
141
121
  export function isAppliedChangedResult(
142
122
  details: HashlineReplaceToolDetails | undefined,
143
123
  ): boolean {
@@ -165,7 +145,6 @@ export function buildAppliedChangedResultText(
165
145
 
166
146
  return sections.length > 0 ? sections.join("\n\n") : undefined;
167
147
  }
168
- // ─── Markdown rendering ─────────────────────────────────────────────────
169
148
 
170
149
  function trimEdgeEmptyLines(lines: string[]): string[] {
171
150
  let start = 0;
@@ -272,4 +251,4 @@ export function createRenderedEditMarkdownTheme(theme: RenderedMarkdownTheme) {
272
251
  return theme.fg("mdCodeBlock", line);
273
252
  }),
274
253
  };
275
- }
254
+ }
@@ -1,4 +1,3 @@
1
-
2
1
  import { generateDiffString } from "./replace-diff";
3
2
  import {
4
3
  computeAffectedLineRange,
@@ -6,6 +5,7 @@ import {
6
5
  formatHashlineRegion,
7
6
  } from "./hashline";
8
7
  import { getVisibleLines } from "./utils";
8
+ import { CHANGED_ANCHOR_TEXT_BUDGET_BYTES } from "./constants";
9
9
 
10
10
  type ToolResult = {
11
11
  content: Array<{ type: "text"; text: string }>;
@@ -13,9 +13,6 @@ type ToolResult = {
13
13
  details: any;
14
14
  };
15
15
 
16
- const CHANGED_ANCHOR_TEXT_BUDGET_BYTES = 50 * 1024;
17
-
18
-
19
16
  export type ReplaceMetrics = {
20
17
  edits_attempted: number;
21
18
  edits_noop: number;
@@ -45,7 +42,6 @@ type NoopEditEntry = {
45
42
  };
46
43
 
47
44
 
48
- // ─── Builder inputs ─────────────────────────────────────────────────────
49
45
 
50
46
  export interface NoopResponseInput {
51
47
  path: string;
@@ -65,7 +61,6 @@ export interface SuccessResponseInput {
65
61
  editMeta: ReplaceMeta;
66
62
  }
67
63
 
68
- // ─── Helpers ────────────────────────────────────────────────────────────
69
64
 
70
65
 
71
66
  function countDiffLines(diff: string, marker: "+" | "-"): number {
@@ -118,7 +113,6 @@ function warningsBlockOf(warnings: string[] | undefined): string {
118
113
  return warnings?.length ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
119
114
  }
120
115
 
121
- // ─── Builders ───────────────────────────────────────────────────────────
122
116
 
123
117
  export function buildNoopResponse(input: NoopResponseInput): ToolResult {
124
118
  const {
@@ -190,10 +184,10 @@ export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
190
184
  CHANGED_ANCHOR_TEXT_BUDGET_BYTES
191
185
  ? block
192
186
  : "Anchors omitted; use read for subsequent edits.";
193
- })()
187
+ })()
194
188
  : resultLines.length === 0
195
189
  ? "File is empty. Use edit to insert content."
196
- : ""; // No anchor context → show nothing; LLM can call read for fresh anchors
190
+ : "";
197
191
  const text = [anchorsBlock, warningsBlock.trimStart()]
198
192
  .filter((section) => section.length > 0)
199
193
  .join("\n\n");
@@ -219,4 +213,4 @@ export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
219
213
  metrics,
220
214
  },
221
215
  };
222
- }
216
+ }
package/src/replace.ts CHANGED
@@ -6,8 +6,6 @@ import type {
6
6
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
7
7
  import { Type } from "typebox";
8
8
  import { constants } from "fs";
9
- import { readFileSync } from "fs";
10
- import { access as fsAccess } from "fs/promises";
11
9
  import {
12
10
  detectLineEnding,
13
11
  generateDiffString,
@@ -45,6 +43,8 @@ import {
45
43
  type ReplacePreview,
46
44
  type ReplaceRenderState,
47
45
  } from "./replace-render";
46
+ import { loadPrompt, loadPromptGuidelines } from "./prompts";
47
+ import { validateFileAccess, validateFileKind, isTextFile } from "./validation";
48
48
 
49
49
 
50
50
  const hashlineEditNewLinesSchema = Type.Array(Type.String(), {
@@ -87,40 +87,15 @@ export type ReplaceRequestParams = {
87
87
  export type HashlineReplaceToolDetails = {
88
88
  diff: string;
89
89
  firstChangedLine?: number;
90
- /**
91
- * Post-edit snapshot fingerprint. Surfaced in details only — the LLM no
92
- * longer receives or echoes it. Hosts may use this for UI hints (e.g.
93
- * "file changed since last view"). See plan W2.
94
- */
95
90
  snapshotId?: string;
96
91
  classification?: "noop";
97
92
  structureOutline?: string[];
98
- /**
99
- * Phase 2 C — opt-in observability surface for hosts. Never echoed in text.
100
- * Hosts can use it for adoption/regression dashboards.
101
- */
102
93
  metrics?: ReplaceMetrics;
103
94
  };
104
95
 
105
- const EDIT_DESC = readFileSync(
106
- new URL("../prompts/replace.md", import.meta.url),
107
- "utf-8",
108
- ).trim();
109
-
110
- const EDIT_PROMPT_SNIPPET = readFileSync(
111
- new URL("../prompts/replace-snippet.md", import.meta.url),
112
- "utf-8",
113
- ).trim();
114
-
115
-
116
- const EDIT_PROMPT_GUIDELINES = readFileSync(
117
- new URL("../prompts/replace-guidelines.md", import.meta.url),
118
- "utf-8",
119
- )
120
- .split("\n")
121
- .map((line) => line.trim())
122
- .filter((line) => line.startsWith("- "))
123
- .map((line) => line.slice(2));
96
+ const EDIT_DESC = loadPrompt("../prompts/replace.md");
97
+ const EDIT_PROMPT_SNIPPET = loadPrompt("../prompts/replace-snippet.md");
98
+ const EDIT_PROMPT_GUIDELINES = loadPromptGuidelines("../prompts/replace-guidelines.md");
124
99
  const ROOT_KEYS = new Set(["path", "edits"]);
125
100
 
126
101
  export function assertReplaceRequest(
@@ -143,7 +118,7 @@ export function assertReplaceRequest(
143
118
  );
144
119
  if (unknownRootKeys.length > 0) {
145
120
  throw new Error(
146
- `[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: ${unknownRootKeys.join(", ")}.`,
121
+ `[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: ${unknownRootKeys.join(", ")}. Use only { path, edits }; each edit is { old_range: ["<START>", "<END>"], new_lines: [...] }.`,
147
122
  );
148
123
  }
149
124
 
@@ -152,7 +127,7 @@ export function assertReplaceRequest(
152
127
  }
153
128
 
154
129
  if (hasOwn(request, "edits") && !Array.isArray(request.edits)) {
155
- throw new Error('[E_BAD_SHAPE] Edit request requires an "edits" array when provided.');
130
+ throw new Error('[E_BAD_SHAPE] Edit request requires an "edits" array when provided. Each edit is { old_range: ["<START>", "<END>"], new_lines: [...] }.');
156
131
  }
157
132
 
158
133
  }
@@ -190,38 +165,11 @@ async function executeEditPipeline(
190
165
  }
191
166
 
192
167
  throwIfAborted(signal);
193
- try {
194
- await fsAccess(absolutePath, accessMode);
195
- } catch (error: unknown) {
196
- const code = (error as NodeJS.ErrnoException).code;
197
- if (code === "ENOENT") {
198
- throw new Error(`File not found: ${path}`);
199
- }
200
- if (code === "EACCES" || code === "EPERM") {
201
- const accessLabel =
202
- accessMode & constants.W_OK ? "not writable" : "not readable";
203
- throw new Error(`File is ${accessLabel}: ${path}`);
204
- }
205
- throw new Error(`Cannot access file: ${path}`);
206
- }
168
+ await validateFileAccess(absolutePath, path, accessMode);
207
169
 
208
170
  throwIfAborted(signal);
209
171
  const file = await loadFileKindAndText(absolutePath);
210
- if (file.kind === "directory") {
211
- throw new Error(
212
- `Path is a directory: ${path}. Use ls to inspect directories.`,
213
- );
214
- }
215
- if (file.kind === "image") {
216
- throw new Error(
217
- `Path is an image file: ${path}. Hashline edit only supports text files.`,
218
- );
219
- }
220
- if (file.kind === "binary") {
221
- throw new Error(
222
- `Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`,
223
- );
224
- }
172
+ validateFileKind(file, path);
225
173
 
226
174
  throwIfAborted(signal);
227
175
  const { bom, text: rawContent } = stripBom(file.text);
@@ -493,4 +441,4 @@ const editToolDefinition: EditToolDefinition = {
493
441
 
494
442
  export function registerReplaceTool(pi: ExtensionAPI): void {
495
443
  pi.registerTool(editToolDefinition);
496
- }
444
+ }
package/src/snapshot.ts CHANGED
@@ -11,13 +11,6 @@ function formatSnapshotId(canonicalPath: string, info: { mtimeMs: number; size:
11
11
  return `v1|${canonicalPath}|${info.mtimeMs}|${info.size}`;
12
12
  }
13
13
 
14
- /**
15
- * Stat the file and return its current snapshot fingerprint.
16
- *
17
- * The snapshot is exposed only via `details.snapshotId` for host UIs (e.g.
18
- * "file changed since last view"). It is no longer used to reject edits or
19
- * surfaced in tool text — the LLM does not need to track it.
20
- */
21
14
  export async function getFileSnapshot(absolutePath: string): Promise<SnapshotInfo> {
22
15
  const canonicalPath = await resolveMutationTargetPath(absolutePath);
23
16
  const stats = await stat(canonicalPath);
package/src/utils.ts CHANGED
@@ -1,6 +1,3 @@
1
- /**
2
- * Shared type guards and utility helpers.
3
- */
4
1
 
5
2
  export function isRecord(value: unknown): value is Record<string, unknown> {
6
3
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -10,18 +7,12 @@ export function hasOwn(record: Record<string, unknown>, key: string): boolean {
10
7
  return Object.hasOwn(record, key);
11
8
  }
12
9
 
13
- /**
14
- * Return the visible lines of a text (excluding the terminal-newline sentinel).
15
- */
16
10
  export function getVisibleLines(text: string): string[] {
17
11
  if (text.length === 0) return [];
18
12
  const lines = text.split("\n");
19
13
  return text.endsWith("\n") ? lines.slice(0, -1) : lines;
20
14
  }
21
15
 
22
- /**
23
- * Count the visible lines of a text (excluding the terminal-newline sentinel).
24
- */
25
16
  export function countVisibleLines(text: string): number {
26
17
  return getVisibleLines(text).length;
27
18
  }
@@ -0,0 +1,46 @@
1
+ import { constants } from "fs";
2
+ import { access as fsAccess } from "fs/promises";
3
+ import type { LoadedFile } from "./file-kind";
4
+
5
+ export async function validateFileAccess(
6
+ absolutePath: string,
7
+ path: string,
8
+ accessMode: number = constants.R_OK,
9
+ ): Promise<void> {
10
+ try {
11
+ await fsAccess(absolutePath, accessMode);
12
+ } catch (error: unknown) {
13
+ const code = getErrorCode(error);
14
+ if (code === "ENOENT") {
15
+ throw new Error(`File not found: ${path}`);
16
+ }
17
+ if (code === "EACCES" || code === "EPERM") {
18
+ const accessLabel = accessMode & constants.W_OK ? "not writable" : "not readable";
19
+ throw new Error(`File is ${accessLabel}: ${path}`);
20
+ }
21
+ throw new Error(`Cannot access file: ${path}`);
22
+ }
23
+ }
24
+
25
+ export function validateFileKind(file: LoadedFile, path: string): void {
26
+ if (file.kind === "directory") {
27
+ throw new Error(`Path is a directory: ${path}. Use ls to inspect directories.`);
28
+ }
29
+ if (file.kind === "binary") {
30
+ throw new Error(`Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`);
31
+ }
32
+ if (file.kind === "image") {
33
+ throw new Error(`Path is an image file: ${path}. Hashline edit only supports text files.`);
34
+ }
35
+ }
36
+
37
+ export function isTextFile(file: LoadedFile): file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
38
+ return file.kind === "text";
39
+ }
40
+
41
+ export function getErrorCode(error: unknown): string | undefined {
42
+ if (error instanceof Error) {
43
+ return (error as NodeJS.ErrnoException).code;
44
+ }
45
+ return undefined;
46
+ }