pi-hashline-edit-pro 4.2.5 → 4.2.7

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/src/grep.ts CHANGED
@@ -12,7 +12,7 @@ import { toCwd } from "./paths";
12
12
  import { loadP, loadGuide } from "./prompts";
13
13
  import { normReq } from "./payload-contract";
14
14
  import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
15
- import { markServed as markServedScoped } from "./anchor-registry";
15
+ import { markServed as markServedScoped, withAnchorSession } from "./anchor-registry";
16
16
  import { buildServedMap } from "./served";
17
17
  import { Text } from "@earendil-works/pi-tui";
18
18
  import { expandHint, getResultText, reuseText, type CallT, type FgT } from "./replace-render";
@@ -553,172 +553,174 @@ export function regGrep(pi: ExtensionAPI): void {
553
553
  },
554
554
 
555
555
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
556
- const canonical = normReq(params);
557
- assertGrepReq(canonical);
558
- const req = canonical;
559
- const context = req.context ?? 0;
560
- const limit = req.limit ?? 100;
561
- const base = req.path ? toCwd(req.path, ctx.cwd) : ctx.cwd;
562
- abortIf(signal);
563
- let baseStat;
564
- try {
565
- baseStat = await stat(base);
566
- } catch (error) {
567
- if (errCode(error) === "ENOENT") {
568
- throw new Error(`[E_NOT_FOUND] File not found: ${req.path ?? ctx.cwd}`);
569
- }
570
- throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
571
- }
572
- const globRoot = baseStat.isFile() ? dirname(base) : base;
573
- const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
574
- const validatedRegex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
575
- const rgPath = await resolveRgPath();
576
- const hits: FileHit[] = [];
577
- let matches = 0;
578
- let limitTruncated = false;
579
- let rowTruncated = false;
580
- let rowCount = 0;
581
- let byteCount = 0;
582
- let totalRows = 0;
583
- let totalBytes = 0;
584
- let truncatedBy: "lines" | "bytes" | null = null;
585
- let linesReplaced = 0;
586
- let countOnly = false;
587
- let poolSkipped = 0;
588
- const makeGrepReader = (allocation: "real" | "shadow") => async (absPath: string) => {
556
+ return withAnchorSession(ctx, async () => {
557
+ const canonical = normReq(params);
558
+ assertGrepReq(canonical);
559
+ const req = canonical;
560
+ const context = req.context ?? 0;
561
+ const limit = req.limit ?? 100;
562
+ const base = req.path ? toCwd(req.path, ctx.cwd) : ctx.cwd;
563
+ abortIf(signal);
564
+ let baseStat;
589
565
  try {
590
- return await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, allocation, signal });
566
+ baseStat = await stat(base);
591
567
  } catch (error) {
592
- if (!isPoolExhaustedError(error)) throw error;
593
- poolSkipped += 1;
594
- return undefined;
568
+ if (errCode(error) === "ENOENT") {
569
+ throw new Error(`[E_NOT_FOUND] File not found: ${req.path ?? ctx.cwd}`);
570
+ }
571
+ throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
595
572
  }
596
- };
597
- const readGrepFile = makeGrepReader("real");
598
- const readGrepFileShadow = makeGrepReader("shadow");
599
- const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal);
600
- const sortedFiles = [...rgMatches.keys()].sort(cmp);
601
- for (let f = 0; f < sortedFiles.length; f++) {
602
- abortIf(signal);
603
- const absPath = sortedFiles[f]!;
604
- const allNums = rgMatches.get(absPath) ?? [];
605
- const totalForFile = allNums.length;
606
- const sortedNums = [...allNums].sort((a, b) => a - b);
607
- const indices = sortedNums.map((n) => n - 1).filter((n) => n >= 0);
608
- if (countOnly) {
573
+ const globRoot = baseStat.isFile() ? dirname(base) : base;
574
+ const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
575
+ const validatedRegex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
576
+ const rgPath = await resolveRgPath();
577
+ const hits: FileHit[] = [];
578
+ let matches = 0;
579
+ let limitTruncated = false;
580
+ let rowTruncated = false;
581
+ let rowCount = 0;
582
+ let byteCount = 0;
583
+ let totalRows = 0;
584
+ let totalBytes = 0;
585
+ let truncatedBy: "lines" | "bytes" | null = null;
586
+ let linesReplaced = 0;
587
+ let countOnly = false;
588
+ let poolSkipped = 0;
589
+ const makeGrepReader = (allocation: "real" | "shadow") => async (absPath: string) => {
590
+ try {
591
+ return await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, allocation, signal });
592
+ } catch (error) {
593
+ if (!isPoolExhaustedError(error)) throw error;
594
+ poolSkipped += 1;
595
+ return undefined;
596
+ }
597
+ };
598
+ const readGrepFile = makeGrepReader("real");
599
+ const readGrepFileShadow = makeGrepReader("shadow");
600
+ const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal);
601
+ const sortedFiles = [...rgMatches.keys()].sort(cmp);
602
+ for (let f = 0; f < sortedFiles.length; f++) {
603
+ abortIf(signal);
604
+ const absPath = sortedFiles[f]!;
605
+ const allNums = rgMatches.get(absPath) ?? [];
606
+ const totalForFile = allNums.length;
607
+ const sortedNums = [...allNums].sort((a, b) => a - b);
608
+ const indices = sortedNums.map((n) => n - 1).filter((n) => n >= 0);
609
+ if (countOnly) {
610
+ if (globRegex) {
611
+ const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
612
+ const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
613
+ if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
614
+ }
615
+ const norm = await readGrepFileShadow(absPath);
616
+ if (!norm) continue;
617
+ const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, indices.length);
618
+ const display = displayRowsForHit(hit);
619
+ totalRows += display.length;
620
+ for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
621
+ const remainingCountOnly = limit - matches;
622
+ if (remainingCountOnly > 0) {
623
+ const add = Math.min(hit.matchCount, remainingCountOnly);
624
+ matches += add;
625
+ if (hit.matchCount > remainingCountOnly) limitTruncated = true;
626
+ } else {
627
+ limitTruncated = true;
628
+ }
629
+ continue;
630
+ }
631
+ const remaining = limit - matches;
632
+ if (remaining <= 0) {
633
+ limitTruncated = true;
634
+ break;
635
+ }
609
636
  if (globRegex) {
610
637
  const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
611
638
  const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
612
639
  if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
613
640
  }
