pi-hashline-edit-pro 6.3.0 → 6.4.0

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 (41) hide show
  1. package/README.md +32 -31
  2. package/index.ts +32 -16
  3. package/package.json +1 -1
  4. package/src/auto-read-all.ts +1 -1
  5. package/src/batch.ts +6 -4
  6. package/src/config-ui.ts +1 -1
  7. package/src/constants.ts +5 -2
  8. package/src/copy-move.ts +90 -65
  9. package/src/edit-common.ts +93 -51
  10. package/src/edit-fidelity.ts +16 -6
  11. package/src/grep.ts +22 -11
  12. package/src/insert.ts +5 -4
  13. package/src/model-gate.ts +1 -1
  14. package/src/payload-contract.ts +13 -9
  15. package/src/prompts.ts +32 -0
  16. package/src/read.ts +5 -10
  17. package/src/replace-match.ts +2 -2
  18. package/src/replace-render.ts +4 -4
  19. package/src/replace-response.ts +1 -0
  20. package/src/replace-undo.ts +2 -2
  21. package/src/replace.ts +2 -2
  22. package/src/write-hook.ts +1 -1
  23. package/tool-prompts/copy-snippet.md +1 -1
  24. package/tool-prompts/copy.md +2 -2
  25. package/tool-prompts/grep-guidelines.md +2 -1
  26. package/tool-prompts/grep-snippet.md +1 -1
  27. package/tool-prompts/grep.md +1 -1
  28. package/tool-prompts/insert-guidelines.md +1 -1
  29. package/tool-prompts/insert.md +1 -1
  30. package/tool-prompts/move-snippet.md +1 -1
  31. package/tool-prompts/move.md +2 -2
  32. package/tool-prompts/read-guidelines.md +3 -3
  33. package/tool-prompts/read.md +1 -3
  34. package/tool-prompts/replace-match-guidelines.md +1 -0
  35. package/tool-prompts/replace-match-snippet.md +1 -1
  36. package/tool-prompts/replace-match.md +1 -1
  37. package/tool-prompts/replace.md +3 -2
  38. package/tool-prompts/transfer-guidelines.md +3 -3
  39. package/tool-prompts/undo-last-change-guidelines.md +1 -1
  40. package/tool-prompts/undo-last-change-snippet.md +1 -1
  41. 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/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. "\\n" is one blank line, and a trailing line break sets the last line\'s ending instead of adding a 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",
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, "\\n" is one blank line, and a trailing line break sets the last line\'s ending instead of adding a 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, "\\n" inserts one blank line, and a trailing line break sets the last line\'s ending instead of adding a 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:
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(
@@ -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);
@@ -13,7 +13,7 @@ import { joinSeparators } from "./line-endings";
13
13
  import { genDiff, genPatch, spansFromHashes } from "./replace-diff";
14
14
  import { getDiffContextLines } from "./config";
15
15
  import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
16
- import { loadP, loadGuide } from "./prompts";
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",
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/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
@@ -1,3 +1,3 @@
1
- Copy a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to copy in the source file, and `insert_after` as the bare anchor of the destination line after which the copy goes. The source lines stay in place, and the copied lines are written exactly as the source file holds them.
1
+ Duplicate a range of lines at another anchor, in this or another file. The source rows and anchors stay live; the copied lines get fresh anchors in the destination's post-edit diff and keep their source line endings.
2
2
 
