pi-hashline-edit-pro 2.8.4 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,36 +7,20 @@ import {
7
7
  persistSnapshot,
8
8
  } from "../hash-store";
9
9
  import { xxh32, initHasher } from "./hasher";
10
- import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS, HASH_RUN } from "./alphabet";
11
- export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS, HASH_RUN };
10
+ import { HASH_LEN, ANCHOR_COUNT, anchorAt, anchorIndex, HASH_CLASS, HASH_RUN } from "./alphabet";
11
+ export { initHasher, HASH_LEN, HASH_CLASS, HASH_RUN };
12
12
 
13
13
  export const ANCHOR_LEN = HASH_LEN;
14
14
 
15
15
  export const HASH_SEP = "│";
16
16
 
17
- export const HASH_SPACE = ALPH.length ** HASH_LEN;
17
+ export const HASH_SPACE = ANCHOR_COUNT;
18
18
  export const MAX_HASH_LINES = HASH_SPACE;
19
19
 
20
- export const HASH_PROBE_STRIDE = ALPH.length ** 2 + ALPH.length + 1;
21
-
22
- function idxToHash(idx: number): string {
23
- let out = "";
24
- for (let j = 0; j < HASH_LEN; j++) {
25
- out = ALPH[idx % ALPH.length]! + out;
26
- idx = Math.floor(idx / ALPH.length);
27
- }
28
- return out;
29
- }
30
-
31
- const hashCache = new Map<number, string>();
20
+ export const HASH_PROBE_STRIDE = 571;
32
21
 
33
22
  function hashAt(idx: number): string {
34
- let hash = hashCache.get(idx);
35
- if (hash === undefined) {
36
- hash = idxToHash(idx);
37
- hashCache.set(idx, hash);
38
- }
39
- return hash;
23
+ return anchorAt(idx);
40
24
  }
41
25
 