614
- const norm = await readGrepFileShadow(absPath);
641
+ const norm = await readGrepFile(absPath);
615
642
  if (!norm) continue;
616
- const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, indices.length);
643
+ const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
644
+ if (!hit) continue;
617
645
  const display = displayRowsForHit(hit);
618
- totalRows += display.length;
619
- for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
620
- const remainingCountOnly = limit - matches;
621
- if (remainingCountOnly > 0) {
622
- const add = Math.min(hit.matchCount, remainingCountOnly);
623
- matches += add;
624
- if (hit.matchCount > remainingCountOnly) limitTruncated = true;
625
- } else {
626
- limitTruncated = true;
646
+ const keptRows: string[] = [];
647
+ const keptHashes: string[] = [];
648
+ const keptLineNumbers: number[] = [];
649
+ const keptFragmented: boolean[] = [];
650
+ for (let i = 0; i < display.length; i++) {
651
+ const row = display[i]!;
652
+ const rowBytes = Buffer.byteLength(row, "utf-8") + 1;
653
+ if (rowCount >= DEFAULT_MAX_LINES || byteCount + rowBytes > DEFAULT_MAX_BYTES) {
654
+ rowTruncated = true;
655
+ if (truncatedBy === null) truncatedBy = byteCount + rowBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines";
656
+ for (let j = i; j < display.length; j++) {
657
+ totalRows += 1;
658
+ totalBytes += Buffer.byteLength(display[j]!, "utf-8") + 1;
659
+ }
660
+ break;
661
+ }
662
+ keptRows.push(row);
663
+ keptHashes.push(hit.hashes[i]!);
664
+ keptLineNumbers.push(hit.lineNumbers[i]!);
665
+ keptFragmented.push(hit.fragmented[i]!);
666
+ if (hit.fragmented[i]) linesReplaced += 1;
667
+ rowCount += 1;
668
+ byteCount += rowBytes;
669
+ totalRows += 1;
670
+ totalBytes += rowBytes;
627
671
  }
628
- continue;
629
- }
630
- const remaining = limit - matches;
631
- if (remaining <= 0) {
632
- limitTruncated = true;
633
- break;
672
+ if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
673
+ matches += hit.matchCount;
674
+ const displayHit: FileHit = { ...hit, rows: keptRows, hashes: keptHashes, lineNumbers: keptLineNumbers, fragmented: keptFragmented };
675
+ hits.push(displayHit);
676
+ if (rowTruncated) countOnly = true;
634
677
  }
