pi-hashline-edit-pro 2.2.1 → 2.3.0

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
@@ -91,9 +91,10 @@ One edit per call, with `hash_bounds` and `new_content` at the top level:
91
91
  Notes:
92
92
 
93
93
  - The request is checked before any file I/O, so a bad request never touches the file.
94
- - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `new_content` or `hash_bounds`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that duplicate unique lines adjacent to the range are stripped automatically — consecutive duplicates are stripped as a run, so re-including a whole unchanged block next to the range never duplicates it. `file_path` works as an alias for `path` in all three tools.
94
+ - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `new_content` or `hash_bounds`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
95
95
  - An edit that produces identical content reports `No changes made` and leaves the anchors alone.
96
96
  - After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
97
+ - Do not issue multiple replace calls on the same file in one message; parallel edits split attention across the post-edit diffs and removed lines are easy to miss. Verify each diff before the next edit on that file.
97
98
 
98
99
  ## Undo
99
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
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",
@@ -4,3 +4,4 @@
4
4
  - `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
5
5
  - `replace`: every `\n` in new_content separates lines, so a trailing `\n` adds a final empty line. Mirror the replaced range's lines exactly: a range that ends on a blank line must end new_content with `\n` (e.g. `"code\n"`), and a replacement whose last line is not blank must not end with `\n`. To add a blank line after a line, end new_content with an explicit empty line after it (e.g. `"X\n"` adds a blank after X). A replacement that is only blank lines is written as one `\n` per blank line (e.g. `"\n"` is a single blank line).
6
6
  - `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file — `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading. `-HASH│` rows show removed lines with their old hashes; those hashes are stale after the edit.
7
+ - `replace`: do not issue multiple replace calls on the same file in one message — parallel edits on one file split attention across the diffs, and removed lines are easy to miss. Issue the next edit only after verifying the previous diff.
@@ -1,2 +1,2 @@
1
- - `undo_last_replace`: reverts only the most recent replace on the file — any write to the file clears the undo history, so call it immediately after a bad replace.
1
+ - `undo_last_replace`: reverts only the most recent replace on the file — any write to the file clears the undo history, so call it immediately after a bad replace. A replace is bad when its post-edit diff shows `-HASH│` rows for lines you meant to keep (a closing brace, import, or declaration).
2
2
  - `undo_last_replace`: when auto-read shows the post-edit diff, its `+HASH│` and ` HASH│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading. `-HASH│` rows show removed lines with their old hashes; those hashes are stale after the undo.
