pi-hashline-edit-pro 2.5.1 → 2.5.3

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
@@ -1,5 +1,7 @@
1
1
  # pi-hashline-edit-pro
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro) [![npm downloads](https://img.shields.io/npm/dm/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro)
4
+
3
5
  Hash-anchored `read` and `replace` tools for [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent). Every line of a file gets a unique 3-character hash, and you edit by hash. No line numbers, no fuzzy matching, no edits landing on the wrong line.
4
6
 
5
7
  Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 3-character hashes and collision resolution.
@@ -68,7 +70,7 @@ Edge cases:
68
70
  - UTF-16 and UTF-32 text (detected via BOM) is rejected, since editing it would corrupt the file.
69
71
  - Empty files come back as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
70
72
  - BOMs are stripped for display. Non-UTF-8 bytes are shown as `U+FFFD`; editing such a file rewrites it as UTF-8, with a warning.
71
- - Files over 238,328 lines are rejected with `[E_FILE_TOO_LARGE]`.
73
+ - Files over 238,328 lines or 100MB are rejected with `[E_FILE_TOO_LARGE]`.
72
74
 
73
75
  ## The replace tool
74
76
 
@@ -169,7 +171,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
169
171
  | `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
170
172
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
171
173
  | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
172
- | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
174
+ | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
173
175
 
174
176
  ## Troubleshooting
175
177
 
package/index.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  toggleAutoRead,
14
14
  } from "./src/config";
15
15
  import { loadHashStore, pruneMissing } from "./src/hash-store";
16
- import { recordServed, clearServed } from "./src/served";
16
+ import { recordServedSafe, clearServed } from "./src/served";
17
17
  import { readNormFile } from "./src/file-reader";
18
18
  import { loadFileKindAndText } from "./src/file-kind";
19
19
  import { toCwd } from "./src/paths";
@@ -88,12 +88,7 @@ export default function (pi: ExtensionAPI): void {
88
88
  DEFAULT_MAX_BYTES,
89
89
  AUTO_READ_MAX,
90
90
  );
91
- try {
92
- const store = await loadHashStore();
93
- recordServed(store, absolutePath, preview.servedHashes);
94
- } catch (error) {
95
- console.error("Failed to record served state from auto-read:", error);
96
- }
91
+ await recordServedSafe(absolutePath, preview.servedHashes, "auto-read");
97
92
  return {
98
93
  content: [
99
94
  ...(event.content ?? []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.5.1",
3
+ "version": "2.5.3",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/undo tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "fs/promises";
2
2
  import { configPath } from "./paths";
3
- import { errCode } from "./utils";
3
+ import { errCode, isRec } from "./utils";
4
4
  import { writeAtomic } from "./fs-write";
5
5
 
6
6
  export interface Config {
@@ -12,10 +12,12 @@ const DEFAULT_CONFIG: Config = {
12
12
  };
13
13
 
14
14
  function parseConfig(content: string): Config {
15
- const parsed = JSON.parse(content) as Partial<Config>;
16
- return {
17
- autoRead: parsed.autoRead === true,
18
- };
15
+ const parsed = JSON.parse(content) as unknown;
16
+ const autoRead = isRec(parsed) ? parsed.autoRead : undefined;
17
+ if (typeof autoRead !== "boolean") {
18
+ throw new Error("config.json must be an object with a boolean autoRead field");
19
+ }
20
+ return { autoRead };
19
21
  }
20
22
 
21
23
 
package/src/file-kind.ts CHANGED
@@ -54,7 +54,8 @@ export type LFile =
54
54
  | { kind: "directory" }
55
55
  | { kind: "image"; mimeType: string }
56
56
  | { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
57
- | { kind: "binary"; description: string };
57
+ | { kind: "binary"; description: string }
58
+ | { kind: "too_large"; description: string };
58
59
 
59
60
 
60
61
  export interface LoadFileOptions {
@@ -78,8 +79,8 @@ export async function loadFileKindAndText(
78
79
  }
79
80
  if (pathStat.size > MAX_BYTES) {
80
81
  return {
81
- kind: "binary",
82
- description: `file exceeds ${MAX_BYTES} byte limit`
82
+ kind: "too_large",
83
+ description: `exceeds the ${MAX_BYTES / (1024 * 1024)}MB size limit`,
83
84
  };
84
85
  }
85
86
 
@@ -45,6 +45,18 @@ export async function fileSnap(absolutePath: string): Promise<SnapInfo> {
45
45
  };
46
46
  }
47
47
 
48
+ export async function safeSnapId(
49
+ absolutePath: string,
50
+ context: string,
51
+ ): Promise<string | undefined> {
52
+ try {
53
+ return (await fileSnap(absolutePath)).snapshotId;
54
+ } catch (error) {
55
+ console.error(`Failed to compute snapshot (${context}):`, error);
56
+ return undefined;
57
+ }
58
+ }
59
+
48
60
  export interface ReadNormOptions {
49
61
  signal?: AbortSignal;
50
62
  accessMode?: number;
package/src/hash-store.ts CHANGED
@@ -49,6 +49,21 @@ export function isValidHashList(value: unknown): value is string[] {
49
49
  return true;
50
50
  }
51
51
 
52
+ export function parseHashList(raw: string, onInvalid: () => void): string[] | undefined {
53
+ let parsed: unknown;
54
+ try {
55
+ parsed = JSON.parse(raw);
56
+ } catch {
57
+ onInvalid();
58
+ return undefined;
59
+ }
60
+ if (!isValidHashList(parsed)) {
61
+ onInvalid();
62
+ return undefined;
63
+ }
64
+ return parsed;
65
+ }
66
+
52
67
  function isValidSnapshot(value: unknown): value is LegacySnapshot {
53
68
  if (typeof value !== "object" || value === null) return false;
54
69
  const v = value as Record<string, unknown>;
@@ -271,7 +286,11 @@ async function openStore(storePath: string): Promise<HashStore> {
271
286
  const { db, stmts } = opened;
272
287
 
273
288
  if (!existed) {
274
- await migrateLegacy(db);
289
+ try {
290
+ await migrateLegacy(db);
291
+ } catch (error) {
292
+ console.error("Hash store migration failed; continuing without legacy import:", error);
293
+ }
275
294
  }
276
295
  cachedDb = { path: storePath, db, stmts };
277
296
 
@@ -313,20 +332,19 @@ export function shutdownHashStore(): void {
313
332
  }
314
333
 
315
334
  function withStore(fn: () => void): void {
316
- if (cachedDb) {
317
- withBusyRetry(() => {
318
- cachedDb!.db.exec("BEGIN IMMEDIATE");
319
- try {
320
- fn();
321
- cachedDb!.db.exec("COMMIT");
322
- } catch (e) {
323
- try { cachedDb!.db.exec("ROLLBACK"); } catch {}
324
- throw e;
325
- }
326
- });
327
- } else {
328
- fn();
335
+ if (!cachedDb) {
336
+ throw new Error("Hash store is not open; transactional update aborted");
329
337
  }
338
+ withBusyRetry(() => {
339
+ cachedDb!.db.exec("BEGIN IMMEDIATE");
340
+ try {
341
+ fn();
342
+ cachedDb!.db.exec("COMMIT");
343
+ } catch (e) {
344
+ try { cachedDb!.db.exec("ROLLBACK"); } catch {}
345
+ throw e;
346
+ }
347
+ });
330
348
  }
331
349
 
332
350
  async function migrateLegacy(db: DatabaseSync): Promise<void> {
@@ -373,17 +391,19 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
373
391
  ]);
374
392
  }
375
393
  if (rows.length > 0) {
376
- db.exec("BEGIN IMMEDIATE");
377
- try {
378
- const stmt = db.prepare(
379
- "INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)"
380
- );
381
- for (const row of rows) stmt.run(...row);
382
- db.exec("COMMIT");
383
- } catch (e) {
384
- db.exec("ROLLBACK");
385
- throw e;
386
- }
394
+ withBusyRetry(() => {
395
+ db.exec("BEGIN IMMEDIATE");
396
+ try {
397
+ const stmt = db.prepare(
398
+ "INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)"
399
+ );
400
+ for (const row of rows) stmt.run(...row);
401
+ db.exec("COMMIT");
402
+ } catch (e) {
403
+ try { db.exec("ROLLBACK"); } catch {}
404
+ throw e;
405
+ }
406
+ });
387
407
  }
388
408
 
389
409
  try {
@@ -418,21 +438,13 @@ export function getSnapshot(
418
438
  }
419
439
  const row = store.stmts.get(path, checksum, lineCount);
420
440
  if (!row) return undefined;
421
- let parsed: unknown;
422
- try {
423
- parsed = JSON.parse(row.hashes as string);
424
- } catch {
441
+ const parsed = parseHashList(row.hashes as string, () => {
425
442
  if (deleteCorrupt) store.stmts.deleteOne(path);
426
443
  snapshotCache.delete(path);
427
- return undefined;
428
- }
429
- if (isValidHashList(parsed)) {
430
- cacheSnapshot(path, checksum, lineCount, parsed);
431
- return parsed;
432
- }
433
- if (deleteCorrupt) store.stmts.deleteOne(path);
434
- snapshotCache.delete(path);
435
- return undefined;
444
+ });
445
+ if (!parsed) return undefined;
446
+ cacheSnapshot(path, checksum, lineCount, parsed);
447
+ return parsed;
436
448
  }
437
449
 
438
450
  export function upsertSnapshot(
@@ -461,23 +473,15 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
461
473
  export function getUndoEntry(store: HashStore, path: string): UndoRecord | undefined {
462
474
  const row = store.stmts.undoGet(path);
463
475
  if (!row) return undefined;
464
- try {
465
- const parsed = JSON.parse(row.hashes as string);
466
- if (!isValidHashList(parsed)) {
467
- store.stmts.undoDelete(path);
468
- return undefined;
469
- }
470
- return {
471
- content: row.content as string,
472
- bom: row.bom as string,
473
- ending: row.ending as string,
474
- hashes: parsed as string[],
475
- resultContent: row.result_content as string,
476
- };
477
- } catch {
478
- store.stmts.undoDelete(path);
479
- return undefined;
480
- }
476
+ const parsed = parseHashList(row.hashes as string, () => store.stmts.undoDelete(path));
477
+ if (!parsed) return undefined;
478
+ return {
479
+ content: row.content as string,
480
+ bom: row.bom as string,
481
+ ending: row.ending as string,
482
+ hashes: parsed,
483
+ resultContent: row.result_content as string,
484
+ };
481
485
  }
482
486
 
483
487
  export function deleteUndo(store: HashStore, path: string): void {
@@ -6,9 +6,6 @@ export {
6
6
  HASH_SPACE,
7
7
  HASH_PROBE_STRIDE,
8
8
  MAX_HASH_LINES,
9
- HL_PREFIX_PLUS_RE,
10
- HL_PREFIX_MINUS_RE,
11
- HL_BARE_PREFIX_RE,
12
9
  lineHashes,
13
10
  _lineHashesPure,
14
11
  initHasher,
@@ -22,7 +19,6 @@ export {
22
19
  } from "./parse";
23
20
 
24
21
  export {
25
- type RAnchor,
26
22
  type HEdit,
27
23
  type RHEdit,
28
24
  type HTEdit,
@@ -34,7 +30,6 @@ export {
34
30
  stripBarePrefixes,
35
31
  stripDiffPrefixes,
36
32
  swapReversedRanges,
37
- fmtMismatch,
38
33
  findNewEdge,
39
34
  assertRangeServed,
40
35
  RangeStaleError,
@@ -133,14 +133,6 @@ export function fmtMismatchWithHashes(
133
133
  return { text: out.join("\n"), hashes };
134
134
  }
135
135
 
136
- export function fmtMismatch(
137
- mismatches: HMismatch[],
138
- fileLines: string[],
139
- fileHashes: string[],
140
- filePath?: string,
141
- ): string {
142
- return fmtMismatchWithHashes(mismatches, fileLines, fileHashes, filePath).text;
143
- }
144
136
 
145
137
  const ITEM_KS = new Set(["replacement_text", "remove_from", "remove_to"]);
146
138
 
package/src/read.ts CHANGED
@@ -9,13 +9,11 @@ import {
9
9
  import { Type } from "typebox";
10
10
  import { MAX_READ_LINE_BYTES } from "./constants";
11
11
  import { loadFileKindAndText } from "./file-kind";
12
- import { readNormFile } from "./file-reader";
12
+ import { readNormFile, safeSnapId } from "./file-reader";
13
13
  import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
14
14
  import { toCwd } from "./paths";
15
- import { abortIf, isRec, normalizeFilePath, visLines } from "./utils";
16
- import { fileSnap } from "./file-reader";
17
- import { loadHashStore } from "./hash-store";
18
- import { recordServed } from "./served";
15
+ import { abortIf, makePrepareArguments, visLines } from "./utils";
16
+ import { recordServedSafe } from "./served";
19
17
  import { loadP, loadGuide } from "./prompts";
20
18
  import { valAccess } from "./validation";
21
19
 
@@ -167,12 +165,7 @@ export function regRead(pi: ExtensionAPI): void {
167
165
  description: R_DESC,
168
166
  promptSnippet: R_SNIPPET,
169
167
  promptGuidelines: readGuide(),
170
- prepareArguments: (args: unknown) => {
171
- if (!isRec(args)) return args as any;
172
- const record = { ...args };
173
- normalizeFilePath(record);
174
- return record;
175
- },
168
+ prepareArguments: makePrepareArguments(),
176
169
  parameters: Type.Object({
177
170
  path: Type.String({
178
171
  description: "Path to the file to read (relative or absolute)",
@@ -223,18 +216,8 @@ export function regRead(pi: ExtensionAPI): void {
223
216
  fileHashes,
224
217
  resolvedPath,
225
218
  );
226
- try {
227
- const store = await loadHashStore();
228
- recordServed(store, resolvedPath, preview.servedHashes);
229
- } catch (error) {
230
- console.error("Failed to record served state from read:", error);
231
- }
232
- let snapshotId: string | undefined;
233
- try {
234
- snapshotId = (await fileSnap(absolutePath)).snapshotId;
235
- } catch (error) {
236
- console.error("Failed to compute snapshot for read:", error);
237
- }
219
+ await recordServedSafe(resolvedPath, preview.servedHashes, "read");
220
+ const snapshotId = await safeSnapId(absolutePath, "read");
238
221
  const previewText =
239
222
  hadUtf8DecodeErrors
240
223
  ? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
@@ -101,7 +101,7 @@ export function buildNoop(input: NoopInput): TResult {
101
101
  ? `Replacement for ${noopEdit.loc} is identical to current content:\n ${noopEdit.loc}: ${clipLine(noopEdit.currentContent)}`
102
102
  : "The edit produced identical content.";
103
103
 
104
- const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}`;
104
+ const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${warnBlock(warnings)}`;
105
105
 
106
106
  const metrics = buildMetrics({
107
107
  classification: "noop",
@@ -8,7 +8,7 @@ import { contentChecksum } from "./hashline/hasher";
8
8
  import { resolveTarget, writeAtomic } from "./fs-write";
9
9
  import { toCwd } from "./paths";
10
10
  import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./replace-diff";
11
- import { cntDiff, splitLines, errCode, isRec, normalizeFilePath } from "./utils";
11
+ import { cntDiff, splitLines, errCode, makePrepareArguments } from "./utils";
12
12
  import { loadP, loadGuide } from "./prompts";
13
13
  import { buildMetrics } from "./replace-response";
14
14
  import { changedRange, lineHashes } from "./hashline";
@@ -92,12 +92,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
92
92
  description: loadP("../prompts/undo-last-replace.md"),
93
93
  promptSnippet: loadP("../prompts/undo-last-replace-snippet.md"),
94
94
  promptGuidelines: loadGuide("../prompts/undo-last-replace-guidelines.md"),
95
- prepareArguments: (args: unknown) => {
96
- if (!isRec(args)) return args as any;
97
- const record = { ...args };
98
- normalizeFilePath(record);
99
- return record;
100
- },
95
+ prepareArguments: makePrepareArguments(),
101
96
  parameters: Type.Object({
102
97
  path: Type.String({
103
98
  description: "Path to the file to undo",
package/src/replace.ts CHANGED
@@ -11,9 +11,9 @@ import {
11
11
  restoreEndings,
12
12
  type LineEnding,
13
13
  } from "./replace-diff";
14
- import { readNormFile } from "./file-reader";
14
+ import { readNormFile, safeSnapId } from "./file-reader";
15
15
  import { normReq } from "./replace-normalize";
16
- import { isRec, rejectUnknownFields, abortIf, normalizeFilePath } from "./utils";
16
+ import { isRec, rejectUnknownFields, abortIf, makePrepareArguments } from "./utils";
17
17
  import { resolveTarget, writeAtomic } from "./fs-write";
18
18
  import { applyEdit,
19
19
  lineHashes,
@@ -26,7 +26,6 @@ import { applyEdit,
26
26
  type NEdit,
27
27
  } from "./hashline";
28
28
  import { toCwd } from "./paths";
29
- import { fileSnap } from "./file-reader";
30
29
  import {
31
30
  buildChanged,
32
31
  buildNoop,
@@ -47,7 +46,7 @@ import {
47
46
  import { loadP, loadGuide } from "./prompts";
48
47
  import { saveUndo } from "./replace-undo";
49
48
  import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
50
- import { getServed, recordServed, recordServedDiff } from "./served";
49
+ import { getServed, recordServedSafe, recordServedDiffSafe } from "./served";
51
50
 
52
51
  const replacementTextSchema = Type.String({
53
52
  description:
@@ -249,17 +248,9 @@ export async function execPipeline(
249
248
  } catch (error) {
250
249
  if (options?.noPersist !== true) {
251
250
  if (error instanceof RangeStaleError) {
252
- try {
253
- recordServed(hashStore, absolutePath, error.rangeHashes);
254
- } catch (recordError) {
255
- console.error("Failed to record served state from range-stale feedback:", recordError);
256
- }
251
+ await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback");
257
252
  } else if (error instanceof AnchorMismatchError) {
258
- try {
259
- recordServed(hashStore, absolutePath, error.feedbackHashes);
260
- } catch (recordError) {
261
- console.error("Failed to record served state from anchor-mismatch feedback:", recordError);
262
- }
253
+ await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback");
263
254
  }
264
255
  }
265
256
  throw error;
@@ -361,12 +352,7 @@ export function buildToolDef(): ToolDef {
361
352
  parameters,
362
353
  promptSnippet: E_SNIPPET,
363
354
  promptGuidelines: E_GUIDE,
364
- prepareArguments: (args: unknown) => {
365
- if (!isRec(args)) return args as any;
366
- const record = { ...args };
367
- normalizeFilePath(record);
368
- return record;
369
- },
355
+ prepareArguments: makePrepareArguments(),
370
356
  renderShell: "default",
371
357
  renderCall(args, theme, context) {
372
358
  const previewInput = getPreviewInput(args);
@@ -512,12 +498,7 @@ export function buildToolDef(): ToolDef {
512
498
 
513
499
  const editsAttempted = 1;
514
500
  if (originalNormalized === result) {
515
- let noopSnapshotId: string | undefined;
516
- try {
517
- noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
518
- } catch (error) {
519
- console.error("Failed to compute snapshot for noop edit:", error);
520
- }
501
+ const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
521
502
  return buildNoop({
522
503
  path,
523
504
  noopEdit,
@@ -561,12 +542,7 @@ export function buildToolDef(): ToolDef {
561
542
  await undo.restore();
562
543
  throw error;
563
544
  }
564
- let updatedSnapshotId: string | undefined;
565
- try {
566
- updatedSnapshotId = (await fileSnap(absolutePath)).snapshotId;
567
- } catch (error) {
568
- console.error("Failed to compute post-edit snapshot:", error);
569
- }
545
+ const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
570
546
 
571
547
  const editMeta: RMeta = {
572
548
  editsAttempted,
@@ -589,12 +565,7 @@ export function buildToolDef(): ToolDef {
589
565
  };
590
566
  const changed = buildChanged(successInput);
591
567
  if (changed.details.diff) {
592
- try {
593
- const store = await loadHashStore();
594
- recordServedDiff(store, mutationTargetPath, changed.details.diff);
595
- } catch (error) {
596
- console.error("Failed to record served state from post-edit diff:", error);
597
- }
568
+ await recordServedDiffSafe(mutationTargetPath, changed.details.diff, "post-edit diff");
598
569
  }
599
570
  return changed;
600
571
  });
package/src/served.ts CHANGED
@@ -1,5 +1,4 @@
1
- import type { HashStore } from "./hash-store";
2
- import { isValidHashList } from "./hash-store";
1
+ import { loadHashStore, parseHashList, type HashStore } from "./hash-store";
3
2
  import { HASH_CLASS } from "./hashline/alphabet";
4
3
 
5
4
  const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
@@ -16,17 +15,9 @@ export function servedHashesFromDiff(diff: string): string[] {
16
15
  export function getServed(store: HashStore, path: string): Set<string> | undefined {
17
16
  const row = store.stmts.servedGet(path);
18
17
  if (!row) return undefined;
19
- try {
20
- const parsed = JSON.parse(row.hashes as string);
21
- if (!isValidHashList(parsed)) {
22
- store.stmts.servedDelete(path);
23
- return undefined;
24
- }
25
- return new Set(parsed);
26
- } catch {
27
- store.stmts.servedDelete(path);
28
- return undefined;
29
- }
18
+ const parsed = parseHashList(row.hashes as string, () => store.stmts.servedDelete(path));
19
+ if (!parsed) return undefined;
20
+ return new Set(parsed);
30
21
  }
31
22
 
32
23
  export function recordServed(store: HashStore, path: string, hashes: string[]): void {
@@ -50,3 +41,26 @@ export function recordServedDiff(store: HashStore, path: string, diff: string):
50
41
  export function clearServed(store: HashStore, path: string): void {
51
42
  store.stmts.servedDelete(path);
52
43
  }
44
+
45
+ export async function recordServedSafe(
46
+ path: string,
47
+ hashes: string[],
48
+ context: string,
49
+ ): Promise<void> {
50
+ if (hashes.length === 0) return;
51
+ try {
52
+ const store = await loadHashStore();
53
+ recordServed(store, path, hashes);
54
+ } catch (error) {
55
+ console.error(`Failed to record served state (${context}):`, error);
56
+ }
57
+ }
58
+
59
+ export async function recordServedDiffSafe(
60
+ path: string,
61
+ diff: string,
62
+ context: string,
63
+ ): Promise<void> {
64
+ if (!diff) return;
65
+ await recordServedSafe(path, servedHashesFromDiff(diff), context);
66
+ }
package/src/utils.ts CHANGED
@@ -9,6 +9,15 @@ export function normalizeFilePath(record: Record<string, unknown>): void {
9
9
  }
10
10
  }
11
11
 
12
+ export function makePrepareArguments(): (args: unknown) => any {
13
+ return (args) => {
14
+ if (!isRec(args)) return args;
15
+ const record = { ...args };
16
+ normalizeFilePath(record);
17
+ return record;
18
+ };
19
+ }
20
+
12
21
  export function splitLines(text: string): string[] {
13
22
  if (text.length === 0) return [""];
14
23
  const lines = text.split("\n");
@@ -16,9 +25,7 @@ export function splitLines(text: string): string[] {
16
25
  }
17
26
 
18
27
  export function visLines(text: string): string[] {
19
- if (text.length === 0) return [];
20
- const lines = text.split("\n");
21
- return text.endsWith("\n") ? lines.slice(0, -1) : lines;
28
+ return text.length === 0 ? [] : splitLines(text);
22
29
  }
23
30
 
24
31
 
package/src/validation.ts CHANGED
@@ -36,5 +36,10 @@ export function valKind(file: LFile, path: string): asserts file is { kind: "tex
36
36
  if (file.kind === "image") {
37
37
  throw new Error(`[E_NOT_TEXT] Path is an image file: ${path}. Hashline edit only supports text files.`);
38
38
  }
39
+ if (file.kind === "too_large") {
40
+ throw new Error(
41
+ `[E_FILE_TOO_LARGE] File is too large: ${path} (${file.description}). Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
42
+ );
43
+ }
39
44
  }
40
45