635
- if (globRegex) {
636
- const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
637
- const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
638
- if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
678
+ hits.sort((a, b) => cmp(a.displayPath, b.displayPath));
679
+ for (const hit of hits) {
680
+ markServedScoped(hit.path, buildServedMap(hit.fileHashes, hit.fileLines, hit.hashes), new Set(hit.fileHashes));
639
681
  }
640
- const norm = await readGrepFile(absPath);
641
- if (!norm) continue;
642
- const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
643
- if (!hit) continue;
644
- const display = displayRowsForHit(hit);
645
- const keptRows: string[] = [];
646
- const keptHashes: string[] = [];
647
- const keptLineNumbers: number[] = [];
648
- const keptFragmented: boolean[] = [];
649
- for (let i = 0; i < display.length; i++) {
650
- const row = display[i]!;
651
- const rowBytes = Buffer.byteLength(row, "utf-8") + 1;
652
- if (rowCount >= DEFAULT_MAX_LINES || byteCount + rowBytes > DEFAULT_MAX_BYTES) {
653
- rowTruncated = true;
654
- if (truncatedBy === null) truncatedBy = byteCount + rowBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines";
655
- for (let j = i; j < display.length; j++) {
656
- totalRows += 1;
657
- totalBytes += Buffer.byteLength(display[j]!, "utf-8") + 1;
682
+ const blocks = hits
683
+ .map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
684
+ .join("\n");
685
+ const notes: string[] = [];
686
+ if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
687
+ if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
688
+ 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.]`);
689
+ if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; narrow the search.]`);
690
+ const truncated = limitTruncated || rowTruncated;
691
+ const truncation: TruncationResult | undefined = rowTruncated
692
+ ? {
693
+ content: blocks,
694
+ truncated: true,
695
+ truncatedBy,
696
+ totalLines: totalRows,
697
+ totalBytes,
698
+ outputLines: rowCount,
699
+ outputBytes: byteCount,
700
+ lastLinePartial: false,
701
+ firstLineExceedsLimit: false,
702
+ maxLines: DEFAULT_MAX_LINES,
703
+ maxBytes: DEFAULT_MAX_BYTES,
658
704
  }
659
- break;
660
- }
661
- keptRows.push(row);
662
- keptHashes.push(hit.hashes[i]!);
663
- keptLineNumbers.push(hit.lineNumbers[i]!);
664
- keptFragmented.push(hit.fragmented[i]!);
665
- if (hit.fragmented[i]) linesReplaced += 1;
666
- rowCount += 1;
667
- byteCount += rowBytes;
668
- totalRows += 1;
669
- totalBytes += rowBytes;
670
- }
671
- if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
672
- matches += hit.matchCount;
673
- const displayHit: FileHit = { ...hit, rows: keptRows, hashes: keptHashes, lineNumbers: keptLineNumbers, fragmented: keptFragmented };
674
- hits.push(displayHit);
675
- if (rowTruncated) countOnly = true;
676
- }
677
- hits.sort((a, b) => cmp(a.displayPath, b.displayPath));
678
- for (const hit of hits) {
679
- markServedScoped(hit.path, buildServedMap(hit.fileHashes, hit.fileLines, hit.hashes), new Set(hit.fileHashes));
680
- }
681
- const blocks = hits
682
- .map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
683
- .join("\n");
684
- const notes: string[] = [];
685
- if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
686
- if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
687
- 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.]`);
688
- if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; narrow the search.]`);
689
- const truncated = limitTruncated || rowTruncated;
690
- const truncation: TruncationResult | undefined = rowTruncated
691
- ? {
692
- content: blocks,
693
- truncated: true,
694
- truncatedBy,
695
- totalLines: totalRows,
696
- totalBytes,
697
- outputLines: rowCount,
698
- outputBytes: byteCount,
699
- lastLinePartial: false,
700
- firstLineExceedsLimit: false,
701
- maxLines: DEFAULT_MAX_LINES,
702
- maxBytes: DEFAULT_MAX_BYTES,
703
- }
704
- : undefined;
705
- const text = blocks.length > 0
706
- ? `${blocks}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}`
707
- : notes.length > 0
708
- ? `No matches found.\n${notes.join("\n")}`
709
- : "No matches found.";
710
- return {
711
- content: [{ type: "text", text }],
712
- details: {
713
- ...(truncation ? { truncation } : {}),
714
- ...(linesReplaced > 0 ? { linesTruncated: true as const } : {}),
715
- metrics: {
716
- matches,
717
- files: hits.length,
718
- truncated,
705
+ : undefined;
706
+ const text = blocks.length > 0
707
+ ? `${blocks}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}`
708
+ : notes.length > 0
709
+ ? `No matches found.\n${notes.join("\n")}`
710
+ : "No matches found.";
711
+ return {
712
+ content: [{ type: "text", text }],
713
+ details: {
714
+ ...(truncation ? { truncation } : {}),
715
+ ...(linesReplaced > 0 ? { linesTruncated: true as const } : {}),
716
+ metrics: {
717
+ matches,
718
+ files: hits.length,
719
+ truncated,
720
+ },
719
721
  },
720
- },
721
- };
722
+ };
723
+ });
722
724
  },
