pi-hashline-edit-pro 6.3.1 → 6.4.1

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.
Files changed (45) hide show
  1. package/README.md +33 -32
  2. package/index.ts +32 -16
  3. package/package.json +1 -1
  4. package/src/batch.ts +30 -9
  5. package/src/commit.ts +17 -3
  6. package/src/config-ui.ts +1 -1
  7. package/src/constants.ts +3 -0
  8. package/src/copy-move.ts +100 -68
  9. package/src/edit-common.ts +86 -54
  10. package/src/edit-fidelity.ts +16 -6
  11. package/src/fs-write.ts +14 -2
  12. package/src/grep.ts +22 -11
  13. package/src/insert.ts +5 -4
  14. package/src/line-endings.ts +6 -2
  15. package/src/model-gate.ts +1 -1
  16. package/src/payload-contract.ts +13 -9
  17. package/src/pre-edit-snapshot.ts +19 -0
  18. package/src/prompts.ts +32 -0
  19. package/src/read.ts +5 -10
  20. package/src/replace-diff.ts +5 -3
  21. package/src/replace-match.ts +2 -2
  22. package/src/replace-render.ts +4 -4
  23. package/src/replace-response.ts +1 -0
  24. package/src/replace-undo.ts +4 -4
  25. package/src/replace.ts +2 -2
  26. package/src/structured.ts +2 -1
  27. package/src/utils.ts +29 -0
  28. package/src/write-hook.ts +1 -1
  29. package/tool-prompts/copy-snippet.md +1 -1
  30. package/tool-prompts/copy.md +2 -2
  31. package/tool-prompts/grep-guidelines.md +2 -1
  32. package/tool-prompts/grep-snippet.md +1 -1
  33. package/tool-prompts/grep.md +1 -1
  34. package/tool-prompts/insert.md +1 -1
  35. package/tool-prompts/move-snippet.md +1 -1
  36. package/tool-prompts/move.md +2 -2
  37. package/tool-prompts/read-guidelines.md +2 -2
  38. package/tool-prompts/read.md +1 -3
  39. package/tool-prompts/replace-match-guidelines.md +1 -0
  40. package/tool-prompts/replace-match-snippet.md +1 -1
  41. package/tool-prompts/replace-match.md +1 -1
  42. package/tool-prompts/replace.md +3 -2
  43. package/tool-prompts/transfer-guidelines.md +3 -3
  44. package/tool-prompts/undo-last-change-snippet.md +1 -1
  45. package/tool-prompts/undo-last-change.md +1 -1
@@ -218,15 +218,22 @@ function sameCallHead(payloadLine: string, referenceLine: string): boolean {
218
218
  return left !== undefined && left === right;
219
219
  }
220
220
 