3
- A cross-file copy joins the destination file's batch. A copy always duplicates the content its source anchors were served from, so a same-message edit to the source file does not change what is copied.
3
+ A copy duplicates the content its source anchors were served from, so a same-message edit to the source file does not change what is copied.
@@ -1 +1,2 @@
1
- - `anchor_grep`: prefer it over shell `rg`/`grep` for searching, so hits carry anchors and need no `read`; use `literal: true` when the pattern contains regex metacharacters.
1
+ - `grep`: set `literal: true` when the pattern contains regex metacharacters.
2
+ - `grep`: `N:│` rows are matches and `N-│` rows are context;
@@ -1 +1 @@
1
- Search with `lineNumber │ anchor│content` hits usable directly for `replace`/`insert`; use `literal`/`glob`/`context`
1
+ Search with `lineNumber:│ anchor│content` hits (`lineNumber-│` for context) usable directly for `replace`/`insert`; use `literal`/`glob`/`context`
@@ -1 +1 @@
1
- Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace, insert, copy, or move without a new read; the `=== path ===` header and line numbers locate the match. Patterns are regex by default; set `literal: true` to search exact text, `ignoreCase` for case-insensitive search, and `context` for surrounding rows. Avoid backreferences, nested quantifiers, and quantified alternation. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When cut by match limit, raise `limit` (default 100); when cut by rows/bytes, refine `pattern` / narrow path.
1
+ Search text files with ripgrep. Hits come back as `lineNumber:│ anchor│content` rows and context lines as `lineNumber-│ anchor│content`, editable with replace, insert, copy, or move without a new read; the `=== path ===` header and line numbers locate the match. Patterns are regex by default; set `literal: true` for exact text, `ignoreCase` for case-insensitive search, and `context` for surrounding rows. Rust regex syntax (no backreferences or look-around) through a linear-time engine. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When cut by match limit, raise `limit` (default 100); when cut by rows/bytes, refine `pattern` or narrow `path`.
@@ -1 +1 @@
1
- - `insert`: compare both edges of the inserted block in the post-edit diff against the request; a missing or extra `+anchor│` blank row at either boundary is the classic insert slip.
1
+ - `insert`: a missing or extra `+anchor│` blank row at either block edge in the diff is the classic insert slip.
@@ -1 +1 @@
1
- Insert text after or before one existing line in a text file, addressed by the `anchor` field (a bare anchor from any served anchor│content row). `text` goes after the anchor line with `direction: "after"` or before it with `direction: "before"`. `text` is one string holding the exact text to insert. A trailing line break sets the last line's ending instead of adding a blank line, so a blank line next to the anchor line must be an extra break in `text`: `"x\n"` leaves the anchor line directly after `x`, and `"x\n\n"` leaves one blank line between them (`direction: "after"` mirrors this at the start of `text`).
1
+ Insert text after or before one existing line, addressed by `anchor` (a bare anchor from any served `anchor│content` row). `direction` places `text` after or before the anchor line; `text` is the exact text to insert.
@@ -1 +1 @@
1
- Move lines to another position by anchor: `source_from`/`source_to` mark the range, it lands after `insert_after`; each anchor resolves its own file
1
+ Move lines to another position by anchor: `source_from`/`source_to` mark the range, `anchor` and `direction` the destination
@@ -1,3 +1,3 @@
1
- Move a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to move in the source file, and `insert_after` as the bare anchor of the destination line after which the range goes. `insert_after` must sit outside the source range when both anchors share a file; moving a range in one file to where it already sits reports no change.
1
+ Relocate a range of lines to another anchor, in this or another file. Within one file the destination must sit outside the source range; moving a range onto itself reports `No changes made` and leaves the anchors alone.
2
2
 
3
- A cross-file `move` whose source file also has batched edits in the message commits on its own. A batched cross-file `move` commits its source removal with the batch but shows only the destination diff; read the source file for fresh anchors.
3
+ A cross-file move commits on its own when its source file also has batched edits in the message.
@@ -1,3 +1,3 @@
1
- - `read`: call again after an edit when you need anchors you lack — post-edit diff `+anchor│`/` anchor│` rows and any served `anchor│content` rows already carry fresh anchors for the changed range.
2
- - `read`: pass a served anchor as `offset` with `limit` to read from it without line-number math; an outline row's `[limit N]` is the span to request, and a row without one is a single line shown as its content.
3
- - `read`: an outline row shows a symbol label, or the line content for a single-line symbol; edit by anchor, and read the anchor (or copy `old_string` from a served row) before `replace_match`.
1
+ - `read`: call again after an edit when you need anchors you lack — post-edit diff `+`/` ` rows and served `anchor│content` rows already carry fresh anchors for the changed range.
2
+ - `read`: an auto-read-all outline row shows a symbol label, or a single-line symbol's content; its `[limit N]` is the span to request.
3
+ - `read`: `replace_match` needs both boundary lines served; copy `old_string` from a served row or read the lines first.
@@ -1,3 +1 @@
1
- Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only, case-sensitive), the `│` separator, then the line content. Target lines in edits by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue with the hinted `offset/limit`. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
2
-
3
- `offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return after it. A stale anchor is refused with the current context rows and fresh anchors.
1
+ Read a text file as `anchor│content` rows: a 4-character anchor (letters only, case-sensitive), `│`, then the line. Target edits by anchor, never by content or line number. `offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts lines after it — no line-number math. Page long files with `offset` and `limit`; when output says truncated, continue from the hinted `offset`. A stale anchor is refused with the current rows and fresh anchors. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
@@ -1 +1,2 @@
1
1
  - `replace_match`: `old_string` is usually a fragment of the line, not the whole line; every occurrence inside the range is replaced, so scope the range with anchors when only some occurrences should change.