723
725
  });
724
726
  }
package/src/hash-store.ts CHANGED
@@ -39,10 +39,81 @@ interface RawDb {
39
39
  close(): void;
40
40
  readonly isOpen: boolean;
41
41
  }
42
- export type SqliteEngine = "node:sqlite";
43
- const sqliteEngine: SqliteEngine = "node:sqlite";
44
- const { DatabaseSync } = await import("node:sqlite");
45
- const openDbFn = (path: string): RawDb => new DatabaseSync(path, { timeout: HASH_STORE_BUSY_TIMEOUT }) as unknown as RawDb;
42
+ export type SqliteEngine = "node:sqlite" | "bun:sqlite";
43
+
44
+ interface BunStatementLike {
45
+ get(...params: SqlParams): unknown;
46
+ all(...params: SqlParams): unknown[];
47
+ run(...params: SqlParams): unknown;
48
+ }
49
+
50
+ interface BunDbLike {
51
+ exec(sql: string): void;
52
+ prepare(sql: string): BunStatementLike;
53
+ close(): void;
54
+ }
55
+
56
+ function wrapBunDatabase(mod: { Database: new (path: string) => BunDbLike }): (path: string) => RawDb {
57
+ return (path) => {
58
+ const db = new mod.Database(path);
59
+ db.exec(`PRAGMA busy_timeout = ${HASH_STORE_BUSY_TIMEOUT}`);
60
+ let closed = false;
61
+ return {
62
+ exec: (sql) => db.exec(sql),
63
+ prepare: (sql) => {
64
+ const stmt = db.prepare(sql);
65
+ return {
66
+ get: (...params) => stmt.get(...params) ?? undefined,
67
+ all: (...params) => stmt.all(...params),
68
+ run: (...params) => stmt.run(...params),
69
+ };
70
+ },
71
+ close: () => {
72
+ if (!closed) {
73
+ closed = true;
74
+ db.close();
75
+ }
76
+ },
77
+ get isOpen() {
78
+ return !closed;
79
+ },
80
+ };
81
+ };
82
+ }
83
+
84
+ async function loadNodeEngine(): Promise<{ engine: SqliteEngine; open: (path: string) => RawDb }> {
85
+ const { DatabaseSync } = await import("node:sqlite");
86
+ return {
87
+ engine: "node:sqlite",
88
+ open: (path) => new DatabaseSync(path, { timeout: HASH_STORE_BUSY_TIMEOUT }) as unknown as RawDb,
89
+ };
90
+ }
91
+
92
+ async function loadBunEngine(): Promise<{ engine: SqliteEngine; open: (path: string) => RawDb }> {
93
+ const specifier = "bun:sqlite";
94
+ const mod = await import(specifier) as { Database: new (path: string) => BunDbLike };
95
+ return { engine: "bun:sqlite", open: wrapBunDatabase(mod) };
96
+ }
97
+
98
+ const isBunRuntime = typeof process !== "undefined" && typeof (process.versions as Record<string, string | undefined>).bun === "string";
99
+
100
+ async function selectSqliteEngine(): Promise<{ engine: SqliteEngine; open: (path: string) => RawDb }> {
101
+ const candidates = isBunRuntime ? [loadBunEngine, loadNodeEngine] : [loadNodeEngine, loadBunEngine];
102
+ let lastError: unknown;
103
+ for (const candidate of candidates) {
104
+ try {
105
+ return await candidate();
106
+ } catch (error) {
107
+ lastError = error;
108
+ }
109
+ }
110
+ const detail = lastError instanceof Error ? lastError.message : String(lastError);
111
+ throw new Error(`[E_STORE_UNAVAILABLE] No SQLite runtime available (node:sqlite and bun:sqlite both failed to load): ${detail}`);
112
+ }
113
+
114
+ const selectedEngine = await selectSqliteEngine();
115
+ const sqliteEngine: SqliteEngine = selectedEngine.engine;
116
+ const openDbFn = selectedEngine.open;
46
117
 