221
- function indentMismatchHint(payloadLine: string, reference: ReferenceRow, anchor: string | undefined): string | undefined {
221
+ function indentMismatchHint(
222
+ payloadLine: string,
223
+ reference: ReferenceRow,
224
+ newAnchor: string | undefined,
225
+ referenceAnchor: string | undefined,
226
+ ): string | undefined {
222
227
  const payloadIndent = leadingWhitespace(payloadLine);
223
228
  const referenceIndent = leadingWhitespace(reference.line);
224
229
  if (payloadIndent.length >= referenceIndent.length || !referenceIndent.startsWith(payloadIndent)) return undefined;
225
230
  const grams = gramSet([payloadLine], INDENT_SIMILARITY_RUN);
226
231
  const sharesLongRun = grams !== undefined && sharesRun(reference.line, grams, INDENT_SIMILARITY_RUN);
227
232
  if (!sharesLongRun && !sameCallHead(payloadLine, reference.line)) return undefined;
228
- const label = anchor === undefined ? "the nearby line" : `${anchor}${HASH_SEP}`;
229
- return `[H_INDENT_MISMATCH] new line has ${plural(payloadIndent.length, "leading whitespace character")}; ${label} has ${referenceIndent.length}.`;
233
+ const count = plural(payloadIndent.length, "leading whitespace character");
234
+ const referenceLabel = referenceAnchor === undefined ? "the nearby line" : `neighboring line ${referenceAnchor}${HASH_SEP}`;
235
+ const newLabel = newAnchor === undefined ? "new line" : `new line ${newAnchor}${HASH_SEP}`;
236
+ return `[H_INDENT_MISMATCH] ${newLabel} has ${count}; ${referenceLabel} has ${referenceIndent.length}.`;
230
237
  }
231
238
 
232
239
  function referenceRows(lines: string[], start: number, end: number): ReferenceRow[] {
@@ -373,11 +380,12 @@ export function fidelityHints(
373
380
  resultContent: string,
374
381
  spans: readonly DiffSpan[] | undefined,
375
382
  originalHashes?: readonly string[],
376
- options?: { separatorMoved?: boolean; indentHints?: boolean },
383
+ options?: { separatorMoved?: boolean; indentHints?: boolean; resultHashes?: readonly string[] },
377
384
  ): string[] {
378
385
  if (spans === undefined || spans.length === 0) return [];
379
386
  const oldLines = splitLines(originalContent);
380
387
  const newLines = splitLines(resultContent);
388
+ const resultHashes = options?.resultHashes;
381
389
  const hints: string[] = [];
382
390
  const seen = new Set<string>();
383
391
  let sensitiveIndex: Map<string, IndexedLine[]> | undefined;
@@ -460,7 +468,7 @@ export function fidelityHints(
460
468
  let nearestDistance = Number.POSITIVE_INFINITY;
461
469
  for (const candidate of references) {
462
470
  if (candidate.index < span.start - INDENT_REFERENCE_WINDOW || candidate.index > span.end + INDENT_REFERENCE_WINDOW) continue;
463
- if (indentMismatchHint(payload[payloadIndex]!, candidate, undefined) === undefined) continue;
471
+ if (indentMismatchHint(payload[payloadIndex]!, candidate, undefined, undefined) === undefined) continue;
464
472
  const distance = Math.abs(candidate.index - span.start);
465
473
  if (distance < nearestDistance) {
466
474
  nearest = candidate;
@@ -474,12 +482,14 @@ export function fidelityHints(
474
482
  }
475
483
  return undefined;
476
484
  };
485
+ const resultIndexForPayload = (payloadIndex: number): number =>
486
+ span.start + offset + (carried !== undefined && payloadIndex >= carried ? payloadIndex + 1 : payloadIndex);
477
487
  for (let index = 0; index < payload.length; index += 1) {
478
488
  if (hints.length >= MAX_FIDELITY_HINTS) break;
479
489
  const payloadLine = payload[index]!;
480
490
  const reference = indentReferenceFor(index);
481
491
  if (reference === undefined) continue;
482
- const indentHint = indentMismatchHint(payloadLine, reference, originalHashes?.[reference.index]);
492
+ const indentHint = indentMismatchHint(payloadLine, reference, resultHashes?.[resultIndexForPayload(index)], originalHashes?.[reference.index]);
483
493
  if (indentHint === undefined) continue;
484
494
  const key = `indent:${reference.index}:${payloadLine}`;
485
495
  if (seen.has(key)) continue;
package/src/fs-write.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  } from "node:fs/promises";
14
14
  import { dirname, join, parse, resolve, sep } from "node:path";
15
15
  import { toCwd } from "./paths";
16
- import { errCode, isModeUnsupported } from "./utils";
16
+ import { errCode, isModeUnsupported, mapIoError } from "./utils";
17
17
 
18
18
  export interface FileIdentity {
19
19
  dev: number;
@@ -138,7 +138,7 @@ export async function resolveInCwd(path: string, cwd: string): Promise<{ absolut
138
138
  const resolved = await resolveTarget(absolute);
139
139
  return { absolute, resolved };
140
140
  }
141
- export async function writeAtomic(
141
+ async function writeAtomicRaw(
142
142
  path: string,
143
143
  content: string,
144
144
  expectedIdentity?: FileIdentity,
@@ -223,3 +223,15 @@ export async function writeAtomic(
223
223
  throw error;
224
224
  }
225
225
  }
226
+ export async function writeAtomic(
227
+ path: string,
228
+ content: string,
229
+ expectedIdentity?: FileIdentity,
230
+ restoreMode?: number,
231
+ ): Promise<void> {
232
+ try {
233
+ await writeAtomicRaw(path, content, expectedIdentity, restoreMode);
234
+ } catch (error) {
235
+ throw mapIoError(error, "write", path);
236
+ }
237
+ }
package/src/grep.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult } from "@earendil-works/pi-coding-agent";
2
+ import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, createGrepToolDefinition, type TruncationResult } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
  import { stat } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, join, win32 } from "node:path";
@@ -11,10 +11,10 @@ import { globToRegex } from "./glob";
11
11
  import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP, HASH_CLASS } from "./hashline";
12
12
  import { ANCHOR_POOL_EXHAUSTED_PREFIX, MAX_GREP_LINE_BYTES } from "./constants";
13
13
  import { toCwd, toDisplayPath } from "./paths";
14
- import { loadP, loadGuide } from "./prompts";
14
+ import { loadP, loadGuidelines } from "./prompts";
15
15
  import { normReq } from "./payload-contract";
16
16
  import { DEFAULT_EDIT_FLAGS, withGrepPrompts, type EditToolFlags } from "./edit-common";
17
- import { abortIf, clipLine, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines, withLineNumbers } from "./utils";
17
+ import { abortIf, clipLine, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines, gutterWidth } from "./utils";
18
18
  import { withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
19
19
  import { serveRows } from "./served";
20
20
  import { Text } from "@earendil-works/pi-tui";
@@ -351,7 +351,12 @@ async function collectRgMatches(
351
351
 
352
352
 
353
353
  function displayRowsForHit(hit: FileHit): string[] {
354
- return withLineNumbers(hit.rows.join("\n"), hit.lineNumbers).split("\n").slice(0, hit.rows.length);
354
+ const matched = new Set(hit.matchLines);
355
+ const width = gutterWidth(hit.lineNumbers.reduce((m, n) => (n > m ? n : m), 0), hit.lineNumbers.length);
356
+ return hit.rows.map((row, index) => {
357
+ const lineNumber = hit.lineNumbers[index]!;
358
+ return `${String(lineNumber).padStart(width)}${matched.has(lineNumber) ? ":" : "-"}${HASH_SEP} ${row}`;
359
+ });
355
360
  }
356
361
 
357
362
  const grepToolSchema = Type.Object(
@@ -408,7 +413,7 @@ export function fmtGrepCall(args: { pattern?: unknown; path?: unknown; glob?: un
408
413
  if (args?.ignoreCase === true) qualifiers.push("case-insensitive");
409
414
  if (typeof args?.context === "number") qualifiers.push(`context ${args.context}`);
410
415
  if (typeof args?.limit === "number") qualifiers.push(`limit ${args.limit}`);
411
- let text = `${theme.fg("toolTitle", theme.bold("anchor_grep"))} ${pattern}`;
416
+ let text = `${theme.fg("toolTitle", theme.bold("grep"))} ${pattern}`;
412
417
  if (qualifiers.length > 0) text += ` ${theme.fg("dim", qualifiers.join(" "))}`;
413
418
  return text;
414
419
  }
@@ -436,6 +441,7 @@ function highlightMatches(text: string, regex: RegExp, theme: FgT): string {
436
441
  }
437
442
 
438
443
  const ANCHORED_ROW_RE = new RegExp(`^${HASH_CLASS}${HASH_SEP}`);
444
+ const GREP_GUTTER_RE = /^\s*\d+(?: │ |[:-]│ )/;
439
445
 
440
446
  function highlightHitRow(row: string, highlight: RegExp, theme: FgT): string {
441
447
  const anchored = row.match(ANCHORED_ROW_RE);
@@ -447,9 +453,9 @@ function styleGrepLine(line: string, highlight: RegExp | undefined, theme: FgT):
447
453
  if (line.startsWith("=== ")) return theme.fg("accent", line);
448
454
  if (line.startsWith("[grep:") || line.startsWith("... ")) return theme.fg("dim", line);
449
455
  if (!highlight) return line;
450
- const gutter = line.indexOf(" │ ");
451
- if (gutter < 0) return highlightHitRow(line, highlight, theme);
452
- const cut = gutter + " │ ".length;
456
+ const gutter = GREP_GUTTER_RE.exec(line);
457
+ if (gutter === null) return highlightHitRow(line, highlight, theme);
458
+ const cut = gutter[0].length;
453
459
  return line.slice(0, cut) + highlightHitRow(line.slice(cut), highlight, theme);
454
460
  }
455
461
 
@@ -471,11 +477,11 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
471
477
  const prompted = withGrepPrompts({
472
478
  description: loadP("../tool-prompts/grep.md"),
473
479
  snippet: loadP("../tool-prompts/grep-snippet.md"),
474
- guidelines: loadGuide("../tool-prompts/grep-guidelines.md"),
480
+ guidelines: loadGuidelines("../tool-prompts/grep-guidelines.md"),
475
481
  }, flags);
476
482
  pi.registerTool({
477
- name: "anchor_grep",
478
- label: "Anchor Grep",
483
+ name: "grep",
484
+ label: "Grep",
479
485
  description: prompted.description,
480
486
  promptSnippet: prompted.snippet,
481
487
  promptGuidelines: prompted.guidelines,
@@ -627,6 +633,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
627
633
  if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
628
634
  if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
629
635
  if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; narrow the search.]`);
636
+ if (context > 0 && hits.length > 0) notes.push('[grep: match rows are "line:│", context rows are "line-│".]');
630
637
  const truncated = limitTruncated || rowTruncated;
631
638
  const truncation: TruncationResult | undefined = rowTruncated
632
639
  ? {
@@ -679,3 +686,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
679
686
  },
680
687
  });
681
688
  }
689
+
690
+ export function regVanillaGrep(pi: ExtensionAPI, cwd: string): void {
691
+ pi.registerTool(createGrepToolDefinition(cwd));
692
+ }
package/src/insert.ts CHANGED
@@ -6,10 +6,11 @@ import { commitEdit } from "./commit";
6
6
  import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
7
7
  import { readNormFile, type NormFile } from "./file-reader";
8
8
  import { MAX_HASH_LINES, parseHashRef, parsePayloadText, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, type Anchor, type HTEdit, type StripWarningLocation } from "./hashline";
9
+ import { TEXT_ENDING_NOTE } from "./constants";
9
10
  import type { LineEnding } from "./normalize";
10
11
  import { stripAnchorRow } from "./hashline/resolve";
11
12
  import { withAnchorSession } from "./anchor-registry";
12
- import { loadP, loadGuide } from "./prompts";
13
+ import { loadP, loadGuidelines } from "./prompts";
13
14
  import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
14
15
  import { isRec, literalEscapeHints, splitLines } from "./utils";
15
16
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
@@ -19,7 +20,7 @@ export { assertInsertReq, type InsertReq };
19
20
 
20
21
  const insertAnchorSchema = Type.String({
21
22
  description:
22
- "4-char anchor of the line to insert next to (never the row content).",
23
+ "4-char anchor of the line to insert next to.",
23
24
  });
24
25
 
25
26
  const insertDirectionSchema = Type.Union(
@@ -28,7 +29,7 @@ const insertDirectionSchema = Type.Union(
28
29
  );
29
30
  const insertTextSchema = Type.String({
30
31
  description:
31
- 'The exact text to insert; an empty string inserts one blank line. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line. Never include the anchor line.',
32
+ `The exact text to insert; an empty string inserts one blank line. ${TEXT_ENDING_NOTE} Never include the anchor line.`,
32
33
  });
33
34
 
34
35
  const insertPathRequiredSchema = Type.String({
@@ -192,7 +193,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
192
193
  const prompted = withInsertPrompts({
193
194
  description: loadP("../tool-prompts/insert.md"),
194
195
  snippet: loadP("../tool-prompts/insert-snippet.md"),
195
- guidelines: loadGuide("../tool-prompts/insert-guidelines.md"),
196
+ guidelines: loadGuidelines("../tool-prompts/insert-guidelines.md"),
196
197
  }, flags);
197
198
  return {
198
199
  name: "insert",
@@ -35,9 +35,13 @@ export function splitSeparators(raw: string): LineEnding[] {
35
35
  export function joinSeparators(content: string, separators: readonly LineEnding[]): string {
36
36
  let out = "";
37
37
  let separatorIndex = 0;
38
- for (const char of content) {
38
+ for (let index = 0; index < content.length; index += 1) {
39
+ const char = content[index]!;
39
40
  if (char === "\n") {
40
- out += separators[separatorIndex] ?? "\n";
41
+ const separator = separators[separatorIndex] ?? "\n";
42
+ const emptyLineBefore = content[index - 1] === "\n";
43
+ const followsLoneCr = separator === "\n" && separators[separatorIndex - 1] === "\r" && emptyLineBefore;
44
+ out += followsLoneCr ? "\r\n" : separator;
41
45
  separatorIndex += 1;
42
46
  } else {
43
47
  out += char;
package/src/model-gate.ts CHANGED
@@ -11,7 +11,7 @@ export const ANCHOR_TOOL_NAMES: readonly string[] = [
11
11
  "insert",
12
12
  "copy",
13
13
  "move",
14
- "anchor_grep",
14
+ "grep",
15
15
  "undo_last_change",
16
16
  ];
17
17
 
@@ -1,15 +1,15 @@
1
1
  import { Type } from "typebox";
2
2
  import { isRec, normalizeRequest, rejectUnknownFields, assertNoNul } from "./utils";
3
- import { TEXT_NOT_STRING_MSG, NEW_CONTENT_NOT_STRING_MSG } from "./constants";
3
+ import { TEXT_ENDING_NOTE, TEXT_NOT_STRING_MSG, NEW_CONTENT_NOT_STRING_MSG } from "./constants";
4
4
 
5
5
  const textSchema = Type.String({
6
6
  description:
7
- 'The exact text to write in place of the removed range. "" deletes the range. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line.',
7
+ `The exact text to write in place of the removed range. "" deletes the range. ${TEXT_ENDING_NOTE}`,
8
8
  });
9
9
 
10
10
  const removeFromSchema = Type.String({
11
11
  description:
12
- "4-char anchor of the FIRST line to remove (never the row content).",
12
+ "4-char anchor of the FIRST line to remove.",
13
13
  });
14
14
 
15
15
  const removeToSchema = Type.String({
@@ -134,13 +134,14 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
134
134
  assertNoNul([request.text]);
135
135
  }
136
136
 
137
- const TRANSFER_KEYS = new Set(["path", "source_from", "source_to", "insert_after"]);
137
+ const TRANSFER_KEYS = new Set(["path", "source_from", "source_to", "anchor", "direction"]);
138
138
 
139
139
  export interface TransferReq {
140
140
  path?: string;
141
141
  source_from: string;
142
142
  source_to: string;
143
- insert_after: string;
143
+ anchor: string;
144
+ direction: "before" | "after";
144
145
  }
145
146
 
146
147
  export function assertTransferReq(request: unknown): asserts request is TransferReq {
@@ -157,8 +158,11 @@ export function assertTransferReq(request: unknown): asserts request is Transfer
157
158
  if (typeof request.source_to !== "string" || request.source_to.length === 0) {
158
159
  throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_to" string (4-char anchor from read output).');
159
160
  }
160
- if (typeof request.insert_after !== "string" || request.insert_after.length === 0) {
161
- throw new Error('[E_BAD_SHAPE] Copy/move request requires an "insert_after" string (4-char anchor from read output).');
161
+ if (typeof request.anchor !== "string" || request.anchor.length === 0) {
162
+ throw new Error('[E_BAD_SHAPE] Copy/move request requires an "anchor" string (4-char anchor from read output).');
163
+ }
164
+ if (request.direction !== "before" && request.direction !== "after") {
165
+ throw new Error('[E_BAD_SHAPE] Copy/move request "direction" must be "before" or "after" (the anchor is the destination line).');
162
166
  }
163
167
  }
164
168
 
@@ -222,7 +226,7 @@ export function getReplaceMatchInput(args: unknown): { path?: string; replace_fr
222
226
 
223
227
  const replaceMatchFromSchema = Type.String({
224
228
  description:
225
- "4-char anchor of the FIRST line of the range searched (never the row content).",
229
+ "4-char anchor of the FIRST line of the range searched.",
226
230
  });
227
231
  const replaceMatchToSchema = Type.String({
228
232
  description:
@@ -234,7 +238,7 @@ const replaceMatchOldStringSchema = Type.String({
234
238
  });
235
239
  const replaceMatchNewStringSchema = Type.String({
236
240
  description:
237
- 'The exact replacement for every matched occurrence. "" deletes the matches. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line; the rest of the range is kept byte-for-byte.',
241
+ `The exact replacement for every matched occurrence. "" deletes the matches. ${TEXT_ENDING_NOTE} The rest of the range is kept byte-for-byte.`,
238
242
  });
239
243
  const replaceMatchPathRequiredSchema = Type.String({
240
244
  description:
@@ -0,0 +1,19 @@
1
+ import type { NormFile } from "./file-reader";
2
+
3
+ export interface PreEditSnapshot extends NormFile {
4
+ served?: ReadonlyMap<string, string>;
5
+ }
6
+
7
+ const snapshots = new Map<string, PreEditSnapshot>();
8
+
9
+ export function recordPreEditSnapshot(snapshot: PreEditSnapshot): void {
10
+ snapshots.set(snapshot.absolutePath, snapshot);
11
+ }
12
+
13
+ export function preEditSnapshotFor(path: string): PreEditSnapshot | undefined {
14
+ return snapshots.get(path);
15
+ }
16
+
17
+ export function clearPreEditSnapshots(): void {
18
+ snapshots.clear();
19
+ }
package/src/prompts.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { errCode } from "./utils";
3
3
 
4
+ export interface Guideline {
5
+ id: string;
6
+ text: string;
7
+ }
8
+
9
+ const GUIDELINE_IDS_BY_NAME: Record<string, readonly string[]> = {
10
+ "read-guidelines.md": ["read-reread", "read-outline", "read-match-served"],
11
+ "replace-guidelines.md": ["replace-disjoint", "replace-deletion", "replace-single-line"],
12
+ "replace-match-guidelines.md": ["replace-match-fragment", "replace-match-wide-range"],
13
+ "insert-guidelines.md": ["insert-blank-edges"],
14
+ "copy-guidelines.md": ["copy-single-anchor"],
15
+ "move-guidelines.md": ["move-same-place"],
16
+ "transfer-guidelines.md": ["transfer-boundary-serving", "transfer-same-file", "transfer-cross-file", "transfer-batch-move"],
17
+ "grep-guidelines.md": ["grep-literal", "grep-row-markers"],
18
+ "undo-last-change-guidelines.md": ["undo-single-level", "undo-write-clears", "undo-cross-file-move"],
19
+ };
20
+
4
21
  export function loadP(relativePath: string): string {
5
22
  return readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
6
23
  }
@@ -22,3 +39,18 @@ export function loadGuide(relativePath: string): string[] {
22
39
  .filter((line) => line.startsWith("- "))
23
40
  .map((line) => line.slice(2));
24
41
  }
42
+
43
+ export function loadGuidelines(relativePath: string): Guideline[] {
44
+ const name = relativePath.slice(relativePath.lastIndexOf("/") + 1);
45
+ const ids = GUIDELINE_IDS_BY_NAME[name];
46
+ if (ids === undefined) throw new Error(`[E_REGISTRY] No guideline ids are registered for ${relativePath}.`);
47
+ const texts = loadGuide(relativePath);
48
+ if (texts.length === 0) return [];
49
+ if (texts.length !== ids.length) {
50
+ console.warn(`hashline: ${relativePath} holds ${texts.length} guideline(s) but declares ${ids.length}; gating may be wrong`);
51
+ }
52
+ return ids.flatMap((id, index) => {
53
+ const text = texts[index];
54
+ return text === undefined ? [] : [{ id, text }];
55
+ });
56
+ }
package/src/read.ts CHANGED
@@ -16,7 +16,7 @@ import { readNormFile, safeSnapId } from "./file-reader";
16
16
  import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES, parseHashRef, resolveAnchorLine, AnchorMismatchError } from "./hashline";
17
17
  import { toCwd } from "./paths";
18
18
  import { abortIf, isRec, makePrepareArguments, numberedRead, visLines, splitLines } from "./utils";
19
- import { loadP, loadGuide } from "./prompts";
19
+ import { loadP, loadGuidelines, type Guideline } from "./prompts";
20
20
  import { withReadPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
21
21
  import { valAccess } from "./validation";
22
22
  import { withAnchorSession, adoptAnchors, ownerOf, ownersDifferingOnlyByCase, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
@@ -26,8 +26,8 @@ import { Text } from "@earendil-works/pi-tui";
26
26
  import { anchoredLine, readResultSchema, withStructuredErrors, type AnchoredLine, type ReadResult } from "./structured";
27
27
  const R_DESC = loadP("../tool-prompts/read.md");
28
28
  const R_SNIPPET = loadP("../tool-prompts/read-snippet.md");
29
- function readGuide(): string[] {
30
- return loadGuide("../tool-prompts/read-guidelines.md");
29
+ function readGuide(): Guideline[] {
30
+ return loadGuidelines("../tool-prompts/read-guidelines.md");
31
31
  }
32
32
  function normPosInt(
33
33
  value: number | undefined,
@@ -300,13 +300,8 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
300
300
  }),
301
301
  offset: Type.Optional(
302
302
  Type.Union([
303
- Type.Integer({
304
- minimum: 1,
305
- description: "Line number to start reading from (1-indexed)",
306
- }),
307
- Type.String({
308
- description: "4-char anchor of a served line to start reading from",
309
- }),
303
+ Type.Integer({ minimum: 1 }),
304
+ Type.String(),
310
305
  ], { description: "Line number (1-indexed) or a served anchor to start reading from" }),
311
306
  ),
312
307
  limit: Type.Optional(
@@ -163,6 +163,8 @@ function trimPlacedSpans(
163
163
  placed: PlacedSpan[],
164
164
  oldLines: string[],
165
165
  newLines: string[],
166
+ oldHashes: string[],
167
+ newHashes: string[],
166
168
  ): PlacedSpan[] {
167
169
  const out: PlacedSpan[] = [];
168
170
  for (const span of placed) {
@@ -170,11 +172,11 @@ function trimPlacedSpans(
170
172
  let oldEnd = span.oldEnd;
171
173
  let newStart = span.newStart;
172
174
  let newEnd = span.newEnd;
173
- while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldStart] === newLines[newStart]) {
175
+ while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldStart] === newLines[newStart] && oldHashes[oldStart] === newHashes[newStart]) {
174
176
  oldStart += 1;
175
177
  newStart += 1;
176
178
  }
177
- while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldEnd] === newLines[newEnd]) {
179
+ while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldEnd] === newLines[newEnd] && oldHashes[oldEnd] === newHashes[newEnd]) {
178
180
  oldEnd -= 1;
179
181
  newEnd -= 1;
180
182
  }
@@ -251,7 +253,7 @@ function genSpanDiff(
251
253
  if (oldHashes.length !== oldLines.length || newHashes.length !== newLines.length) return undefined;
252
254
  const placed = placeSpans(oldLines.length, newLines.length, spans);
253
255
  if (!placed) return undefined;
254
- const trimmed = trimPlacedSpans(placed, oldLines, newLines);
256
+ const trimmed = trimPlacedSpans(placed, oldLines, newLines, oldHashes, newHashes);
255
257
  const ordered = [...trimmed].sort((a, b) => a.oldStart - b.oldStart || a.newStart - b.newStart);
256
258
  let oldPos = 0;
257
259
  let newPos = 0;
@@ -6,7 +6,7 @@ import { readNormFile, type NormFile } from "./file-reader";
6
6
  import { fmtRegion, MAX_HASH_LINES, parseHashRef, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
7
7
  import { servedForPath, withAnchorSession } from "./anchor-registry";
8
8
  import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
9
- import { loadP, loadGuide } from "./prompts";
9
+ import { loadP, loadGuidelines } from "./prompts";
10
10
  import {
11
11
  assertReplaceMatchReq,
12
12
  buildReplaceMatchToolSchema,
@@ -177,7 +177,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
177
177
  const prompted = withReplaceMatchPrompts({
178
178
  description: loadP("../tool-prompts/replace-match.md"),
179
179
  snippet: loadP("../tool-prompts/replace-match-snippet.md"),
180
- guidelines: loadGuide("../tool-prompts/replace-match-guidelines.md"),
180
+ guidelines: loadGuidelines("../tool-prompts/replace-match-guidelines.md"),
181
181
  }, flags);
182
182
  return {
183
183
  name: "replace_match",
@@ -81,7 +81,7 @@ export function highlightBatchRefs(text: string, theme: FgT): string {
81
81
  }
82
82
 
83
83
  export function fmtCall(
84
- args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string; replace_from?: string; replace_to?: string } | undefined,
84
+ args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; direction?: string; replace_from?: string; replace_to?: string } | undefined,
85
85
  state: RRState,
86
86
  expanded: boolean,
87
87
  theme: CallT,
@@ -89,7 +89,7 @@ export function fmtCall(
89
89
  ): string {
90
90
  const previewPath = state.preview && "path" in state.preview ? state.preview.path : undefined;
91
91
  const path = args?.path ?? state.resolvedPath ?? previewPath;
92
- const anchorFallback = typeof args?.remove_from === "string" && typeof args?.remove_to === "string" ? `${args.remove_from}→${args.remove_to}` : typeof args?.anchor === "string" ? args.anchor : typeof args?.source_from === "string" && typeof args?.insert_after === "string" ? `${args.source_from}→${args.insert_after}` : typeof args?.replace_from === "string" && typeof args?.replace_to === "string" ? `${args.replace_from}→${args.replace_to}` : undefined;
92
+ const anchorFallback = typeof args?.remove_from === "string" && typeof args?.remove_to === "string" ? `${args.remove_from}→${args.remove_to}` : typeof args?.source_from === "string" && typeof args?.anchor === "string" ? `${args.source_from}→${args.anchor}` : typeof args?.anchor === "string" ? args.anchor : typeof args?.replace_from === "string" && typeof args?.replace_to === "string" ? `${args.replace_from}→${args.replace_to}` : undefined;
93
93
  const pathDisplay =
94
94
  typeof path === "string" && path.length > 0
95
95
  ? theme.fg("accent", path)
@@ -272,9 +272,9 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
272
272
  export function makeRenderCall(
273
273
  preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
274
274
  options: {
275
- getInput?: (args: unknown) => { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string; replace_from?: string; replace_to?: string } | null;
275
+ getInput?: (args: unknown) => { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; direction?: string; replace_from?: string; replace_to?: string } | null;
276
276
  toolName?: string;
277
- resolveTarget?: (input: { remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string; replace_from?: string; replace_to?: string }) => string | undefined;
277
+ resolveTarget?: (input: { remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; direction?: string; replace_from?: string; replace_to?: string }) => string | undefined;
278
278
  } = {},
279
279
  ) {
280
280
  const getInput = options.getInput ?? getPreviewInput;
@@ -160,6 +160,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
160
160
  const fidelity = fidelityHints(originalNormalized, result, spans, originalHashes, {
161
161
  separatorMoved: fidelityOptions?.separatorMoved ?? (verb === "inserted" || verb === "edited"),
162
162
  indentHints: fidelityOptions?.indentHints ?? (verb !== "copied" && verb !== "moved"),
163
+ resultHashes,
163
164
  });
164
165
  const { warnings: noticeWarnings, hints } = splitNotices(fidelity.length > 0 ? [...(warnings ?? []), ...fidelity] : warnings);
165
166
  const annotatedHints = annotateLiteralEscapeHints(hints, result, spans, resultHashes);
@@ -12,8 +12,8 @@ import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
12
12
  import { joinSeparators } from "./line-endings";
13
13
  import { genDiff, genPatch, spansFromHashes } from "./replace-diff";
14
14
  import { getDiffContextLines } from "./config";
15
- import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
16
- import { loadP, loadGuide } from "./prompts";
15
+ import { cntDiff, errCode, makePrepareArguments, mapIoError, splitLines } from "./utils";
16
+ import { loadP, loadGuidelines } from "./prompts";
17
17
  import { withUndoPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
18
18
  import { buildMetrics } from "./replace-response";
19
19
  import { renderEditResult, fmtCall } from "./replace-render";
@@ -115,7 +115,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
115
115
  const prompted = withUndoPrompts({
116
116
  description: loadP("../tool-prompts/undo-last-change.md"),
117
117
  snippet: loadP("../tool-prompts/undo-last-change-snippet.md"),
118
- guidelines: loadGuide("../tool-prompts/undo-last-change-guidelines.md"),
118
+ guidelines: loadGuidelines("../tool-prompts/undo-last-change-guidelines.md"),
119
119
  }, flags);
120
120
  pi.registerTool({
121
121
  name: "undo_last_change",
@@ -166,7 +166,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
166
166
  await handle.close();
167
167
  }
168
168
  } catch (error) {
169
- if (errCode(error) !== "ENOENT") throw error;
169
+ if (errCode(error) !== "ENOENT") throw mapIoError(error, "read", path);
170
170
  }
171
171
 
172
172
  if (
package/src/replace.ts CHANGED
@@ -12,7 +12,7 @@ import { readNormFile, type NormFile } from "./file-reader";
12
12
  import { editToolSchema, buildEditToolSchema, type ReqParams, type RawReqParams, assertReq, normReq } from "./payload-contract";
13
13
  import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
14
14
  import { editResultSchema, withStructuredErrors } from "./structured";
15
- import { loadP, loadGuide } from "./prompts";
15
+ import { loadP, loadGuidelines } from "./prompts";
16
16
  import { type FileIdentity } from "./fs-write";
17
17
  import { applyEdit,
18
18
  lineHashes,
@@ -292,7 +292,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
292
292
  const prompted = withReplacePrompts({
293
293
  description: loadP("../tool-prompts/replace.md"),
294
294
  snippet: loadP("../tool-prompts/replace-snippet.md"),
295
- guidelines: loadGuide("../tool-prompts/replace-guidelines.md"),
295
+ guidelines: loadGuidelines("../tool-prompts/replace-guidelines.md"),
296
296
  }, flags);
297
297
  const parameters = buildEditToolSchema(flags.requirePath);
298
298
  return {
package/src/structured.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { HASH_CLASS, HASH_SEP } from "./hashline/hash";
3
+ import { ioErrorCode } from "./utils";
3
4
 
4
5
  export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
5
6
 
@@ -200,7 +201,7 @@ export function messageOf(error: unknown): string {
200
201
 
201
202
  export function structuredError(error: unknown): StructuredError {
202
203
  const message = messageOf(error);
203
- return { ok: false, kind: "error", error: { code: ERROR_CODE_RE.exec(message)?.[1] ?? "E_TOOL", message } };
204
+ return { ok: false, kind: "error", error: { code: ERROR_CODE_RE.exec(message)?.[1] ?? ioErrorCode(error) ?? "E_TOOL", message } };
204
205
  }
205
206
 
206
207
  export type StructuredFailureResult<TDetails> = {
package/src/utils.ts CHANGED
@@ -94,6 +94,35 @@ export function errCode(error: unknown): string | undefined {
94
94
  return undefined;
95
95
  }
96
96
 
97
+ const IO_ERROR_CODES: Record<string, string> = {
98
+ ENOENT: "E_NOT_FOUND",
99
+ EACCES: "E_ACCESS",
100
+ EPERM: "E_ACCESS",
101
+ EROFS: "E_ACCESS",
102
+ EISDIR: "E_ACCESS",
103
+ ENOTDIR: "E_ACCESS",
104
+ EEXIST: "E_ACCESS",
105
+ ELOOP: "E_ACCESS",
106
+ ENOSPC: "E_ACCESS",
107
+ EDQUOT: "E_ACCESS",
108
+ EMFILE: "E_ACCESS",
109
+ ENFILE: "E_ACCESS",
110
+ ENAMETOOLONG: "E_ACCESS",
111
+ };
112
+
113
+ export function ioErrorCode(error: unknown): string | undefined {
114
+ const code = errCode(error);
115
+ return code !== undefined ? IO_ERROR_CODES[code] : undefined;
116
+ }
117
+
118
+ export function mapIoError(error: unknown, action: string, path: string): Error {
119
+ if (error instanceof Error && /\[[EWH]_[A-Z0-9_]+\]/.test(error.message)) return error;
120
+ const mapped = ioErrorCode(error);
121
+ if (mapped === undefined) return error instanceof Error ? error : new Error(String(error));
122
+ const detail = error instanceof Error ? error.message : String(error);
123
+ return new Error(`[${mapped}] Cannot ${action} ${path}: ${detail}`);
124
+ }
125
+
97
126
  export function isModeUnsupported(error: unknown): boolean {
98
127
  const code = errCode(error);
99
128
  return code === "EPERM" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EINVAL" || code === "ENOSYS";
package/src/write-hook.ts CHANGED
@@ -6,7 +6,7 @@ import { resolveInCwd } from "./fs-write";
6
6
  import { abortIf, splitLines, isRec } from "./utils";
7
7
  import type { ModelLike } from "./model-gate";
8
8
 
9
- const HASH_ECHO_RE = new RegExp(`^(?: *[0-9]+ ${HASH_SEP} )?[+ -]?(${HASH_CLASS})${HASH_SEP}`);
9
+ const HASH_ECHO_RE = new RegExp(`^(?: *[0-9]+(?: │ |[:-]│ ))?[+ -]?(${HASH_CLASS})${HASH_SEP}`);
10
10
 
11
11
  function searchEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
12
12
  for (let i = 0; i < lines.length; i++) {
@@ -1 +1 @@
1
- Copy lines to another position by anchor: `source_from`/`source_to` mark the range, the copy lands after `insert_after`; each anchor resolves its own file
1
+ Copy lines to another position by anchor: `source_from`/`source_to` mark the range, `anchor` and `direction` the destination