pi-hashline-edit-pro 0.19.1 → 0.19.2

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/index.ts CHANGED
@@ -3,7 +3,7 @@ import { initHasher } from "./src/hashline";
3
3
  import { regReplace, regReplaceFlat } from "./src/replace";
4
4
  import { regReplaceUndo, clearUndo } from "./src/replace-undo";
5
5
  import { regRead, fmtReadPreview } from "./src/read";
6
- import { visLines } from "./src/utils";
6
+ import type { RMetrics } from "./src/replace-response";
7
7
  import { AUTO_READ_MAX } from "./src/constants";
8
8
  import { MAX_HASH_LINES } from "./src/hashline";
9
9
  import {
@@ -95,17 +95,17 @@ export default function (pi: ExtensionAPI): void {
95
95
  const filePath = (event.input as Record<string, unknown>)?.path;
96
96
  if (typeof filePath !== "string") return;
97
97
 
98
+ const metrics = (event.details as { metrics?: RMetrics } | undefined)?.metrics;
99
+ if (event.toolName !== "write" && metrics?.classification === "noop") return;
100
+
98
101
  try {
99
102
  const { normalized, fileHashes, absolutePath } = await readNormFile(
100
103
  filePath, ctx.cwd, { maxLines: MAX_HASH_LINES },
101
104
  );
102
- if (visLines(normalized).length === 0) return;
103
105
 
104
106
  const changedLines =
105
107
  event.toolName === "replace" || event.toolName === "undo_last_replace"
106
- ? (event.details as
107
- | { metrics?: { changed_lines?: { first: number; last: number } } }
108
- | undefined)?.metrics?.changed_lines
108
+ ? metrics?.changed_lines
109
109
  : undefined;
110
110
  let offset: number | undefined;
111
111
  let limit = AUTO_READ_MAX;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.19.1",
3
+ "version": "0.19.2",
4
4
  "type": "module",
5
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
6
6
  "main": "index.ts",
@@ -46,6 +46,7 @@
46
46
  "scripts": {
47
47
  "test": "vitest run",
48
48
  "test:watch": "vitest",
49
+ "test:coverage": "vitest run --coverage",
49
50
  "lint": "eslint 'src/**/*.ts' 'index.ts' 'test/**/*.ts'",
50
51
  "typecheck": "tsc --noEmit"
51
52
  },
@@ -53,6 +54,7 @@
53
54
  "@earendil-works/pi-coding-agent": "^0.74.0",
54
55
  "@eslint/js": "^10.0.1",
55
56
  "@types/node": "^24.0.0",
57
+ "@vitest/coverage-v8": "^4.1.10",
56
58
  "eslint": "^10.7.0",
57
59
  "typescript": "^5.8.0",
58
60
  "typescript-eslint": "^8.65.0",
@@ -1,2 +1,3 @@
1
1
  - `replace`: content_lines is a native JSON array of strings — never a serialized JSON string; strip the HASH│ prefix from read output and keep leading whitespace exactly as shown after │; no line numbers or diff markers.
2
- - `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file; on [E_STALE_ANCHOR], re-read the file and retry with fresh anchors.
2
+ - `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file; on [E_STALE_ANCHOR], re-read the file and retry with fresh anchors.
3
+ - `replace`: minimize the replaced range — anchor only the lines that actually change; for insertions use a single-line range (e.g. the line after the insertion point) instead of a whole block, so fewer unchanged lines must be reproduced byte-exact.
package/src/hash-store.ts CHANGED
@@ -53,7 +53,20 @@ function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
53
53
  "updated_at INTEGER NOT NULL" +
54
54
  ")"
55
55
  );
56
-
56
+ db.exec(
57
+ "CREATE TABLE IF NOT EXISTS meta (" +
58
+ "key TEXT PRIMARY KEY, " +
59
+ "value TEXT NOT NULL" +
60
+ ")"
61
+ );
62
+ const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
63
+ if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
64
+ db.exec("DELETE FROM snapshots");
65
+ }
66
+ db.prepare(
67
+ "INSERT INTO meta (key, value) VALUES ('version', ?) " +
68
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value"
69
+ ).run(String(HASH_STORE_VERSION));
57
70
  const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
58
71
  const allStmt = db.prepare("SELECT path FROM snapshots");
59
72
  const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
@@ -284,5 +297,3 @@ export async function pruneMissing(store: HashStore): Promise<void> {
284
297
  for (const path of missing) store.stmts.deleteOne(path);
285
298
  });
286
299
  }
287
-
288
- export { HASH_STORE_VERSION };
@@ -219,44 +219,6 @@ function assemble(
219
219
  return result;
220
220
  }
221
221
 