package/src/hash-store.ts CHANGED
@@ -11,6 +11,7 @@ type SqlParams = (string | number)[];
11
11
  interface Prepared {
12
12
  get: (...params: SqlParams) => Record<string, unknown> | undefined;
13
13
  allPaths: (...params: SqlParams) => Record<string, unknown>[];
14
+ allHashes: (...params: SqlParams) => Record<string, unknown>[];
14
15
  deleteOne: (...params: SqlParams) => void;
15
16
  upsert: (...params: SqlParams) => void;
16
17
  undoUpsert: (...params: SqlParams) => void;
@@ -159,6 +160,7 @@ function buildStore(
159
160
  ).run(String(HASH_STORE_VERSION));
160
161
  const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
161
162
  const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo");
163
+ const allHashesStmt = db.prepare("SELECT path, hashes FROM snapshots");
162
164
  const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
163
165
  const upsertStmt = db.prepare(
164
166
  "INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
@@ -175,6 +177,7 @@ function buildStore(
175
177
  const stmts: Prepared = {
176
178
  get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
177
179
  allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
180
+ allHashes: (...params) => allHashesStmt.all(...params) as Record<string, unknown>[],
178
181
  deleteOne: (...params) => { withBusyRetry(() => { delStmt.run(...params); }); },
179
182
  upsert: (...params) => { withBusyRetry(() => { upsertStmt.run(...params); }); },
180
183
  undoUpsert: (...params) => { withBusyRetry(() => { undoUpsertStmt.run(...params); }); },
@@ -459,3 +462,18 @@ export async function pruneMissing(store: HashStore): Promise<void> {
459
462
  }
460
463
  });
461
464
  }
465
+
466
+ export function findSnapshotPaths(store: HashStore, hashes: string[]): string[] {
467
+ const rows = store.stmts.allHashes() as { path: string; hashes: string }[];
468
+ const matches: string[] = [];
469
+ for (const row of rows) {
470
+ try {
471
+ const parsed = JSON.parse(row.hashes) as unknown;
472
+ if (!isValidHashList(parsed)) continue;
473
+ if (hashes.every((h) => parsed.includes(h))) matches.push(row.path);
474
+ } catch {
475
+ continue;
476
+ }
477
+ }
478
+ return matches;
479
+ }
@@ -315,22 +315,41 @@ function leadingDups(
315
315
  return dups;
316
316
  }
317
317
 
318
+ function sectionIsUnique(
319
+ canonLines: string[],
320
+ start: number,
321
+ length: number,
322
+ ): boolean {
323
+ let count = 0;
324
+ for (let i = 0; i + length <= canonLines.length; i++) {
325
+ let k = 0;
326
+ while (k < length && canonLines[i + k] === canonLines[start + k]) k++;
327
+ if (k < length) continue;
328
+ count++;
329
+ if (count > 1) return false;
330
+ }
331
+ return true;
332
+ }
333
+
318
334
  function firstNewAfterDups(
319
335
  contentLines: string[],
320
336
  rangeLines: string[],
321
- fileLines: string[],
337
+ canonLines: string[],
322
338
  endLine: number,
323
- fileCounts: Map<string, number>,
324
339
  ): BDup[] {
325
340
  const firstNew = findNewEdge(contentLines, rangeLines, false);
326
341
  if (!firstNew) return [];
342
+ const maxK = Math.min(contentLines.length - firstNew.index, canonLines.length - endLine);
343
+ let runLen = 0;
344
+ while (
345
+ runLen < maxK &&
346
+ canon(contentLines[firstNew.index + runLen]!) === canonLines[endLine + runLen]!
347
+ ) {
348
+ runLen++;
349
+ }
350
+ if (runLen === 0 || !sectionIsUnique(canonLines, endLine, runLen)) return [];
327
351
  const dups: BDup[] = [];
328
- const maxK = Math.min(contentLines.length - firstNew.index, fileLines.length - endLine);
329
- for (let k = 0; k < maxK; k++) {
330
- const newLine = contentLines[firstNew.index + k]!;
331
- const fileLine = fileLines[endLine + k]!;
332
- if (canon(newLine) !== canon(fileLine)) break;
333
- if ((fileCounts.get(canon(fileLine)) ?? 0) !== 1) break;
352
+ for (let k = 0; k < runLen; k++) {
334
353
  dups.push({ kind: "first-new-after", replacementLineIndex: firstNew.index + k });
335
354
  }
336
355
  return dups;
@@ -339,19 +358,24 @@ function firstNewAfterDups(
339
358
  function lastNewBeforeDups(
340
359
  contentLines: string[],
341
360
  rangeLines: string[],
342
- fileLines: string[],
361
+ canonLines: string[],
343
362
  startLine: number,
344
- fileCounts: Map<string, number>,
345
363
  ): BDup[] {
346
364
  const lastNew = findNewEdge(contentLines, rangeLines, true);
347
365
  if (!lastNew) return [];
348
- const dups: BDup[] = [];
349
366
  const maxK = Math.min(lastNew.index + 1, startLine - 1);
350
- for (let k = 0; k < maxK; k++) {
351
- const newLine = contentLines[lastNew.index - k]!;
352
- const fileLine = fileLines[startLine - 2 - k]!;
353
- if (canon(newLine) !== canon(fileLine)) break;
354
- if ((fileCounts.get(canon(fileLine)) ?? 0) !== 1) break;
367
+ let runLen = 0;
368
+ while (
369
+ runLen < maxK &&
370
+ canon(contentLines[lastNew.index - runLen]!) === canonLines[startLine - 2 - runLen]!
371
+ ) {
372
+ runLen++;
373
+ }
374
+ if (runLen === 0) return [];
375
+ const sectionStart = startLine - 1 - runLen;
376
+ if (!sectionIsUnique(canonLines, sectionStart, runLen)) return [];
377
+ const dups: BDup[] = [];
378
+ for (let k = 0; k < runLen; k++) {
355
379
  dups.push({ kind: "last-new-before", replacementLineIndex: lastNew.index - k });
356
380
  }
357
381
  return dups;
@@ -436,12 +460,12 @@ export function valEdit(
436
460
  }
437
461
  const endLine = endResolved.line;
438
462
  const rangeLines = fileLines.slice(startResolved.line - 1, endLine);
439
- const fileCounts = canonCounts(fileLines);
463
+ const canonLines = fileLines.map((line) => canon(line));
440
464
  boundaryDups.push(
441
465
  ...trailingDups(edit.content_lines, fileLines, endLine),
442
466
  ...leadingDups(edit.content_lines, fileLines, startResolved.line),
443
- ...firstNewAfterDups(edit.content_lines, rangeLines, fileLines, endLine, fileCounts),
444
- ...lastNewBeforeDups(edit.content_lines, rangeLines, fileLines, startResolved.line, fileCounts),
467
+ ...firstNewAfterDups(edit.content_lines, rangeLines, canonLines, endLine),
468
+ ...lastNewBeforeDups(edit.content_lines, rangeLines, canonLines, startResolved.line),
445
469
  );
446
470
 
447
471
  return {
package/src/replace.ts CHANGED
@@ -18,6 +18,7 @@ import { resolveTarget, writeAtomic } from "./fs-write";
18
18
  import { applyEdit,
19
19
  lineHashes,
20
20
  resEdit,
21
+ parseHashRef,
21
22
  MAX_HASH_LINES,
22
23
  type HEdit,
23
24
  type NEdit,
@@ -43,7 +44,7 @@ import {
43
44
  } from "./replace-render";
44
45
  import { loadP, loadGuide } from "./prompts";
45
46
  import { saveUndo } from "./replace-undo";
46
- import { loadHashStore, type HashStore } from "./hash-store";
47
+ import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
47
48
 
48
49
  const newContentSchema = Type.String({
49
50
  description:
@@ -61,7 +62,7 @@ const hashBoundsSchema = Type.Array(
61
62
 
62
63
  export const editToolSchema = Type.Object(
63
64
  {
64
- path: Type.String({ description: "Path to edit" }),
65
+ path: Type.Optional(Type.String({ description: "Path to edit. Required — always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake." })),
65
66
  hash_bounds: hashBoundsSchema,
66
67
  new_content: newContentSchema,
67
68
  },
@@ -122,6 +123,42 @@ export function assertReq(
122
123
  }
123
124
  }
124
125
 
126
+ async function resolveMissingPath(
127
+ request: Record<string, unknown>,
128
+ ): Promise<{ path: string; warning: string } | undefined> {
129
+ if (typeof request.path === "string") return undefined;
130
+ const bounds = request.hash_bounds;
131
+ if (!Array.isArray(bounds) || bounds.length !== 2) return undefined;
132
+ const hashes: string[] = [];
133
+ for (const ref of bounds) {
134
+ if (typeof ref !== "string") return undefined;
135
+ try {
136
+ hashes.push(parseHashRef(ref).hash);
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ let store: HashStore;
142
+ try {
143
+ store = await loadHashStore();
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ const matches = findSnapshotPaths(store, hashes);
148
+ if (matches.length === 1) {
149
+ return {
150
+ path: matches[0]!,
151
+ warning: `[E_BAD_SHAPE] Autocorrected: missing "path" resolved to ${matches[0]} — the only file whose stored hashes contain both anchors.`,
152
+ };
153
+ }
154
+ if (matches.length > 1) {
155
+ throw new Error(
156
+ `[E_BAD_SHAPE] Edit request requires a non-empty "path" string; the anchors match multiple known files: ${matches.join(", ")}. Include the intended path.`,
157
+ );
158
+ }
159
+ return undefined;
160
+ }
161
+
125
162
  export interface ExecPipelineOptions {
126
163
  accessMode?: number;
127
164
  signal?: AbortSignal;
@@ -405,6 +442,10 @@ export function buildToolDef(): ToolDef {
405
442
 
406
443
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
407
444
  const canonical = normReq(params);
445
+ const resolution = isRec(canonical) ? await resolveMissingPath(canonical) : undefined;
446
+ if (resolution && isRec(canonical)) {
447
+ canonical.path = resolution.path;
448
+ }
408
449
  assertReq(canonical);
409
450
 
410
451
  const normalizedParams = canonical;
@@ -434,6 +475,10 @@ export function buildToolDef(): ToolDef {
434
475
  { accessMode: constants.R_OK | constants.W_OK, signal },
435
476
  );
436
477
 
478
+ if (resolution) {
479
+ warnings.unshift(resolution.warning);
480
+ }
481
+
437
482
  const editsAttempted = 1;
438
483
  if (originalNormalized === result) {
439
484
  let noopSnapshotId: string | undefined;