47
118
  interface Prepared {
48
119
  get: (...params: SqlParams) => Record<string, unknown> | undefined;
@@ -1,4 +1,4 @@
1
- import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached } from "../utils";
1
+ import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached, decodeStringArray, assertNoNul } from "../utils";
2
2
  import { parseHashRef, parseText, type Anchor } from "./parse";
3
3
  import { HASH_SEP, stripRowPrefix, canon } from "./hash";
4
4
  import { HASH_RUN } from "./alphabet";
@@ -171,7 +171,8 @@ export function stripAnchorRow(
171
171
  export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
172
172
  assertItem(edit as Record<string, unknown>);
173
173
 
174
- const replaceLines = parseText(edit.replacement_lines, warnings);
174
+ const replaceLines = parseText(decodeStringArray(edit.replacement_lines, warnings) ?? edit.replacement_lines, warnings);
175
+ assertNoNul(replaceLines);
175
176
  const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
176
177
  return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
177
178
  }) as [string, string];
package/src/insert.ts CHANGED
@@ -7,6 +7,7 @@ import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure,
7
7
  import { readNormFile, type NormFile } from "./file-reader";
8
8
  import { MAX_HASH_LINES, parseHashRef, resEdit, resolveAnchorLine, type Anchor, type HEdit } from "./hashline";
9
9
  import { stripAnchorRow } from "./hashline/resolve";
10
+ import { withAnchorSession } from "./anchor-registry";
10
11
  import { loadP, loadGuide } from "./prompts";
11
12
  import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
12
13
  import { decodeStringArray, isRec, splitLines } from "./utils";
@@ -95,11 +96,8 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
95
96
  const normalized = normReq(request);
96
97
  const previewFixes: string[] = [];
97
98
  if (isRec(normalized)) {
98
- const expanded = decodeStringArray(normalized.lines);
99
- if (expanded) {
100
- previewFixes.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
101
- normalized.lines = expanded;
102
- }
99
+ const expanded = decodeStringArray(normalized.lines, previewFixes, "lines");
100
+ if (expanded) normalized.lines = expanded;
103
101
  }