222
- export function fmtBoundaryWarning(params: {
223
- kind: "trailing" | "leading";
224
- survivingContent: string;
225
- matchIndex: number;
226
- resultLines: string[];
227
- resultHashes: string[];
228
- }): string {
229
- const header =
230
- params.kind === "trailing"
231
- ? "Boundary duplication (trailing): the last replacement line duplicated the next line. This happens when `content_lines` includes a line that was already outside the replaced range. Delete the duplicate — the original line outside the range is still there."
232
- : "Boundary duplication (leading): the first replacement line duplicated the previous line. This happens when `content_lines` includes a line that was already outside the replaced range. Delete the duplicate — the original line outside the range is still there.";
233
-
234
- let pairStart = -1;
235
- let bestDist = Infinity;
236
- for (let i = 0; i < params.resultLines.length - 1; i++) {
237
- if (
238
- params.resultLines[i] === params.survivingContent &&
239
- params.resultLines[i + 1] === params.survivingContent
240
- ) {
241
- const dist = Math.abs(i - params.matchIndex);
242
- if (dist < bestDist) {
243
- bestDist = dist;
244
- pairStart = i;
245
- }
246
- }
247
- }
248
- if (pairStart < 0) pairStart = params.matchIndex;
249
-
250
- const winStart = Math.max(0, pairStart - 2);
251
- const winEnd = Math.min(params.resultLines.length - 1, pairStart + 3);
252
-
253
- const rows: string[] = [];
254
- for (let i = winStart; i <= winEnd; i++) {
255
- rows.push(`${params.resultHashes[i]}${HASH_SEP}${params.resultLines[i]}`);
256
- }
257
- return `${header}\n\n${rows.join("\n")}`;
258
- }
259
-
260
222
  export function applyEdits(
261
223
  content: string,
262
224
  edits: HEdit[],
@@ -37,9 +37,6 @@ const HASH_TABLE: string[] = Array.from(
37
37
  (_, i) => idxToHash(i),
38
38
  );
39
39
 
40
- export const HL_PREFIX_RE = new RegExp(
41
- `^\\s*(?:>>>|>>)?\\s*${HASH_CLASS}│`,
42
- );
43
40
  export const HL_PREFIX_PLUS_RE = new RegExp(
44
41
  `^\\+\\s*${HASH_CLASS}│`,
45
42
  );
@@ -5,7 +5,6 @@ export {
5
5
  HASH_CLASS,
6
6
  HASH_SPACE,
7
7
  MAX_HASH_LINES,
8
- HL_PREFIX_RE,
9
8
  HL_PREFIX_PLUS_RE,
10
9
  HL_PREFIX_MINUS_RE,
11
10
  DIFF_MINUS_RE,
@@ -40,5 +39,4 @@ export {
40
39
  applyEdits,
41
40
  fmtRegion,
42
41
  changedRange,
43
- fmtBoundaryWarning,
44
42
  } from "./apply";
@@ -6,6 +6,7 @@ import {
6
6
  DIFF_MINUS_RE,
7
7
  } from "./hash";
8
8
  import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
9
+ import { clipLine } from "../utils";
9
10
 
10
11
  export type Anchor = { hash: string };
11
12
 
@@ -51,7 +52,7 @@ function assertNoPrefixes(lines: string[]): void {
51
52
  DIFF_MINUS_RE.test(line)
52
53
  ) {
53
54
  throw new Error(
54
- `[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like a diff preview row (e.g. +HASH│ or -HASH│): ${JSON.stringify(line)}. Use literal file content only — plain + or - lines are written literally.`
55
+ `[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like a diff preview row (e.g. +HASH│ or -HASH│): ${JSON.stringify(clipLine(line))}. Use literal file content only — plain + or - lines are written literally.`
55
56
  );
56
57
  }
57
58
  }
@@ -1,4 +1,4 @@
1
- import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty } from "../utils";
1
+ import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, clipLine } from "../utils";
2
2
  import { HL_BARE_PREFIX_RE } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
4
  import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
@@ -109,7 +109,7 @@ export function fmtMismatch(
109
109
  : "";
110
110
  const lines = sample
111
111
  .map((line) => {
112
- const content = fileLines[line - 1] ?? "";
112
+ const content = clipLine(fileLines[line - 1] ?? "");
113
113
  return ` ${line}: ${fileHashes[line - 1]}│${content}`;
114
114
  })
115
115
  .join("\n");
@@ -223,7 +223,7 @@ export function assertNoBarePrefix(
223
223
  const matched = suspects.filter((s) => fileHashSet.has(s.hash));
224
224
  const matchedCount = matched.length;
225
225
 
226
- const exampleLine = `${suspects[0]!.hash}│${suspects[0]!.line}`;
226
+ const exampleLine = `${suspects[0]!.hash}│${clipLine(suspects[0]!.line)}`;
227
227
 
228
228
  const linesHint =
229
229
  matchedCount === 0
@@ -1,6 +1,6 @@
1
1
  import type { ReplaceDetails } from "./replace";
2
2
  import { genDiff } from "./replace-diff";
3
- import { visLines } from "./utils";
3
+ import { visLines, clipLine } from "./utils";
4
4
 
5
5
  type TResult = {
6
6
  content: Array<{ type: "text"; text: string }>;
@@ -102,7 +102,7 @@ export function buildNoop(input: NoopInput): TResult {
102
102
  ? noopEdits
103
103
  .map(
104
104
  (edit) =>
105
- `Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}: ${edit.currentContent}`,
105
+ `Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}: ${clipLine(edit.currentContent)}`,
106
106
  )
107
107
  .join("\n")
108
108
  : "The edits produced identical content.";
package/src/utils.ts CHANGED
@@ -82,3 +82,8 @@ export function firstNonEmpty(lines: string[]): string | undefined {
82
82
  const idx = firstNonEmptyIndex(lines);
83
83
  return idx >= 0 ? lines[idx] : undefined;
84
84
  }
85
+
86
+ export function clipLine(line: string, maxLen = 200): string {
87
+ const flat = line.replace(/\n/g, "\\n");
88
+ return flat.length > maxLen ? `${flat.slice(0, maxLen)}...` : flat;
89
+ }