pi-hashline-edit-pro 6.4.7 → 6.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -422,7 +422,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
422
422
 
423
423
  All eight tools return machine-readable metadata in `details` alongside the model-visible text.
424
424
 
425
- All eight tools also declare an `outputSchema` and return `structuredContent`, so codemode scripts receive anchors, line numbers, diffs, and errors as data instead of parsing the rendered text. For `read`, `text` is the raw file content of the served window and `lines` carries the anchored rows, so a script parses the file without anchor prefixes. The model still receives `content`. Every structured result is either `{ ok: true, kind, ... }` or `{ ok: false, kind: "error", error: { code, message } }`; a failed call is still a tool error (`isError: true`) and also carries `structuredContent`, so a script can branch on the code without matching the message.
425
+ All eight tools also declare an `outputSchema` and return `structuredContent`, so codemode scripts receive anchors, line numbers, diffs, and errors as data instead of parsing the rendered text. In a codemode session `tools.read()` resolves to the file's text, like the built-in `read`; pass `anchors: true` to receive `{ text, lines, notices }`, where `text` is the raw file content of the served window and `lines` carries the anchored rows. The model still receives `content`. Every structured result is either `{ ok: true, kind, ... }` or `{ ok: false, kind: "error", error: { code, message } }`; a failed call is still a tool error (`isError: true`) and also carries `structuredContent`, so a script can branch on the code without matching the message.
426
426
 
427
427
  `read` returns `{ kind: "read", path, text, lines, totalLines, startLine, nextOffset, truncated, blockedByLongLine, hadUtf8DecodeErrors, notices }`, where `text` is the raw file content of the served window (no anchors, no footers) and each entry of `lines` is `{ line, text, anchor, rendered }` for a row that was served (`text` there is what was shown, so an oversized line appears as its marker while keeping its anchor). `notices` carries non-file notes such as the UTF-8 rewrite note and `[W_ANCHOR_RECLAIMED]`. An image read returns `{ type: "image", data, mimeType, note }`.
428
428
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.4.7",
3
+ "version": "6.4.8",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -61,15 +61,15 @@
61
61
  "typecheck": "tsc --noEmit"
62
62
  },
63
63
  "devDependencies": {
64
- "@earendil-works/pi-coding-agent": "^1.2.0",
64
+ "@earendil-works/pi-coding-agent": "^1.3.0",
65
65
  "@eslint/js": "^10.0.1",
66
- "@types/node": "^24",
67
- "@vitest/coverage-v8": "^4.1.11",
68
- "eslint": "^10.9.1",
69
- "typebox": "^1.3.7",
70
- "typescript": "^5.9.3",
71
- "typescript-eslint": "^8.68.0",
72
- "vitest": "^4.1.11"
66
+ "@types/node": "^24.19.2",
67
+ "@vitest/coverage-v8": "^5.0.5",
68
+ "eslint": "^10.13.0",
69
+ "typebox": "^1.4.1",
70
+ "typescript": "^6.0.3",
71
+ "typescript-eslint": "^8.71.2",
72
+ "vitest": "^5.0.5"
73
73
  },