104
102
  assertInsertReq(normalized);
105
103
  const previewReq = normalized as InsertReq;
@@ -175,87 +173,86 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
175
173
  renderCall: editRenderCallWrapper(insertPreview, getInsertInput, "insert"),
176
174
  renderResult: editRenderResultWrapper,
177
175
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
178
- const canonical = normReq(params);
179
- const insertWarnings: string[] = [];
180
- if (isRec(canonical)) {
181
- const expanded = decodeStringArray(canonical.lines);
182
- if (expanded) {
183
- insertWarnings.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
184
- canonical.lines = expanded;
176
+ return withAnchorSession(ctx, async () => {
177
+ const canonical = normReq(params);
178
+ const insertWarnings: string[] = [];
179
+ if (isRec(canonical)) {
180
+ const expanded = decodeStringArray(canonical.lines, insertWarnings, "lines");
181
+ if (expanded) canonical.lines = expanded;
185
182
  }
186
- }
187
- assertInsertReq(canonical);
188
- const req = canonical;
189
- const targetPath = await resolveEditTargetWithRequirement({
190
- anchor: req.anchor,
191
- providedPath: req.path,
192
- cwd: ctx.cwd,
193
- }).catch((error: unknown) => {
194
- const member = batchMemberFor(_toolCallId);
195
- if (member) noteBatchFailure(member, error);
196
- else suffixPoisonCause(_toolCallId, error);
197
- throw error;
198
- });
199
- let ref: Anchor;
200
- let anchorWarnings: string[];
201
- try {
202
- ({ ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor));
203
- await throwIfStrictInput([...anchorWarnings, ...insertWarnings]);
204
- } catch (error) {
205
- const member = batchMemberFor(_toolCallId);
206
- if (member) noteBatchFailure(member, error);
207
- throw error;
208
- }
209
- return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
210
- const member = batchMemberFor(_toolCallId);
211
- if (member) {
212
- const base = await ensureBatchBase({ member, targetPath, mutationTargetPath, cwd: ctx.cwd, signal });
213
- const basePreload = { normalized: base.content, fileHashes: base.hashes } as NormFile;
214
- const built = buildInsertEdit(req, basePreload, ref, targetPath);
215
- let hedit: HEdit;
216
- const resWarnings: string[] = [];
217
- try {
218
- hedit = resEdit(built.editParams, resWarnings);
219
- } catch (error) {
220
- noteBatchFailure(member, error);
221
- throw error;
183
+ assertInsertReq(canonical);
184
+ const req = canonical;
185
+ const targetPath = await resolveEditTargetWithRequirement({
186
+ anchor: req.anchor,
187
+ providedPath: req.path,
188
+ cwd: ctx.cwd,
189
+ }).catch((error: unknown) => {
190
+ const member = batchMemberFor(_toolCallId);
191
+ if (member) noteBatchFailure(member, error);
192
+ else suffixPoisonCause(_toolCallId, error);
193
+ throw error;
194
+ });
195
+ let ref: Anchor;
196
+ let anchorWarnings: string[];
197
+ try {
198
+ ({ ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor));
199
+ await throwIfStrictInput([...anchorWarnings, ...insertWarnings]);
200
+ } catch (error) {
201
+ const member = batchMemberFor(_toolCallId);
202
+ if (member) noteBatchFailure(member, error);
203
+ throw error;
204
+ }
205
+ return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
206
+ const member = batchMemberFor(_toolCallId);
207
+ if (member) {
208
+ const base = await ensureBatchBase({ member, targetPath, mutationTargetPath, cwd: ctx.cwd, signal });
209
+ const basePreload = { normalized: base.content, fileHashes: base.hashes } as NormFile;
210
+ const built = buildInsertEdit(req, basePreload, ref, targetPath);
211
+ let hedit: HEdit;
212
+ const resWarnings: string[] = [];
213
+ try {
214
+ hedit = resEdit(built.editParams, resWarnings);
215
+ } catch (error) {
216
+ noteBatchFailure(member, error);
217
+ throw error;
218
+ }
219
+ return executeBatchMember({
220
+ kind: "insert",
221
+ member,
222
+ targetPath,
223
+ mutationTargetPath,
224
+ cwd: ctx.cwd,
225
+ signal,
226
+ hedit,
227
+ extraWarnings: [...anchorWarnings, ...insertWarnings, ...resWarnings],
228
+ skipBoundaryDedup: true,
229
+ strictBoundaryDedup: false,
230
+ foldedLines: built.anchorLine === undefined ? 0 : 1,
231
+ });
222
232
  }
223
- return executeBatchMember({
224
- kind: "insert",
225
- member,
226
- targetPath,
227
- mutationTargetPath,
228
- cwd: ctx.cwd,
233
+ const preload = await readNormFile(targetPath, ctx.cwd, {
234
+ signal,
235
+ accessMode: constants.R_OK | constants.W_OK,
236
+ maxLines: MAX_HASH_LINES,
237
+ });
238
+ const { editParams, anchorLine } = buildInsertEdit(req, preload, ref, targetPath);
239
+ const pipe = await execPipeline(targetPath, editParams, ctx.cwd, {
240
+ accessMode: constants.R_OK | constants.W_OK,
229
241
  signal,
230
- hedit,
231
- extraWarnings: [...anchorWarnings, ...insertWarnings, ...resWarnings],
242
+ preloadedNorm: preload,
232
243
  skipBoundaryDedup: true,
233
- strictBoundaryDedup: false,
234
- foldedLines: built.anchorLine === undefined ? 0 : 1,
235
244
  });
236
- }
237
- const preload = await readNormFile(targetPath, ctx.cwd, {
238
- signal,
239
- accessMode: constants.R_OK | constants.W_OK,
240
- maxLines: MAX_HASH_LINES,
241
- });
242
- const { editParams, anchorLine } = buildInsertEdit(req, preload, ref, targetPath);
243
- const pipe = await execPipeline(targetPath, editParams, ctx.cwd, {
244
- accessMode: constants.R_OK | constants.W_OK,
245
- signal,
246
- preloadedNorm: preload,
247
- skipBoundaryDedup: true,
248
- });
249
- return commitEdit(pipe, {
250
- path: pipe.path,
251
- absolutePath,
252
- mutationTargetPath,
253
- signal,
254
- verb: "inserted",
255
- noopNoun: "Insertion",
256
- foldedAnchorLines: anchorLine === undefined ? 0 : 1,
257
- prefixWarnings: [...anchorWarnings, ...insertWarnings],
258
- onApplied: () => clearBoundaryBypass(mutationTargetPath),
245
+ return commitEdit(pipe, {
246
+ path: pipe.path,
247
+ absolutePath,
248
+ mutationTargetPath,
249
+ signal,
250
+ verb: "inserted",
251
+ noopNoun: "Insertion",
252
+ foldedAnchorLines: anchorLine === undefined ? 0 : 1,
253
+ prefixWarnings: [...anchorWarnings, ...insertWarnings],
254
+ onApplied: () => clearBoundaryBypass(mutationTargetPath),
255
+ });
259
256
  });
260
257
  });
261
258
  },
@@ -1,5 +1,5 @@
1
1
  import { Type } from "typebox";
2
- import { isRec, normalizeAnchors, normalizeFilePath, rejectUnknownFields } from "./utils";
2
+ import { isRec, normalizeAnchors, normalizeFilePath, rejectUnknownFields, assertNoNul } from "./utils";
3
3
 
4
4
  const replacementLinesSchema = Type.Array(
5
5
  Type.String({
@@ -74,6 +74,7 @@ export function assertReq(request: unknown): asserts request is ReqParams {
74
74
  '[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" (array of strings, one per line; use [] to delete).',
75
75
  );
76
76
  }
77
+ assertNoNul(request.replacement_lines);
77
78
  }
78
79
 
79
80
  export function normReq(input: unknown): unknown {
@@ -136,4 +137,5 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
136
137
  if (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
137
138
  throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
138
139
  }
140
+ assertNoNul(request.lines);
139
141
  }