42
26
  export const HL_PREFIX_PLUS_RE = new RegExp(
@@ -182,13 +166,7 @@ export async function lineHashes(
182
166
  }
183
167
 
184
168
  function hashToIndex(hash: string): number {
185
- let idx = 0;
186
- for (let j = 0; j < HASH_LEN; j++) {
187
- const charIdx = ALPH.indexOf(hash[j]!);
188
- if (charIdx < 0) return -1;
189
- idx = idx * ALPH.length + charIdx;
190
- }
191
- return idx;
169
+ return anchorIndex(hash);
192
170
  }
193
171
 
194
172
  function nearestNew(
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  ANCHOR_LEN,
3
- ALPH_RE,
4
3
  HASH_CLASS,
5
4
  } from "./hash";
5
+ import { ALPH_RE } from "./alphabet";
6
6
  import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
7
7
 
8
8
  const HASH_EXTRACT_RE = new RegExp(HASH_CLASS);
@@ -13,11 +13,11 @@ function diagRef(ref: string): string {
13
13
  const trimmed = ref.trim();
14
14
 
15
15
  if (!trimmed.length) {
16
- return `[E_BAD_REF] Invalid anchor. Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
16
+ return `[E_BAD_REF] Invalid anchor. Expected a 4-char alphanumeric anchor (e.g. "Hasu").`;
17
17
  }
18
18
 
19
19
  if (/^\d+/.test(trimmed)) {
20
- return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "aB3"): no line numbers or trailing content.`;
20
+ return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "Hasu"): no line numbers or trailing content.`;
21
21
  }
22
22
  if (trimmed.includes("│") && trimmed.includes("\n")) {
23
23
  const lines = trimmed.split(/\r?\n/);
@@ -26,16 +26,16 @@ function diagRef(ref: string): string {
26
26
  const hashRe = HASH_EXTRACT_RE;
27
27
  const firstMatch = first.match(hashRe);
28
28
  const lastMatch = last.match(hashRe);
29
- const firstHash = firstMatch?.[0] ?? "aB3";
30
- const lastHash = lastMatch?.[0] ?? "aB3";
29
+ const firstHash = firstMatch?.[0] ?? "Hasu";
30
+ const lastHash = lastMatch?.[0] ?? "Hasu";
31
31
  const preview = first.slice(0, 60);
32
- return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 3-char hash (e.g. "aB3"), not a block with HASH│content. Received ${lines.length} lines starting "${preview}…" — use only the first hash "${firstHash}" as remove_from and "${lastHash}" as remove_to, and put the new content (without HASH│) in replacement_lines.`;
32
+ return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 4-char hash (e.g. "Hasu"), not a block with HASH│content. Received ${lines.length} lines starting "${preview}…" — use only the first hash "${firstHash}" as remove_from and "${lastHash}" as remove_to, and put the new content (without HASH│) in replacement_lines.`;
33
33
  }
34
34
  if (trimmed.includes("│")) {
35
- return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char anchor, drop everything from "│" onward.`;
35
+ return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 4-char anchor, drop everything from "│" onward.`;
36
36
  }
37
37
 
38
- return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
38
+ return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 4-char alphanumeric anchor (e.g. "Hasu").`;
39
39
  }
40
40
 
41
41
  function parseRef(ref: string): Anchor {
@@ -60,7 +60,13 @@ function unwrapJsonEnvelope(line: string, warnings?: string[]): string {
60
60
  if (!match) return line;
61
61
  const withoutDot = line.trim().slice(0, -1);
62
62
  try {
63
- JSON.parse(withoutDot);
63
+ const parsed: unknown = JSON.parse(withoutDot);
64
+ if (Array.isArray(parsed) && parsed.length === 1 && typeof parsed[0] === "string") {
65
+ warnings?.push(
66
+ '[E_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.',
67
+ );
68
+ return parsed[0];
69
+ }
64
70
  return line;
65
71
  } catch {
66
72
  warnings?.push(
@@ -159,12 +159,12 @@ function assertItem(edit: Record<string, unknown>): void {
159
159
 
160
160
  if ("remove_from" in edit && typeof edit.remove_from !== "string") {
161
161
  throw new Error(
162
- `[E_BAD_SHAPE] Field "remove_from" must be an anchor string (3-char anchor).`,
162
+ `[E_BAD_SHAPE] Field "remove_from" must be an anchor string (4-char anchor).`,
163
163
  );
164
164
  }
165
165
  if ("remove_to" in edit && typeof edit.remove_to !== "string") {
166
166
  throw new Error(
167
- `[E_BAD_SHAPE] Field "remove_to" must be an anchor string (3-char anchor).`,
167
+ `[E_BAD_SHAPE] Field "remove_to" must be an anchor string (4-char anchor).`,
168
168
  );
169
169
  }
170
170
  if (!("replacement_lines" in edit)) {
@@ -175,7 +175,7 @@ function assertItem(edit: Record<string, unknown>): void {
175
175
  }
176
176
  if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
177
177
  throw new Error(
178
- `[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (3-char anchors from read output).`,
178
+ `[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (4-char anchors from read output).`,
179
179
  );
180
180
  }
181
181
  }
package/src/insert.ts CHANGED
@@ -8,7 +8,7 @@ import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./
8
8
  import { stripAnchorRow } from "./hashline/resolve";
9
9
  import { loadP, loadGuide } from "./prompts";
10
10
  import { normReq } from "./payload-contract";
11
- import { isRec, rejectUnknownFields, splitLines } from "./utils";
11
+ import { decodeStringArray, isRec, rejectUnknownFields, splitLines } from "./utils";
12
12
  import { clearBoundaryBypass } from "./boundary-bypass";
13
13
  import type { RPreview, RRState } from "./replace-render";
14
14
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
@@ -31,7 +31,7 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
31
31
  throw new Error('[E_BAD_SHAPE] Insert request requires a non-empty "path" string.');
32
32
  }
33
33
  if (typeof request.anchor !== "string" || request.anchor.length === 0) {
34
- throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (3-char anchor from read output).');
34
+ throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (4-char anchor from read output).');
35
35
  }
36
36
  if (request.direction !== "before" && request.direction !== "after") {
37
37
  throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
@@ -49,23 +49,23 @@ const insertToolSchema = Type.Object(
49
49
  }),
50
50
  anchor: Type.String({
51
51
  description:
52
- 'Bare 3-char anchor only (e.g. "aB3"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. A pasted diff row like `+aB3│x` or an `anchor│` prefix is stripped automatically with a warning. The anchored line is preserved; the new lines go after or before it.',
52
+ 'Bare 4-char anchor from a read row like `Hasu│content`, never the content. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped with a warning. The anchor line is preserved; lines go after or before it.',
53
53
  }),
54
54
  direction: Type.Union(
55
55
  [
56
- Type.Literal("after", { description: "Insert the lines after the anchor line" }),
57
- Type.Literal("before", { description: "Insert the lines before the anchor line" }),
56
+ Type.Literal("after"),
57
+ Type.Literal("before"),
58
58
  ],
59
59
  { description: '"after" or "before"' },
60
60
  ),
61
61
  lines: Type.Array(
62
62
  Type.String({
63
63
  description:
64
- "One line to insert. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
64
+ "One line to insert; never embed \\n inside an element.",
65
65
  }),
66
66
  {
67
67
  description:
68
- 'Lines to insert as an array of strings, one element per line. Use [""] for a blank line. The anchor line is preserved; never include it in lines.',
68
+ 'One string per line; [""] is a blank line; never include the anchor line.',
69
69
  }
70
70
  ),
71
71
  },
@@ -101,15 +101,20 @@ function buildInsertEdit(
101
101
  return { editParams, anchorLine };
102
102
  }
103
103
 
104
- export async function insertPreview(request: unknown, cwd: string): Promise<RPreview> {
104
+ export async function insertPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
105
105
  try {
106
106
  const normalized = normReq(request);
107
+ if (isRec(normalized)) {
108
+ const expanded = decodeStringArray(normalized.lines);
109
+ if (expanded) normalized.lines = expanded;
110
+ }
107
111
  assertInsertReq(normalized);
108
112
  const { ref } = parseInsertAnchor(normalized.anchor);
109
113
  const preload = await readNormFile(normalized.path, cwd, {
110
114
  accessMode: constants.R_OK,
111
115
  maxLines: MAX_HASH_LINES,
112
116
  noPersist: true,
117
+ signal,
113
118
  });
114
119
  const { editParams } = buildInsertEdit(normalized, preload, ref);
115
120
  const pipe = await execPipeline(editParams, cwd, {
@@ -117,9 +122,11 @@ export async function insertPreview(request: unknown, cwd: string): Promise<RPre
117
122
  noPersist: true,
118
123
  preloadedNorm: preload,
119
124
  skipBoundaryDedup: true,
125
+ signal,
120
126
  });
121
127
  return previewFromPipe(pipe);
122
128
  } catch (error: unknown) {
129
+ if (signal?.aborted) throw error;
123
130
  return previewError(error);
124
131
  }
125
132
  }
@@ -163,6 +170,14 @@ export function buildInsertToolDef(): InsertToolDef {
163
170
  renderResult: editRenderResultWrapper,
164
171
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
165
172
  const canonical = normReq(params);
173
+ const insertWarnings: string[] = [];
174
+ if (isRec(canonical)) {
175
+ const expanded = decodeStringArray(canonical.lines);
176
+ if (expanded) {
177
+ insertWarnings.push('[E_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
178
+ canonical.lines = expanded;
179
+ }
180
+ }
166
181
  assertInsertReq(canonical);
167
182
  const req = canonical;
168
183
  const path = req.path;
@@ -188,7 +203,7 @@ export function buildInsertToolDef(): InsertToolDef {
188
203
  verb: "inserted",
189
204
  noopNoun: "Insertion",
190
205
  foldedAnchorLines: anchorLine === undefined ? 0 : 1,
191
- prefixWarnings: anchorWarnings,
206
+ prefixWarnings: [...anchorWarnings, ...insertWarnings],
192
207
  onApplied: () => clearBoundaryBypass(mutationTargetPath),
193
208
  });
194
209
  });
@@ -4,22 +4,22 @@ import { isRec, normalizeFilePath, rejectUnknownFields } from "./utils";
4
4
  const replacementLinesSchema = Type.Array(
5
5
  Type.String({
6
6
  description:
7
- "One replacement line. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
7
+ "One replacement line; never embed \\n inside an element.",
8
8
  }),
9
9
  {
10
10
  description:
11
- "Replacement lines as an array of strings, one element per line. Use [] to delete the range.",
11
+ "One string per line. Use [] to delete the range.",
12
12
  },
13
13
  );
14
14
 
15
15
  const removeFromSchema = Type.String({
16
16
  description:
17
- "Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
17
+ "Bare 4-char anchor from a read row like `Hasu│content` (the leftmost column), never the row content. Marks the FIRST line to remove (inclusive)",
18
18
  });
19
19
 
20
20
  const removeToSchema = Type.String({
21
21
  description:
22
- "Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the LAST line to remove (inclusive)",
22
+ "Bare 4-char anchor from a read row like `Hasu│content` (the leftmost column), never the row content. Marks the LAST line to remove (inclusive)",
23
23
  });
24
24
 
25
25
  export const editToolSchema = Type.Object(
@@ -27,7 +27,7 @@ export const editToolSchema = Type.Object(
27
27
  path: Type.Optional(
28
28
  Type.String({
29
29
  description:
30
- "Path to edit. Required: always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake.",
30
+ "Path to edit; always provide it explicitly — it is only auto-resolved from the anchors as a fallback.",
31
31
  }),
32
32
  ),
33
33
  remove_from: removeFromSchema,
@@ -4,7 +4,10 @@ import {
4
4
  _lineHashesPure,
5
5
  ANCHOR_LEN,
6
6
  HASH_SEP,
7
+ changedRange,
7
8
  } from "./hashline";
9
+ import { MAX_DIFF_INPUT_BYTES } from "./constants";
10
+ import { splitLines } from "./utils";
8
11
  import {
9
12
  detectEnding,
10
13
  toLF,
@@ -48,9 +51,63 @@ export function genDiff(
48
51
  oldContentHashes?: string[],
49
52
  limits?: DiffLimits,
50
53
  ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
51
- const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
52
54
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
53
55
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
56
+ if (!limits?.unlimited && Buffer.byteLength(oldContent, "utf-8") + Buffer.byteLength(newContent, "utf-8") > MAX_DIFF_INPUT_BYTES) {
57
+ const guardedRange = changedRange(oldContent, newContent);
58
+ const guardNote = `[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`;
59
+ if (!guardedRange || !newContentHashes) {
60
+ return { diff: ` ...\n${guardNote}`, firstChangedLine: guardedRange?.firstChangedLine, lineNumbers: [undefined, undefined] };
61
+ }
62
+ const newLines = splitLines(newContent);
63
+ const oldLines = splitLines(oldContent);
64
+ const first = guardedRange.firstChangedLine;
65
+ const last = guardedRange.lastChangedLine;
66
+ const suffixLen = newLines.length - last;
67
+ const oldLast = oldLines.length - suffixLen;
68
+ const beforeStart = Math.max(1, first - contextLines);
69
+ const afterEnd = Math.min(newLines.length, last + contextLines);
70
+ const guarded: string[] = [];
71
+ const guardedNumbers: (number|undefined)[] = [];
72
+ let guardedBytes = 0;
73
+ const pushGuarded = (text: string, num?: number): boolean => {
74
+ const size = Buffer.byteLength(text, "utf-8") + 1;
75
+ if (guardedBytes + size > maxBytes) return false;
76
+ guardedBytes += size;
77
+ guarded.push(text);
78
+ guardedNumbers.push(num);
79
+ return true;
80
+ };
81
+ const pushGuardedRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): boolean => {
82
+ const full = fmtDiffLine(prefix, line, hash);
83
+ if (Buffer.byteLength(full, "utf-8") > maxLineBytes) {
84
+ const marker = `[Row is ${formatSize(Buffer.byteLength(full, "utf-8"))}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
85
+ return pushGuarded(fmtDiffLine(prefix, marker, hash), num);
86
+ }
87
+ return pushGuarded(full, num);
88
+ };
89
+ if (beforeStart > 1) pushGuarded(" ...", undefined);
90
+ for (let n = beforeStart; n < first; n++) {
91
+ if (!pushGuardedRow(" ", newLines[n - 1]!, newContentHashes[n - 1], n)) break;
92
+ }
93
+ if (oldContent.length > 0) {
94
+ for (let n = first; n <= Math.min(oldLast, oldLines.length); n++) {
95
+ if (!pushGuardedRow("-", oldLines[n - 1]!, oldContentHashes?.[n - 1], n)) break;
96
+ }
97
+ }
98
+ for (let n = first; n <= last; n++) {
99
+ if (!pushGuardedRow("+", newLines[n - 1]!, newContentHashes[n - 1], n)) break;
100
+ }
101
+ for (let n = last + 1; n <= afterEnd; n++) {
102
+ if (!pushGuardedRow(" ", newLines[n - 1]!, newContentHashes[n - 1], n)) break;
103
+ }
104
+ guarded.push(" ...");
105
+ guardedNumbers.push(undefined);
106
+ guarded.push(guardNote);
107
+ guardedNumbers.push(undefined);
108
+ return { diff: guarded.join("\n"), firstChangedLine: first, lineNumbers: guardedNumbers };
109
+ }
110
+ const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
54
111
 
55
112
  const parts = Diff.diffLines(oldContent, newContent);
56
113
  const output: string[] = [];
@@ -19,6 +19,7 @@ export type RRState = {
19
19
  preview?: RPreview;
20
20
  previewGeneration?: number;
21
21
  previewTimer?: ReturnType<typeof setTimeout>;
22
+ previewAbort?: AbortController;
22
23
  };
23
24
 
24
25
  type DiffRowKind = "added" | "removed" | "context";
@@ -232,7 +233,7 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
232
233
  }
233
234
 
234
235
  export function makeRenderCall(
235
- preview: (args: unknown, cwd: string) => Promise<RPreview>,
236
+ preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
236
237
  options: { getInput?: (args: unknown) => { path?: string } | null; toolName?: string } = {},
237
238
  ) {
238
239
  const getInput = options.getInput ?? getPreviewInput;
@@ -244,6 +245,10 @@ export function makeRenderCall(
244
245
  clearTimeout(context.state.previewTimer);
245
246
  context.state.previewTimer = undefined;
246
247
  }
248
+ if (context.state.previewAbort) {
249
+ context.state.previewAbort.abort();
250
+ context.state.previewAbort = undefined;
251
+ }
247
252
  };
248
253
  if (context.executionStarted) {
249
254
  cancelPendingPreview();
@@ -265,8 +270,12 @@ export function makeRenderCall(
265
270
  context.state.previewGeneration = previewGeneration;
266
271
  context.state.previewTimer = setTimeout(() => {
267
272
  context.state.previewTimer = undefined;
268
- preview(args, context.cwd)
273
+ const controller = new AbortController();
274
+ context.state.previewAbort = controller;
275
+ preview(args, context.cwd, controller.signal)
269
276
  .then((result) => {
277
+ if (controller.signal.aborted) return;
278
+ if (context.state.previewAbort === controller) context.state.previewAbort = undefined;
270
279
  if (
271
280
  context.state.argsKey === argsKey &&
272
281
  context.state.previewGeneration === previewGeneration
@@ -276,6 +285,8 @@ export function makeRenderCall(
276
285
  }
277
286
  })
278
287
  .catch((err: unknown) => {
288
+ if (controller.signal.aborted) return;
289
+ if (context.state.previewAbort === controller) context.state.previewAbort = undefined;
279
290
  if (
280
291
  context.state.argsKey === argsKey &&
281
292
  context.state.previewGeneration === previewGeneration
@@ -311,6 +322,10 @@ export function renderEditResult(
311
322
  clearTimeout(renderState.previewTimer);
312
323
  renderState.previewTimer = undefined;
313
324
  }
325
+ if (renderState.previewAbort) {
326
+ renderState.previewAbort.abort();
327
+ renderState.previewAbort = undefined;
328
+ }
314
329
  renderState.preview = undefined;
315
330
  renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
316
331
  }
@@ -105,6 +105,9 @@ export function regUndo(pi: ExtensionAPI): void {
105
105
  },
106
106
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
107
107
  const path = params.path;
108
+ if (typeof path !== "string" || path.length === 0) {
109
+ throw new Error('[E_BAD_SHAPE] Undo request requires a non-empty "path" string.');
110
+ }
108
111
  const { resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
109
112
 
110
113
  const undo = await getUndo(mutationTargetPath);
package/src/replace.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from "./replace-diff";
10
10
  import { readNormFile, type NormFile } from "./file-reader";
11
11
  import { editToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
12
- import { isRec } from "./utils";
12
+ import { decodeStringArray, isRec } from "./utils";
13
13
  import { loadP, loadGuide } from "./prompts";
14
14
  import { type FileIdentity } from "./fs-write";
15
15
  import { applyEdit,
@@ -160,11 +160,17 @@ export async function execPipeline(
160
160
  const path = params.path;
161
161
 
162
162
  const editWarnings: string[] = [];
163
+ let replacementLines = params.replacement_lines;
164
+ const expandedReplacement = decodeStringArray(replacementLines);
165
+ if (expandedReplacement) {
166
+ editWarnings.push('[E_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.');
167
+ replacementLines = expandedReplacement;
168
+ }
163
169
  const edit = resEdit(
164
170
  {
165
171
  remove_from: params.remove_from,
166
172
  remove_to: params.remove_to,
167
- replacement_lines: params.replacement_lines,
173
+ replacement_lines: replacementLines,
168
174
  },
169
175
  editWarnings,
170
176
  );
@@ -246,6 +252,7 @@ export function previewError(error: unknown): RPreview {
246
252
  export async function compPreview(
247
253
  request: unknown,
248
254
  cwd: string,
255
+ signal?: AbortSignal,
249
256
  ): Promise<RPreview> {
250
257
  try {
251
258
  const normalized = normReq(request);
@@ -253,10 +260,11 @@ export async function compPreview(
253
260
  const pipe = await execPipeline(
254
261
  normalized,
255
262
  cwd,
256
- { accessMode: constants.R_OK, noPersist: true },
263
+ { accessMode: constants.R_OK, noPersist: true, signal },
257
264
  );
258
265
  return previewFromPipe(pipe);
259
266
  } catch (error: unknown) {
267
+ if (signal?.aborted) throw error;
260
268
  return previewError(error);
261
269
  }
262
270
  }
package/src/served.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { loadHashStore, parseStoredServed, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
2
+ import { withBusyRetry } from "./hash-store/retry";
2
3
  import { HASH_CLASS } from "./hashline/alphabet";
3
4
  import { contentChecksum } from "./hashline/hasher";
4
5
 
@@ -37,7 +38,7 @@ export function buildServedMap(fileHashes: string[], fileLines: string[], wanted
37
38
  }
38
39
 
39
40
  export function getServed(store: HashStore, path: string): Map<string, string> | undefined {
40
- const row = store.stmts.servedGet(path);
41
+ const row = withBusyRetry(() => store.stmts.servedGet(path));
41
42
  const parsed = parseStoredServed(row, () => store.stmts.servedDelete(path));
42
43
  if (!parsed) return undefined;
43
44
  return parsed;
package/src/utils.ts CHANGED
@@ -114,7 +114,7 @@ export function getCached<K, V>(map: Map<K, V>, key: K, compute: (key: K) => V):
114
114
  }
115
115
 
116
116
  export function isHashRow(line: string): boolean {
117
- return /^[A-Za-z0-9]{3}│/.test(line);
117
+ return /^[A-Za-z0-9]{4}│/.test(line);
118
118
  }
119
119
 
120
120
  function gutterWidth(max: number, fallback: number): number {
@@ -168,3 +168,84 @@ function formatLineLimit(displayPath: string, limit: number, count: number | und
168
168
  const detail = count === undefined ? `has more than ${limit}` : `has ${count}`;
169
169
  return `[E_FILE_TOO_LARGE] ${displayPath} ${detail} lines, exceeding the ${limit}-line hashline limit. For very large files, use write.`;
170
170
  }
171
+
172
+ function escapeRawControl(value: string): string {
173
+ if (value === "\b") return "\\b";
174
+ if (value === "\t") return "\\t";
175
+ if (value === "\n") return "\\n";
176
+ if (value === "\f") return "\\f";
177
+ if (value === "\r") return "\\r";
178
+ const hex = value.charCodeAt(0).toString(16).padStart(4, "0");
179
+ return `\\u${hex}`;
180
+ }
181
+
182
+ function escapeControls(value: string): string {
183
+ let out = "";
184
+ for (const char of value) {
185
+ out += char.charCodeAt(0) < 32 ? escapeRawControl(char) : char;
186
+ }
187
+ return out;
188
+ }
189
+
190
+ function decodeArraySegment(segment: string): string | undefined {
191
+ const trimmed = segment.trim();
192
+ if (trimmed.length < 2 || !trimmed.startsWith('"') || !trimmed.endsWith('"')) return undefined;
193
+ try {
194
+ const cleaned = escapeControls(trimmed);
195
+ const parsed: unknown = JSON.parse(cleaned);
196
+ return typeof parsed === "string" ? parsed : undefined;
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ }
201
+
202
+ function splitArraySegments(inner: string): string[] | undefined {
203
+ const segments: string[] = [];
204
+ let current = "";
205
+ let inQuotes = false;
206
+ let escaped = false;
207
+ for (const char of inner) {
208
+ if (inQuotes) {
209
+ current += char;
210
+ if (escaped) escaped = false;
211
+ else if (char === "\\") escaped = true;
212
+ else if (char === '"') inQuotes = false;
213
+ continue;
214
+ }
215
+ if (char === '"') {
216
+ inQuotes = true;
217
+ current += char;
218
+ continue;
219
+ }
220
+ if (char === ",") {
221
+ segments.push(current);
222
+ current = "";
223
+ continue;
224
+ }
225
+ current += char;
226
+ }
227
+ if (inQuotes || escaped) return undefined;
228
+ segments.push(current);
229
+ return segments;
230
+ }
231
+
232
+ function decodeArrayText(value: unknown): string[] | undefined {
233
+ if (typeof value !== "string") return undefined;
234
+ const trimmed = value.trim();
235
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return undefined;
236
+ const segments = splitArraySegments(trimmed.slice(1, -1));
237
+ if (!segments) return undefined;
238
+ const decoded: string[] = [];
239
+ for (const segment of segments) {
240
+ const part = decodeArraySegment(segment);
241
+ if (part === undefined) return undefined;
242
+ decoded.push(part);
243
+ }
244
+ return decoded.length > 0 ? decoded : undefined;
245
+ }
246
+
247
+ export function decodeStringArray(value: unknown): string[] | undefined {
248
+ if (typeof value === "string") return decodeArrayText(value);
249
+ if (Array.isArray(value) && value.length === 1) return decodeArrayText(value[0]);
250
+ return undefined;
251
+ }