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/utils.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { NUL_CONTENT_MSG } from "./constants";
2
+
1
3
  export function isRec(value: unknown): value is Record<string, unknown> {
2
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
3
5
  }
@@ -74,6 +76,10 @@ export function abortIf(signal?: AbortSignal): void {
74
76
  if (signal?.aborted) throw new Error("Operation aborted");
75
77
  }
76
78
 
79
+ export function assertNoNul(lines: string[]): void {
80
+ if (lines.some((line) => line.includes("\0"))) throw new Error(NUL_CONTENT_MSG);
81
+ }
82
+
77
83
  export function errCode(error: unknown): string | undefined {
78
84
  if (error instanceof Error) {
79
85
  return (error as NodeJS.ErrnoException).code;
@@ -181,83 +187,117 @@ function formatLineLimit(displayPath: string, limit: number, count: number | und
181
187
  return `[E_FILE_TOO_LARGE] ${displayPath} ${detail} lines, exceeding the ${limit}-line hashline limit. For very large files, use write.`;
182
188
  }
183
189
 
184
- function escapeRawControl(value: string): string {
185
- if (value === "\b") return "\\b";
186
- if (value === "\t") return "\\t";
187
- if (value === "\n") return "\\n";
188
- if (value === "\f") return "\\f";
189
- if (value === "\r") return "\\r";
190
- const hex = value.charCodeAt(0).toString(16).padStart(4, "0");
191
- return `\\u${hex}`;
190
+ function stripCodeFence(text: string): string {
191
+ const trimmed = text.trim();
192
+ const fenced = /^```[^\n]*\n([\s\S]*?)\n?```$/.exec(trimmed);
193
+ return fenced ? fenced[1]!.trim() : trimmed;
192
194
  }
193
195
 
194
- function escapeControls(value: string): string {
195
- let out = "";
196
- for (const char of value) {
197
- out += char.charCodeAt(0) < 32 ? escapeRawControl(char) : char;
196
+ function jsonStringArray(text: string): string[] | undefined {
197
+ try {
198
+ const parsed: unknown = JSON.parse(text);
199
+ if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string")) {
200
+ return parsed as string[];
201
+ }
202
+ } catch {
198
203
  }
199
- return out;
204
+ return undefined;
200
205
  }
201
206
 
202
- function decodeArraySegment(segment: string): string | undefined {
203
- const trimmed = segment.trim();
204
- if (trimmed.length < 2 || !trimmed.startsWith('"') || !trimmed.endsWith('"')) return undefined;
205
- try {
206
- const cleaned = escapeControls(trimmed);
207
- const parsed: unknown = JSON.parse(cleaned);
208
- return typeof parsed === "string" ? parsed : undefined;
209
- } catch {
210
- return undefined;
207
+ function decodeEscape(char: string): string | undefined {
208
+ switch (char) {
209
+ case "\"": return "\"";
210
+ case "'": return "'";
211
+ case "\\": return "\\";
212
+ case "/": return "/";
213
+ case "b": return "\b";
214
+ case "f": return "\f";
215
+ case "n": return "\n";
216
+ case "r": return "\r";
217
+ case "t": return "\t";
218
+ default: return undefined;
211
219
  }
212
220
  }
213
221
 
214
- function splitArraySegments(inner: string): string[] | undefined {
215
- const segments: string[] = [];
216
- let current = "";
217
- let inQuotes = false;
218
- let escaped = false;
219
- for (const char of inner) {
220
- if (inQuotes) {
221
- current += char;
222
- if (escaped) escaped = false;
223
- else if (char === "\\") escaped = true;
224
- else if (char === '"') inQuotes = false;
225
- continue;
226
- }
227
- if (char === '"') {
228
- inQuotes = true;
229
- current += char;
230
- continue;
231
- }
232
- if (char === ",") {
233
- segments.push(current);
234
- current = "";
222
+ function scanQuotedSegment(inner: string, start: number): { value: string; next: number } | undefined {
223
+ const quote = inner[start]!;
224
+ let out = "";
225
+ let index = start + 1;
226
+ while (index < inner.length) {
227
+ const char = inner[index]!;
228
+ if (char === "\\") {
229
+ const escaped = inner[index + 1];
230
+ if (escaped === undefined) return undefined;
231
+ if (escaped === "u") {
232
+ const hex = inner.slice(index + 2, index + 6);
233
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) return undefined;
234
+ out += String.fromCharCode(Number.parseInt(hex, 16));
235
+ index += 6;
236
+ continue;
237
+ }
238
+ const decoded = decodeEscape(escaped);
239
+ if (decoded === undefined) return undefined;
240
+ out += decoded;
241
+ index += 2;
235
242
  continue;
236
243
  }
237
- current += char;
244
+ if (char === quote) return { value: out, next: index + 1 };
245
+ out += char;
246
+ index += 1;
247
+ }
248
+ return undefined;
249
+ }
250
+
251
+ function scanArrayText(inner: string): string[] | undefined {
252
+ const values: string[] = [];
253
+ let index = 0;
254
+ while (index < inner.length && /\s/.test(inner[index]!)) index += 1;
255
+ if (index >= inner.length) return [];
256
+ for (;;) {
257
+ if (inner[index] !== "\"" && inner[index] !== "'") return undefined;
258
+ const segment = scanQuotedSegment(inner, index);
259
+ if (!segment) return undefined;
260
+ values.push(segment.value);
261
+ index = segment.next;
262
+ while (index < inner.length && /\s/.test(inner[index]!)) index += 1;
263
+ if (index >= inner.length) return values;
264
+ if (inner[index] !== ",") return undefined;
265
+ index += 1;
266
+ while (index < inner.length && /\s/.test(inner[index]!)) index += 1;
267
+ if (index >= inner.length) return values;
238
268
  }
239
- if (inQuotes || escaped) return undefined;
240
- segments.push(current);
241
- return segments;
242
269
  }
243
270
 
244
271
  function decodeArrayText(value: unknown): string[] | undefined {
245
272
  if (typeof value !== "string") return undefined;
246
- const trimmed = value.trim();
273
+ const trimmed = stripCodeFence(value);
247
274
  if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return undefined;
248
- const segments = splitArraySegments(trimmed.slice(1, -1));
249
- if (!segments) return undefined;
250
- const decoded: string[] = [];
251
- for (const segment of segments) {
252
- const part = decodeArraySegment(segment);
253
- if (part === undefined) return undefined;
254
- decoded.push(part);
255
- }
256
- return decoded.length > 0 ? decoded : undefined;
275
+ const decoded = jsonStringArray(trimmed);
276
+ if (decoded !== undefined && decoded.length > 0) return decoded;
277
+ const scanned = scanArrayText(trimmed.slice(1, -1));
278
+ return scanned !== undefined && scanned.length > 0 ? scanned : undefined;
279
+ }
280
+
281
+ function looksLikeStringArray(value: unknown): boolean {
282
+ if (typeof value !== "string") return false;
283
+ const trimmed = stripCodeFence(value);
284
+ return trimmed.endsWith("]") && /^\[\s*['"]/.test(trimmed);
257
285
  }
258
286
 
259
- export function decodeStringArray(value: unknown): string[] | undefined {
260
- if (typeof value === "string") return decodeArrayText(value);
261
- if (Array.isArray(value) && value.length === 1) return decodeArrayText(value[0]);
287
+ export function decodeStringArray(value: unknown, warnings?: string[], label = "replacement_lines"): string[] | undefined {
288
+ const candidate = typeof value === "string"
289
+ ? value
290
+ : Array.isArray(value) && value.length === 1 && typeof value[0] === "string"
291
+ ? value[0]
292
+ : undefined;
293
+ if (candidate === undefined) return undefined;
294
+ const decoded = decodeArrayText(candidate);
295
+ if (decoded !== undefined) {
296
+ warnings?.push(`[W_BAD_SHAPE] Unwrapped JSON array syntax from a ${label} element.`);
297
+ return decoded;
298
+ }
299
+ if (looksLikeStringArray(candidate)) {
300
+ warnings?.push(`[W_BAD_SHAPE] ${label} looked like a JSON array but could not be parsed; kept as one literal line: ${clipLine(candidate, 60)}`);
301
+ }
262
302
  return undefined;
263
303
  }
package/src/write-hook.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { HASH_CLASS } from "./hashline/alphabet";
3
3
  import { HASH_SEP } from "./hashline/hash";
4
- import { servedForPath } from "./anchor-registry";
4
+ import { servedForPath, withAnchorSession } from "./anchor-registry";
5
5
  import { resolveInCwd } from "./fs-write";
6
6
  import { abortIf, splitLines, isRec, normalizeFilePath } from "./utils";
7
7
 
@@ -35,7 +35,7 @@ export async function servedHashEchoDenial(rawPath: string, content: string, cwd
35
35
  }
36
36
 
37
37
  export function registerWriteHook(pi: ExtensionAPI): void {
38
- pi.on("tool_call", async (event, ctx) => {
38
+ pi.on("tool_call", async (event, ctx) => withAnchorSession(ctx, async () => {
39
39
  if (event.toolName !== "write") return;
40
40
  const input = event.input as Record<string, unknown> | undefined;
41
41
  if (!input || !isRec(input)) return;
@@ -53,5 +53,5 @@ export function registerWriteHook(pi: ExtensionAPI): void {
53
53
  console.error("write hook failed:", error);
54
54
  }
55
55
  return;
56
- });
56
+ }));
57
57
  }