74
74
  "allowScripts": {
75
75
  "@google/genai@1.52.0": true,
@@ -6,8 +6,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { sessionClaimsDir } from "./paths";
7
7
  import { contentChecksum } from "./hashline/hasher";
8
8
  import { ANCHOR_COUNT, anchorAt } from "./hashline/alphabet";
9
- import { HASH_PROBE_STRIDE } from "./hashline/hash";
10
- import { errCode, isRec, splitLines } from "./utils";
9
+ import { HASH_PROBE_STRIDE, MAX_HASH_LINES } from "./hashline/hash";
10
+ import { assertLineCount, errCode, isRec, splitLines, spliceAll } from "./utils";
11
11
  import * as Diff from "diff";
12
12
  import { lineChecksum } from "./hashline";
13
13
  import { getAllocatedState, persistSnapshot, type HashStore } from "./hash-store";
@@ -46,6 +46,7 @@ interface OwnedAnchorMap {
46
46
  }
47
47
 
48
48
  interface AnchorMintedSet {
49
+ readonly size: number;
49
50
  has(anchor: string): boolean;
50
51
  add(anchor: string): unknown;
51
52
  [Symbol.iterator](): IterableIterator<string>;
@@ -125,9 +126,12 @@ class ShadowOwnedMap implements OwnedAnchorMap {
125
126
  class ShadowMintedSet implements AnchorMintedSet {
126
127
  private readonly parent: AnchorMintedSet;
127
128
  private readonly added = new Set<string>();
129
+ private extra = 0;
130
+ private parentSize: number;
128
131
 
129
132
  constructor(parent: AnchorMintedSet) {
130
133
  this.parent = parent;
134
+ this.parentSize = parent.size;
131
135
  }
132
136
 
133
137
  has(anchor: string): boolean {
@@ -135,10 +139,22 @@ class ShadowMintedSet implements AnchorMintedSet {
135
139
  }
136
140
 
137
141
  add(anchor: string): this {
142
+ if (!this.added.has(anchor) && !this.parent.has(anchor)) this.extra += 1;
138
143
  this.added.add(anchor);
139
144
  return this;
140
145
  }
141
146
 
147
+ get size(): number {
148
+ if (this.parent.size !== this.parentSize) {
149
+ this.parentSize = this.parent.size;
150
+ this.extra = 0;
151
+ for (const anchor of this.added) {
152
+ if (!this.parent.has(anchor)) this.extra += 1;
153
+ }
154
+ }
155
+ return this.parentSize + this.extra;
156
+ }
157
+
142
158
  *[Symbol.iterator](): IterableIterator<string> {
143
159
  yield* this.parent;
144
160
  for (const anchor of this.added) {
@@ -555,16 +571,19 @@ export function reclaimAnchorSpace(state: SessionState, protectPath?: string): s
555
571
 
556
572
  export function mintAnchor(state: SessionState, protectPath?: string): string {
557
573
  for (;;) {
558
- for (let probe = 0; probe < MINT_PROBE_LIMIT; probe++) {
559
- state.probe = (state.probe + HASH_PROBE_STRIDE) % ANCHOR_COUNT;
560
- const candidate = anchorAt(state.probe);
561
- if (!state.owned.has(candidate) && !state.everMinted.has(candidate)) {
562
- return candidate;
574
+ if (state.everMinted.size < ANCHOR_COUNT) {
575
+ for (let probe = 0; probe < MINT_PROBE_LIMIT; probe++) {
576
+ state.probe = (state.probe + HASH_PROBE_STRIDE) % ANCHOR_COUNT;
577
+ const candidate = anchorAt(state.probe);
578
+ if (!state.owned.has(candidate) && !state.everMinted.has(candidate)) {
579
+ return candidate;
580
+ }
563
581
  }
564
582
  }
565
583
  for (let step = 1; step <= ANCHOR_COUNT; step++) {
566
584
  const candidate = anchorAt((state.probe + step) % ANCHOR_COUNT);
567
585
  if (!state.owned.has(candidate)) {
586
+ state.probe = (state.probe + step) % ANCHOR_COUNT;
568
587
  for (const served of state.served.values()) served.delete(candidate);
569
588
  return candidate;
570
589
  }
@@ -861,7 +880,7 @@ export function alignOwnershipWithSpans(
861
880
  state.everMinted.add(settled[k]!);
862
881
  state.owned.set(settled[k]!, { path, checksum: newChecksums[start + k]! });
863
882
  }
864
- anchors.splice(start, spanLength, ...settled);
883
+ spliceAll(anchors, start, spanLength, settled);
865
884
  offset += span.replacementCount - spanLength;
866
885
  }
867
886
  const rows: [string, string][] = minted.map((m) => [m.anchor, m.checksum]);
@@ -963,8 +982,9 @@ export async function allocateFileAnchors(
963
982
  ensureRegistry();
964
983
  const registry = current();
965
984
  const shadow = options?.shadow === true;
966
- if (!shadow && registry) touchPath(registry, path);
967
985
  const lines = splitLines(content);
986
+ assertLineCount(lines.length, path, MAX_HASH_LINES);
987
+ if (!shadow && registry) touchPath(registry, path);
968
988
  const checksums = lines.map(lineChecksum);
969
989
  if (options?.previous?.spans) {
970
990
  const prevChecksums = splitLines(options.previous.content).map(lineChecksum);
@@ -1058,6 +1078,10 @@ export function resetRegistryForTests(): void {
1058
1078
  registries.clear();
1059
1079
  }
1060
1080
 
1081
+ export function lastUsedAtForTests(path: string): number | undefined {
1082
+ return current()?.lastUsedAt.get(path);
1083
+ }
1084
+
1061
1085
  async function readSidecarWindow(sidecar: string): Promise<{ text: string; filled: boolean }> {
1062
1086
  const handle = await open(sidecar, "r");
1063
1087
  const buffer = Buffer.alloc(SIDECAR_HEADER_BYTES);
package/src/batch.ts CHANGED
@@ -28,7 +28,7 @@ import { saveUndo } from "./replace-undo";
28
28
  import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
29
29
  import { toEditVerb, withStructuredText, type EditStructured } from "./structured";
30
30
  import { serveRows, servedHashesFromDiff } from "./served";
31
- import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, mapIoError, splitLines } from "./utils";
31
+ import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, mapIoError, splitLines, spliceAll } from "./utils";
32
32
 
33
33
  export interface PlannedMember {
34
34
  batchKey: number;
@@ -793,7 +793,7 @@ function composeBatchLines(baseContent: string, pieces: BatchPiece[]): string {
793
793
  const lines = splitLines(baseContent);
794
794
  const baseLineCount = lines.length;
795
795
  const descending = [...pieces].sort((a, b) => b.start - a.start);
796
- for (const piece of descending) lines.splice(piece.start - 1, piece.end - piece.start + 1, ...piece.newLines);
796
+ for (const piece of descending) spliceAll(lines, piece.start - 1, piece.end - piece.start + 1, piece.newLines);
797
797
  let composed = lines.join("\n");
798
798
  if (lines.length > 0 && (baseContent.endsWith("\n") || lines[lines.length - 1] === "")) {
799
799
  composed += "\n";
package/src/copy-move.ts CHANGED
@@ -23,7 +23,7 @@ import { formatAnchorReclaimNotice, servedForPath, takeReclaimedPaths, withAncho
23
23
  import { batchMemberFor, batchServedFor, ensureBatchBase, executeBatchMember, noteBatchFailure, pendingBatchMemberFor, registerBatchPlanner, type BatchBase, type BatchMemberPlanner, type PlannedMember } from "./batch";
24
24
  import { loadP, loadGuidelines } from "./prompts";
25
25
  import { assertTransferReq, normReq, type TransferReq } from "./payload-contract";
26
- import { abortIf, assertByteLimit, isRec, splitLines } from "./utils";
26
+ import { abortIf, anchorCarryIndex, assertByteLimit, isRec, splitLines } from "./utils";
27
27
  import { toDisplayPath } from "./paths";
28
28
  import { getDiffContextLines } from "./config";
29
29
  import { resolveInCwd, writeAtomic } from "./fs-write";
@@ -145,7 +145,7 @@ export function buildTransferEdit(input: {
145
145
  : [...sourceLines, fileLines[anchorLine - 1]!],
146
146
  },
147
147
  foldedAnchorLines: 1,
148
- anchorCarry: refs.direction === "after" ? 0 : sourceLines.length,
148
+ anchorCarry: anchorCarryIndex(refs.direction, sourceLines.length),
149
149
  endingOverrides: refs.direction === "after"
150
150
  ? [undefined, ...sourceEndings]
151
151
  : [...sourceEndings, undefined],
@@ -319,14 +319,14 @@ async function prepareCrossTransfer(input: {
319
319
  ? offsetEndings(1, sourceEndings)
320
320
  : sourceEndings;
321
321
  if (kind === "copy") {
322
- return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: refs.direction === "after" ? 0 : moved.length } : {}), endingOverrides };
322
+ return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: anchorCarryIndex(refs.direction, moved.length) } : {}), endingOverrides };
323
323
  }
324
324
  const sourceEdit: HTEdit = {
325
325
  remove_from: sourcePreload.fileHashes[sourceStart - 1]!,
326
326
  remove_to: sourcePreload.fileHashes[sourceEnd - 1]!,
327
327
  text: [],
328
328
  };
329
- return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: refs.direction === "after" ? 0 : moved.length } : {}), sourceEdit, endingOverrides };
329
+ return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: anchorCarryIndex(refs.direction, moved.length) } : {}), sourceEdit, endingOverrides };
330
330
  }
331
331
 
332
332
  function pairEntry(pipe: PipelineResult, separators: LineEnding[] | undefined): UndoEntry {
@@ -584,6 +584,7 @@ async function executeCrossFile(
584
584
  signal,
585
585
  preloadedNorm: prepared.destinationPreload,
586
586
  endingOverrides: prepared.endingOverrides,
587
+ ...(prepared.anchorCarry !== undefined ? { anchorCarry: prepared.anchorCarry } : {}),
587
588
  });
588
589
  if (kind === "copy") {
589
590
  return commitEdit(destinationPipe, {
@@ -827,6 +828,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
827
828
  served: plan.servedOverride,
828
829
  endingOverrides: plan.endingOverrides,
829
830
  signal,
831
+ ...(plan.anchorCarry !== undefined ? { anchorCarry: plan.anchorCarry } : {}),
830
832
  });
831
833
  return previewFromPipe(pipe);
832
834
  }
@@ -837,6 +839,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
837
839
  preloadedNorm: prepared.destinationPreload,
838
840
  endingOverrides: prepared.endingOverrides,
839
841
  signal,
842
+ ...(prepared.anchorCarry !== undefined ? { anchorCarry: prepared.anchorCarry } : {}),
840
843
  });
841
844
  if (kind === "copy") return previewFromPipe(destinationPipe);
842
845
  const sourcePipe = await execPipeline(sourcePath, prepared.sourceEdit!, cwd, {
@@ -1017,6 +1020,7 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
1017
1020
  preloadedNorm: preload,
1018
1021
  served: plan.servedOverride,
1019
1022
  endingOverrides: plan.endingOverrides,
1023
+ ...(plan.anchorCarry !== undefined ? { anchorCarry: plan.anchorCarry } : {}),
1020
1024
  });
1021
1025
  return commitEdit(pipe, {
1022
1026
  path: pipe.path,
@@ -53,10 +53,14 @@ function preferenceGuideline(flags: EditToolFlags): string {
53
53
  const SHARED_EDIT_OPS = ["replace", "replace_match", "insert", "copy", "move"];
54
54
  const SHARED_PAYLOAD_OPS = ["replace", "replace_match", "insert"];
55
55
  const SHARED_DIFF_OPS = ["replace", "replace_match", "insert", "copy", "move", "undo_last_change"];
56
- const RESULT_CONTRACT_GUIDELINE =
57
- 'When called from a codemode script, failures resolve to `{ ok: false, kind: "error", error: { code, message } }`; branch on `ok` instead of `try`/`catch`.';
56
+ const SHARED_SCRIPT_OPS = ["read", "replace", "replace_match", "insert", "copy", "move", "grep", "undo_last_change"];
57
+
58
+ function resultContractGuideline(flags: EditToolFlags): string {
59
+ const tools = operationNames(SHARED_SCRIPT_OPS, flags);
60
+ return `${tools}: when called from a codemode script, failures resolve to \`{ ok: false, kind: "error", error: { code, message } }\`; branch on \`ok\` instead of \`try\`/\`catch\`.`;
61
+ }
58
62
  const READ_SCRIPT_GUIDELINE =
59
- '`read`: a codemode script receives `{ text, lines, notices }`; `text` is the raw file content of the served window and `lines[].anchor` is the editable anchor. An image read resolves to an image block.';
63
+ '`read` in a codemode script resolves to the file\'s text, like the built-in `read`; pass `anchors: true` to receive `{ text, lines, notices }` with the raw file content and `lines[].anchor` for chained edits. An image read resolves to an image block.';
60
64
  const GREP_SCRIPT_GUIDELINE =
61
65
  '`grep`: a codemode script receives `{ text, results }`; each `results[].lines[]` entry carries the matching line\'s `anchor` and `text`, so a result is editable without a fresh read.';
62
66
  function editScriptGuideline(flags: EditToolFlags): string {
@@ -137,7 +141,7 @@ function readGuidelineApplies(guideline: Guideline, flags: EditToolFlags): boole
137
141
 
138
142
  export function withReadPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
139
143
  const preference = preferenceGuideline(flags);
140
- const script = flags.codemode ? [READ_SCRIPT_GUIDELINE, RESULT_CONTRACT_GUIDELINE] : [];
144
+ const script = flags.codemode ? [READ_SCRIPT_GUIDELINE, resultContractGuideline(flags)] : [];
141
145
  const applicable = base.guidelines.filter((guideline) => readGuidelineApplies(guideline, flags));
142
146
  if (flags.autoReadAllActive) {
143
147
  const rewritten = applicable.filter((guideline) => guideline.id !== READ_REREAD_ID).map((guideline) => guideline.text);
package/src/insert.ts CHANGED
@@ -12,7 +12,7 @@ import { stripAnchorRow } from "./hashline/resolve";
12
12
  import { withAnchorSession } from "./anchor-registry";
13
13
  import { loadP, loadGuidelines } from "./prompts";
14
14
  import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
15
- import { isRec, literalEscapeHints, splitLines } from "./utils";
15
+ import { anchorCarryIndex, isRec, literalEscapeHints, splitLines } from "./utils";
16
16
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
17
17
  import type { RPreview, RRState } from "./replace-render";
18
18
  import { editResultSchema, withStructuredErrors } from "./structured";
@@ -98,6 +98,11 @@ function insertStripWarning(anchorLine: string | undefined, direction: "before"
98
98
  return { label: "text", indexOffset: anchorLine !== undefined && direction === "after" ? -1 : 0 };
99
99
  }
100
100
 
101
+ function insertAnchorCarry(direction: "before" | "after", editParams: HTEdit, anchorLine: string | undefined): number | undefined {
102
+ if (anchorLine === undefined) return undefined;
103
+ return anchorCarryIndex(direction, editParams.text.length - 1);
104
+ }
105
+
101
106
  function planInsertBatchMember(input: BatchPlannerInput): BatchMemberPlan {
102
107
  const req = normReq(input.member.args);
103
108
  assertInsertReq(req);
@@ -149,6 +154,7 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
149
154
  signal,
150
155
  });
151
156
  const { editParams, anchorLine, contentSeparators } = buildInsertEdit(normalized, preload, ref, targetPath);
157
+ const anchorCarry = insertAnchorCarry(normalized.direction, editParams, anchorLine);
152
158
  const pipe = await execPipeline(targetPath, editParams, cwd, {
153
159
  accessMode: constants.R_OK,
154
160
  noPersist: true,
@@ -156,6 +162,7 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
156
162
  signal,
157
163
  stripWarning: insertStripWarning(anchorLine, normalized.direction),
158
164
  endingOverrides: contentSeparators,
165
+ ...(anchorCarry !== undefined ? { anchorCarry } : {}),
159
166
  });
160
167
  return previewFromPipe(pipe);
161
168
  } catch (error: unknown) {
@@ -241,21 +248,21 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
241
248
  maxLines: MAX_HASH_LINES,
242
249
  });
243
250
  const { editParams, anchorLine, contentSeparators } = buildInsertEdit(req, preload, ref, targetPath);
251
+ const anchorCarry = insertAnchorCarry(req.direction, editParams, anchorLine);
244
252
  const pipe = await execPipeline(targetPath, editParams, ctx.cwd, {
245
253
  accessMode: constants.R_OK | constants.W_OK,
246
254
  signal,
247
255
  preloadedNorm: preload,
248
256
  stripWarning: insertStripWarning(anchorLine, req.direction),
249
257
  endingOverrides: contentSeparators,
258
+ ...(anchorCarry !== undefined ? { anchorCarry } : {}),
250
259
  });
251
260
  return commitEdit(pipe, {
252
261
  path: pipe.path,
253
262
  absolutePath,
254
263
  mutationTargetPath,
255
264
  editAnchors: [editParams.remove_from, editParams.remove_to],
256
- ...(anchorLine === undefined
257
- ? {}
258
- : { anchorCarry: req.direction === "after" ? 0 : editParams.text.length - 1 }),
265
+ ...(anchorCarry !== undefined ? { anchorCarry } : {}),
259
266
  signal,
260
267
  verb: "inserted",
261
268
  noopNoun: "Insertion",
package/src/read.ts CHANGED
@@ -23,7 +23,7 @@ import { withAnchorSession, adoptAnchors, ownerOf, ownersDifferingOnlyByCase, fo
23
23
  import { serveRows } from "./served";
24
24
  import { snapshotCache } from "./hash-store/cache";
25
25
  import { Text } from "@earendil-works/pi-tui";
26
- import { anchoredLine, readResultSchema, withStructuredErrors, type AnchoredLine, type ReadResult } from "./structured";
26
+ import { anchoredLine, readResultSchema, withStructuredErrors, type AnchoredLine, type ReadResult, type ReadTextResult } from "./structured";
27
27
  const R_DESC = loadP("../tool-prompts/read.md");
28
28
  const R_SNIPPET = loadP("../tool-prompts/read-snippet.md");
29
29
  function readGuide(): Guideline[] {
@@ -241,7 +241,6 @@ function anchorRefOf(args: unknown): { anchor: string; limit: number | undefined
241
241
  const { offset, limit, path } = args;
242
242
  if (typeof offset !== "string" || offset.length === 0 || /^\d+$/.test(offset)) return undefined;
243
243
  const validLimit = typeof limit === "number" && Number.isInteger(limit) && limit >= 1 ? limit : undefined;
244
- if (limit !== undefined && validLimit === undefined) return undefined;
245
244
  return { anchor: offset, limit: validLimit, path: typeof path === "string" ? path : undefined };
246
245
  }
247
246
 
@@ -263,28 +262,79 @@ function anchorLineOf(anchor: string, path: string | undefined, cwd: unknown): n
263
262
  return index < 0 ? undefined : index + 1;
264
263
  }
265
264
 
265
+ const ANSI_ESC = String.fromCharCode(27);
266
+ const CALL_ANSI_RE = new RegExp(`${ANSI_ESC}\\[[0-9;]*m`, "g");
267
+ const CALL_ANSI_AT_RE = new RegExp(`^${ANSI_ESC}\\[[0-9;]*m`);
268
+
269
+ function rawIndexForPlainIndex(text: string, plainIndex: number): number {
270
+ let raw = 0;
271
+ let plain = 0;
272
+ while (raw < text.length && plain < plainIndex) {
273
+ const escape = CALL_ANSI_AT_RE.exec(text.slice(raw));
274
+ if (escape !== null) {
275
+ raw += escape[0].length;
276
+ continue;
277
+ }
278
+ raw += 1;
279
+ plain += 1;
280
+ }
281
+ return raw;
282
+ }
283
+
284
+ export function insertBeforeExpandHint(text: string, suffix: string): string {
285
+ const plain = text.replace(CALL_ANSI_RE, "");
286
+ if (!plain.endsWith(" to expand)")) return `${text}${suffix}`;
287
+ const plainIndex = plain.lastIndexOf(" (");
288
+ const insertAt = plainIndex < 0 ? text.length : rawIndexForPlainIndex(text, plainIndex);
289
+ return `${text.slice(0, insertAt)}${suffix}${text.slice(insertAt)}`;
290
+ }
291
+
292
+ function anchorRangeLabel(ref: { anchor: string; limit: number | undefined }, line: number | undefined): string {
293
+ if (line === undefined) return ref.limit === undefined ? `:${ref.anchor}` : `:${ref.anchor} +${ref.limit}`;
294
+ return ref.limit === undefined ? `:${line} (${ref.anchor})` : `:${line}-${line + ref.limit - 1} (${ref.anchor})`;
295
+ }
296
+
266
297
  function renderReadCall(args: unknown, theme: any, context: any): Text {
267
298
  const normalized = normalizeReadCallArgs(args);
268
- const rendered = builtinReadRenderCall(normalized, theme, context);
269
299
  const ref = anchorRefOf(normalized);
270
- if (ref === undefined) return rendered;
271
- const marker = `:${ref.anchor}`;
272
- const text = (rendered as unknown as { text: string }).text;
273
- const index = text.lastIndexOf(marker);
274
- if (index < 0) return rendered;
300
+ if (ref === undefined) return builtinReadRenderCall(normalized, theme, context);
301
+ const rendered = builtinReadRenderCall(isRec(normalized) ? { ...normalized, offset: undefined, limit: undefined } : normalized, theme, context);
275
302
  const line = anchorLineOf(ref.anchor, ref.path, context?.cwd);
276
- if (line === undefined) {
277
- if (ref.limit === undefined) return rendered;
278
- const suffix = theme.fg("warning", ` +${ref.limit}`);
279
- rendered.setText(`${text.slice(0, index + marker.length)}${suffix}${text.slice(index + marker.length)}`);
280
- return rendered;
281
- }
282
- const range = ref.limit === undefined ? `${line}` : `${line}-${line + ref.limit - 1}`;
283
- const replacement = theme.fg("warning", `:${range} (${ref.anchor})`);
284
- rendered.setText(`${text.slice(0, index)}${replacement}${text.slice(index + marker.length)}`);
303
+ const suffix = theme.fg("warning", anchorRangeLabel(ref, line));
304
+ const text = (rendered as unknown as { text: string }).text;
305
+ rendered.setText(insertBeforeExpandHint(text, suffix));
285
306
  return rendered;
286
307
  }
287
308
 
309
+ function buildReadToolSchema(codemode: boolean) {
310
+ return Type.Object({
311
+ path: Type.String({
312
+ description: "Path to the file to read (relative or absolute)",
313
+ }),
314
+ offset: Type.Optional(
315
+ Type.Union([
316
+ Type.Integer({ minimum: 1 }),
317
+ Type.String(),
318
+ ], { description: "Line number (1-indexed) or a served anchor to start reading from" }),
319
+ ),
320
+ limit: Type.Optional(
321
+ Type.Integer({
322
+ minimum: 1,
323
+ description: "Maximum number of lines to read from offset",
324
+ }),
325
+ ),
326
+ ...(codemode
327
+ ? {
328
+ anchors: Type.Optional(
329
+ Type.Boolean({
330
+ description: "Return { text, lines, notices } with editable anchors instead of the plain file text to a codemode script",
331
+ }),
332
+ ),
333
+ }
334
+ : {}),
335
+ });
336
+ }
337
+
288
338
  export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
289
339
  const prompted = withReadPrompts({ description: R_DESC, snippet: R_SNIPPET, guidelines: readGuide() }, flags);
290
340
  pi.registerTool({
@@ -294,23 +344,7 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
294
344
  promptSnippet: prompted.snippet,
295
345
  promptGuidelines: prompted.guidelines,
296
346
  prepareArguments: makePrepareArguments(),
297
- parameters: Type.Object({
298
- path: Type.String({
299
- description: "Path to the file to read (relative or absolute)",
300
- }),
301
- offset: Type.Optional(
302
- Type.Union([
303
- Type.Integer({ minimum: 1 }),
304
- Type.String(),
305
- ], { description: "Line number (1-indexed) or a served anchor to start reading from" }),
306
- ),
307
- limit: Type.Optional(
308
- Type.Integer({
309
- minimum: 1,
310
- description: "Maximum number of lines to read from offset",
311
- }),
312
- ),
313
- }),
347
+ parameters: buildReadToolSchema(flags.codemode),
314
348
  outputSchema: readResultSchema,
315
349
  executionMode: "sequential",
316
350
  renderCall: renderReadCall,
@@ -370,7 +404,7 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
370
404
  const servedRawText = preview.anchoredLines.map((row) => fileLines[row.line - 1] ?? "").join("\n");
371
405
  const rawTruncation = servedRawText.length > 0 ? truncateHead(servedRawText, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES }) : undefined;
372
406
 
373
- const structuredContent: ReadResult = {
407
+ const readResult: ReadTextResult = {
374
408
  ok: true,
375
409
  kind: "read",
376
410
  path: rawPath,
@@ -384,6 +418,8 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
384
418
  hadUtf8DecodeErrors,
385
419
  notices,
386
420
  };
421
+ const anchorsRequested = isRec(params) && params.anchors === true;
422
+ const structuredContent: ReadResult = flags.codemode && !anchorsRequested ? readResult.text : readResult;
387
423
  return {
388
424
  content: [{ type: "text", text: previewText }],
389
425
  details: {
@@ -159,6 +159,40 @@ function placeSpans(
159
159
  return placed;
160
160
  }
161
161
 
162
+ function splitPlacedSpan(
163
+ span: PlacedSpan,
164
+ oldLines: string[],
165
+ newLines: string[],
166
+ oldHashes: string[],
167
+ newIndexByHash: ReadonlyMap<string, number>,
168
+ ): PlacedSpan[] {
169
+ if (span.oldStart > span.oldEnd || span.newStart > span.newEnd) return [span];
170
+ const pairs: Array<[number, number]> = [];
171
+ let lastNew = span.newStart - 1;
172
+ for (let oldIdx = span.oldStart; oldIdx <= span.oldEnd; oldIdx++) {
173
+ const newIdx = newIndexByHash.get(oldHashes[oldIdx]!);
174
+ if (newIdx === undefined || newIdx < span.newStart || newIdx > span.newEnd || newIdx <= lastNew) continue;
175
+ if (oldLines[oldIdx] !== newLines[newIdx]) continue;
176
+ pairs.push([oldIdx, newIdx]);
177
+ lastNew = newIdx;
178
+ }
179
+ if (pairs.length === 0) return [span];
180
+ const out: PlacedSpan[] = [];
181
+ let oldCursor = span.oldStart;
182
+ let newCursor = span.newStart;
183
+ for (const [oldIdx, newIdx] of pairs) {
184
+ if (oldCursor <= oldIdx - 1 || newCursor <= newIdx - 1) {
185
+ out.push({ oldStart: oldCursor, oldEnd: oldIdx - 1, newStart: newCursor, newEnd: newIdx - 1 });
186
+ }
187
+ oldCursor = oldIdx + 1;
188
+ newCursor = newIdx + 1;
189
+ }
190
+ if (oldCursor <= span.oldEnd || newCursor <= span.newEnd) {
191
+ out.push({ oldStart: oldCursor, oldEnd: span.oldEnd, newStart: newCursor, newEnd: span.newEnd });
192
+ }
193
+ return out;
194
+ }
195
+
162
196
  function trimPlacedSpans(
163
197
  placed: PlacedSpan[],
164
198
  oldLines: string[],
@@ -166,22 +200,11 @@ function trimPlacedSpans(
166
200
  oldHashes: string[],
167
201
  newHashes: string[],
168
202
  ): PlacedSpan[] {
203
+ const newIndexByHash = new Map<string, number>();
204
+ for (let index = 0; index < newHashes.length; index++) newIndexByHash.set(newHashes[index]!, index);
169
205
  const out: PlacedSpan[] = [];
170
206
  for (const span of placed) {
171
- let oldStart = span.oldStart;
172
- let oldEnd = span.oldEnd;
173
- let newStart = span.newStart;
174
- let newEnd = span.newEnd;
175
- while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldStart] === newLines[newStart] && oldHashes[oldStart] === newHashes[newStart]) {
176
- oldStart += 1;
177
- newStart += 1;
178
- }
179
- while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldEnd] === newLines[newEnd] && oldHashes[oldEnd] === newHashes[newEnd]) {
180
- oldEnd -= 1;
181
- newEnd -= 1;
182
- }
183
- if (oldStart > oldEnd && newStart > newEnd) continue;
184
- out.push({ oldStart, oldEnd, newStart, newEnd });
207
+ for (const piece of splitPlacedSpan(span, oldLines, newLines, oldHashes, newIndexByHash)) out.push(piece);
185
208
  }
186
209
  return out;
187
210
  }
@@ -364,7 +387,7 @@ export function genDiff(
364
387
  ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
365
388
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
366
389
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
367
- if (spans && newContentHashes && oldContentHashes) {
390
+ if (spans && spans.length > 0 && newContentHashes && oldContentHashes) {
368
391
  const anchored = genSpanDiff(oldContent, newContent, contextLines, newContentHashes, oldContentHashes, maxLineBytes, maxBytes, spans);
369
392
  if (anchored) {
370
393
  const diff = disambiguateDuplicateAnchors(anchored.diff);
package/src/replace.ts CHANGED
@@ -87,6 +87,7 @@ export interface ExecPipelineOptions {
87
87
  stripWarning?: StripWarningLocation;
88
88
  endingOverrides?: (LineEnding | undefined)[];
89
89
  preserveDeletionSeparators?: boolean;
90
+ anchorCarry?: number;
90
91
  }
91
92
 
92
93
  export function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
@@ -205,20 +206,22 @@ export async function execPipeline(
205
206
  const result = anchorResult.content;
206
207
  const isNoop = result === originalNormalized;
207
208
 
209
+ const pipeSpan = isNoop ? undefined : spanForEdit(originalHashes, effectiveEdit.hash_bounds[0].hash, effectiveEdit.hash_bounds[1].hash, result);
210
+ if (pipeSpan && options?.anchorCarry !== undefined) pipeSpan.carry = options.anchorCarry;
211
+ const pipeSpans = pipeSpan ? [pipeSpan] : undefined;
212
+
208
213
  const resultHashes = isNoop
209
214
  ? originalHashes
210
215
  : await lineHashes(result, absolutePath, {
211
216
  content: originalNormalized,
212
217
  hashes: originalHashes,
218
+ ...(pipeSpans ? { spans: pipeSpans } : {}),
213
219
  }, hashStore, false, true);
214
220
  const warnings = [...editWarnings, ...(anchorResult.warnings ?? [])];
215
221
  await throwIfStrictInput(warnings);
216
222
  const { totalAddedLines, totalRemovedLines } = countLineChanges(
217
223
  effectiveEdit, originalHashes, isNoop,
218
224
  );
219
-
220
- const pipeSpan = isNoop ? undefined : spanForEdit(originalHashes, effectiveEdit.hash_bounds[0].hash, effectiveEdit.hash_bounds[1].hash, result);
221
- const pipeSpans = pipeSpan ? [pipeSpan] : undefined;
222
225
  return {
223
226
  path: displayPath,
224
227
  originalNormalized,
package/src/utils.ts CHANGED
@@ -41,6 +41,24 @@ export function splitLines(text: string): string[] {
41
41
  return text.endsWith("\n") ? lines.slice(0, -1) : lines;
42
42
  }
43
43
 
44
+ export function spliceAll<T>(target: T[], start: number, deleteCount: number, items: readonly T[]): T[] {
45
+ const rawStart = Number.isNaN(start) ? 0 : Math.trunc(start);
46
+ const at = rawStart < 0 ? Math.max(target.length + rawStart, 0) : Math.min(rawStart, target.length);
47
+ const rawCount = Number.isNaN(deleteCount) ? 0 : Math.trunc(deleteCount);
48
+ const remove = Math.max(0, Math.min(rawCount, target.length - at));
49
+ const removed = target.slice(at, at + remove);
50
+ const tail = target.slice(at + remove);
51
+ const additions: readonly T[] = items === target ? [...items] : items;
52
+ target.length = at;
53
+ for (const item of additions) target.push(item);
54
+ for (const item of tail) target.push(item);
55
+ return removed;
56
+ }
57
+
58
+ export function anchorCarryIndex(direction: "after" | "before", lineCount: number): number {
59
+ return direction === "after" ? 0 : lineCount;
60
+ }
61
+
44
62
  export function visLines(text: string): string[] {
45
63
  return text.length === 0 ? [] : splitLines(text);
46
64
  }
@@ -194,10 +212,12 @@ export function clipLine(line: string, maxLen = 200): string {
194
212
  const flat = line.replace(/\n/g, "\\n");
195
213
  return flat.length > maxLen ? `${flat.slice(0, maxLen)}...` : flat;
196
214
  }
197
- export function assertLineLimit(content: string, displayPath: string, limit: number): void {
198
- const count = splitLines(content).length;
215
+ export function assertLineCount(count: number, displayPath: string, limit: number): void {
199
216
  if (count > limit) throw new Error(formatLineLimit(displayPath, limit, count));
200
217
  }
218
+ export function assertLineLimit(content: string, displayPath: string, limit: number): void {
219
+ assertLineCount(splitLines(content).length, displayPath, limit);
220
+ }
201
221
  export function assertByteLimit(content: string, displayPath: string, limit = MAX_BYTES): void {
202
222
  if (Buffer.byteLength(content, "utf-8") > limit) {
203
223
  throw new Error(`[E_FILE_TOO_LARGE] File is too large: ${displayPath} (exceeds the ${limit / (1024 * 1024)}MB size limit). For very large files, use write.`);