2
+ - `replace_match`: when you intend to change every matching line in a range, anchor the first and last matching lines of that range so one call replaces them all, instead of one call per line.
@@ -1 +1 @@
1
- Replace part of a line or range by anchor: every occurrence of `old_string` inside `replace_from`/`replace_to` is replaced by `new_string`, leaving the rest of the text untouched
1
+ Replace part of a line or range by anchor: every `old_string` occurrence inside `replace_from`/`replace_to` becomes `new_string`; the rest of the line stays untouched
@@ -1,3 +1,3 @@
1
- Replace part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range; use the same anchor for one line. `old_string` is the text to find inside that range, and `new_string` replaces every occurrence of it. `remove_from`/`remove_to` are accepted as aliases for `replace_from`/`replace_to`.
1
+ Replace part of a line (or a range of lines) without retyping the rest. `replace_from`/`replace_to` are bare anchors from served `anchor│content` rows marking the range's first and last line (the same anchor for one line), `old_string` is the text to find inside it, and `new_string` replaces every occurrence. `remove_from`/`remove_to` are accepted as aliases.
2
2
 
3
3
  The two boundary anchors must still match what was last shown; lines strictly inside the range are matched against the file as it is on disk.
@@ -1,2 +1,3 @@
1
- Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `text` is one string holding the exact replacement text. The text is written exactly as given, and nothing else in the file changes. `replace_from`/`replace_to` are accepted as aliases for `remove_from`/`remove_to`.
2
- To change only part of a line without retyping the rest, use `replace_match` instead; it preserves every character the request does not name. Deleting every line empties the file; the result names the new empty-line anchor, so a follow-up `replace` on it can seed content without a `read`.
1
+ Replace a range of lines (or a single line) in a text file by anchor: `remove_from` and `remove_to` are the 4-character anchors of its first and last line, `text` is the exact replacement text, and `replace_from`/`replace_to` are accepted as aliases. Deleting every line empties the file, and the result names the empty-line anchor, so a follow-up `replace` can seed content without a `read`.
2
+
3
+ To change only part of a line without retyping the rest, use `replace_match` instead; it preserves every character the request does not name.
@@ -1,4 +1,4 @@
1
- - `copy`/`move`: for a block transfer, only the source's two boundary rows and the destination line need serving; the interior transfers verbatim and the block lands in one commit. To append at the end, use the destination's last served line as `insert_after`.
2
- - `copy`/`move`: `insert_after` must sit outside the source range; `source_to` places the block right after itself.
3
- - `copy`/`move`: `source_from` and `source_to` may live in a different file than `insert_after`; an empty destination file is seeded with the copied or moved lines.
1
+ - `copy`/`move`: only the source's two boundary rows and the destination line need serving; to append to the end of a file, anchor that file's last line with `direction: "after"` — not a marker line and not the block's own end.
2
+ - `copy`/`move`: within one file the insertion point must fall before the source range's first line or after its last; anchoring the block's last line with `direction: "after"` (or its first line with `direction: "before"`) targets the gap right beside the block.
3
+ - `copy`/`move`: `source_from` and `source_to` may live in a different file than `anchor`; an empty destination file is seeded with the copied or moved lines.
4
4
  - `move`: a batched cross-file move does not display the source file's diff; its removal commits with the batch, and the source file needs a read for fresh anchors.
@@ -1,3 +1,3 @@
1
1
  - `undo_last_change`: history is one slot per file, not a stack; a second undo returns `E_UNDO_NONE`.
2
- - `undo_last_change`: a `write` clears the history, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
2
+ - `undo_last_change`: a `write` clears the history, so undo before any other edit or write; the undo diff's `+anchor│` rows are the restored lines.
3
3
  - `undo_last_change`: a cross-file `move` records one undo entry per file; undo both the source and the destination to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing.
@@ -1 +1 @@
1
- Single-level undo: reverts a file's last `replace`, `replace_match`, `insert`, `copy`, or `move`
1
+ Revert a file's most recent edit (single level)
@@ -1 +1 @@
1
- Undo the last replace, replace_match, insert, copy, or move on a file, restoring the previous content, BOM, and line endings. Use it when an edit removed or changed the wrong lines. If the file changed since that edit, the undo is refused and the file is left untouched; do not force it. A file deleted since the edit is restored. If the diff is truncated, use read to see the full file.
1
+ Undo the last replace, replace_match, insert, copy, or move on a file, restoring the previous content, BOM, and line endings. If the file changed since that edit, the undo is refused and the file is left untouched; a file deleted since the edit is restored.