pi-hashline-edit-pro 1.0.5 → 1.0.6

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
@@ -62,6 +62,8 @@ Optional parameters:
62
62
 
63
63
  Paged output ends with a continuation hint, e.g. `[Showing lines 1-50 of 120. Use offset=51 to continue.]`.
64
64
 
65
+ Lines up to 200KB are displayed in full; larger lines are replaced by a marker with a bash inspection hint (`sed -n 'Np' <path> | head -c 204800`) since hash anchors require full lines.
66
+
65
67
  Edge cases:
66
68
 
67
69
  - **Images** (JPEG, PNG, GIF, WebP) are passed through as visual attachments and don't participate in the hashline protocol.
@@ -93,7 +95,7 @@ Exactly one edit per call, with `hash_range_inclusive` and `content_lines` at th
93
95
  Behavior:
94
96
 
95
97
  - **Validation before any file I/O.** Unknown fields, missing fields, wrong types, and malformed anchors are rejected with `[E_BAD_SHAPE]` / `[E_BAD_REF]`. The edit applies against the pre-edit snapshot, so all hashes in the request come from one consistent file state.
96
- - **Rejected dialects.** The `changes` array dialect and the legacy `oldText`/`newText` dialect are rejected with `[E_BAD_SHAPE]` / `[E_LEGACY_SHAPE]`; the error tells you to send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
98
+ - **Rejected dialects.** The `changes` array dialect and the legacy `oldText`/`newText` dialect are rejected with `[E_LEGACY_SHAPE]`; the error tells you to send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
97
99
  - **Autocorrections** (all accompanied by a warning unless noted):
98
100
  - A `HASH│` prefix accidentally left on a `content_lines` entry is stripped.
99
101
  - Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `content_lines` have their markers stripped. Numbered deletion rows (`-1 foo`) and unified-diff lines are written literally — never silently altered.
@@ -120,6 +122,7 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
120
122
 
121
123
  - After `replace` / `undo_last_replace`, the block covers the changed span plus 2 lines of context above and below — the rest of the file keeps its anchors from the persistent store.
122
124
  - After `write`, the block dumps from the top of the file. For files over 2000 lines, the dump is truncated with a pagination hint — use `read` with `offset` to continue.
125
+ - Auto-read keeps a 50KB display budget: lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
123
126
  - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
124
127
  - If the auto-read itself fails (e.g. the file was deleted between the operation and the read), a short `--- Auto-read failed: ... ---` notice is appended instead of the anchor block, so the model knows the anchors are missing.
125
128
 
@@ -157,7 +160,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
157
160
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
158
161
  | `[E_INVALID_PATCH]` | A `content_lines` entry is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
159
162
  | `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
160
- | `[E_LEGACY_SHAPE]` | The request uses the unsupported `oldText`/`newText` dialect. |
163
+ | `[E_LEGACY_SHAPE]` | The request uses an unsupported dialect: `oldText`/`newText` fields or a `changes` array. |
161
164
  | `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
162
165
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
163
166
  | `[E_NOT_FOUND]` | The path does not exist. |
package/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
2
3
  import { initHasher } from "./src/hashline";
3
4
  import { regReplace } from "./src/replace";
4
5
  import { regReplaceUndo, clearUndo } from "./src/replace-undo";
@@ -98,6 +99,7 @@ export default function (pi: ExtensionAPI): void {
98
99
  { offset, limit },
99
100
  fileHashes,
100
101
  absolutePath,
102
+ DEFAULT_MAX_BYTES,
101
103
  );
102
104
 
103
105
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "type": "module",
5
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
6
6
  "main": "index.ts",
@@ -47,7 +47,7 @@
47
47
  "scripts": {
48
48
  "test": "vitest run",
49
49
  "test:watch": "vitest",
50
- "test:coverage": "vitest run --coverage",
50
+ "test:coverage": "vitest run --coverage --coverage.thresholds.lines=90 --coverage.thresholds.statements=90 --coverage.thresholds.functions=85 --coverage.thresholds.branches=80",
51
51
  "lint": "eslint \"src/**/*.ts\" \"index.ts\" \"test/**/*.ts\"",
52
52
  "typecheck": "tsc --noEmit"
53
53
  },
package/src/constants.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export const AUTO_READ_MAX = 2000;
2
2
  export const SNIFF_BYTES = 8192;
3
3
  export const MAX_BYTES = 100 * 1024 * 1024;
4
+ export const MAX_READ_LINE_BYTES = 200 * 1024;
4
5
 
5
6
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
6
7
  export const HASH_STORE_VERSION = 4;
@@ -143,6 +143,7 @@ export async function lineHashes(
143
143
  store?: HashStore,
144
144
  persist?: boolean,
145
145
  ): Promise<string[]> {
146
+ await initHasher();
146
147
  if (!path) {
147
148
  return _lineHashesPure(content);
148
149
  }
@@ -9,7 +9,7 @@ let hasher: Hasher | null = null;
9
9
 
10
10
  export function getH(): Hasher {
11
11
  if (hasher) return hasher;
12
- throw new Error("xxhash-wasm not initialized yet. This should not happen.");
12
+ throw new Error("xxhash-wasm hasher not initialized; await initHasher() before calling hashline APIs.");
13
13
  }
14
14
 
15
15
  const hasherP: Promise<Hasher> = xxhash().then((h) => {
package/src/read.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type TruncationResult,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { Type } from "typebox";
9
+ import { MAX_READ_LINE_BYTES } from "./constants";
9
10
  import { loadFileKindAndText } from "./file-kind";
10
11
  import { readNormFile } from "./file-reader";
11
12
  import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
@@ -58,6 +59,7 @@ export async function fmtReadPreview(
58
59
  options: { offset?: number; limit?: number },
59
60
  precomputedHashes?: string[],
60
61
  path?: string,
62
+ maxLineBytes = MAX_READ_LINE_BYTES,
61
63
  ): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number }> {
62
64
  const allLines = visLines(text);
63
65
  const totalLines = allLines.length;
@@ -88,15 +90,42 @@ export async function fmtReadPreview(
88
90
  const allHashes = precomputedHashes ?? await (path ? lineHashes(text, path) : lineHashes(text));
89
91
  const selectedHashes = allHashes.slice(startLine - 1, endIdx);
90
92
  const formatted = fmtRegion(selectedHashes, selected);
91
-
92
- const truncation = truncateHead(formatted);
93
- if (truncation.firstLineExceedsLimit) {
93
+ const maxBytes = maxLineBytes;
94
+ const rowSizes = selected.map((line, index) => ({
95
+ lineNumber: startLine + index,
96
+ bytes: Buffer.byteLength(`${selectedHashes[index]}${HASH_SEP}${line}`, "utf-8"),
97
+ }));
98
+ if (rowSizes.some((row) => row.bytes > maxBytes)) {
99
+ const oversized = rowSizes.filter((row) => row.bytes > maxBytes);
100
+ const rows = rowSizes.map((row, index) =>
101
+ row.bytes > maxBytes
102
+ ? `[Line ${row.lineNumber} is ${formatSize(row.bytes)}, exceeds ${formatSize(maxBytes)}; content not shown. Use bash: sed -n '${row.lineNumber}p' <path> | head -c ${maxBytes}]`
103
+ : fmtRegion([selectedHashes[index]!], [selected[index]!]),
104
+ );
105
+ const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes });
106
+ const shownRows = rowSizes.filter((row) => row.bytes <= maxBytes);
107
+ const lastShownLine = shownRows.at(-1)?.lineNumber ?? startLine - 1;
108
+ const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
109
+ const verb = oversized.length === 1 ? "exceeds" : "exceed";
110
+ const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
111
+ const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown because hashline anchors require full lines. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}]`;
112
+ let preview = skippedTruncation.content;
113
+ let nextOffset: number | undefined;
114
+ if (shownRows.length > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
115
+ nextOffset = lastShownLine + 1;
116
+ preview += `\n\n${warning}\n${formatPaginationHint(startLine, lastShownLine, totalLines, nextOffset, skippedTruncation.truncated ? skippedTruncation.maxBytes : undefined)}`;
117
+ } else {
118
+ preview += `\n\n${warning}`;
119
+ }
94
120
  return {
95
- text: `[Line ${startLine} exceeds ${formatSize(truncation.maxBytes)}. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`,
96
- truncation,
121
+ text: preview,
122
+ truncation: skippedTruncation.truncated ? skippedTruncation : undefined,
123
+ ...(nextOffset !== undefined ? { nextOffset } : {}),
97
124
  };
98
125
  }
99
126
 
127
+ const truncation = truncateHead(formatted, { maxBytes });
128
+
100
129
  let preview = truncation.content;
101
130
  let nextOffset: number | undefined;
102
131
  if (truncation.truncated) {
@@ -101,16 +101,14 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
101
101
  }
102
102
 
103
103
  return withFileMutationQueue(mutationTargetPath, async () => {
104
- let currentNormalized: string | undefined;
104
+ let currentRaw: string | undefined;
105
105
  try {
106
- const currentRaw = await readFile(mutationTargetPath, "utf-8");
107
- const { text: currentStripped } = stripBOM(currentRaw);
108
- currentNormalized = toLF(currentStripped);
106
+ currentRaw = await readFile(mutationTargetPath, "utf-8");
109
107
  } catch (error) {
110
108
  if (errCode(error) !== "ENOENT") throw error;
111
109
  }
112
110
 
113
- if (currentNormalized === undefined) {
111
+ if (currentRaw === undefined) {
114
112
  return {
115
113
  content: [
116
114
  {
@@ -122,7 +120,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
122
120
  details: {},
123
121
  };
124
122
  }
125
- if (currentNormalized !== undo.resultContent) {
123
+ if (currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)) {
126
124
  return {
127
125
  content: [
128
126
  {
@@ -135,6 +133,8 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
135
133
  };
136
134
  }
137
135
 
136
+ const { text: currentStripped } = stripBOM(currentRaw);
137
+ const currentNormalized = toLF(currentStripped);
138
138
  const diffResult = genDiff(undo.content, currentNormalized, 0);
139
139
  const linesAddedByReplace = cntDiff(diffResult.diff, "+");
140
140
  const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
package/src/replace.ts CHANGED
@@ -101,7 +101,7 @@ interface PipelineResult {
101
101
 
102
102
  const ROOT_KS = new Set(["path", "content_lines", "hash_range_inclusive"]);
103
103
 
104
- const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"];
104
+ const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines", "changes"];
105
105
 
106
106
  export function assertNoLegacyKeys(request: unknown): void {
107
107
  if (!isRec(request)) return;
@@ -251,11 +251,6 @@ export async function compPreview(
251
251
  ): Promise<RPreview> {
252
252
  try {
253
253
  const normalized = normReq(request);
254
- if (isRec(request) && Array.isArray(request.changes)) {
255
- return {
256
- error: `[E_BAD_SHAPE] The replace tool does not accept a "changes" array. Send hash_range_inclusive and content_lines at the top level (one edit per call).`
257
- };
258
- }
259
254
  assertReq(normalized);
260
255
  const { path, originalNormalized, result, resultHashes } = await execPipeline(
261
256
  normalized,
@@ -410,8 +405,9 @@ export function buildToolDef(): ToolDef {
410
405
 
411
406
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
412
407
  const canonical = normReq(params);
408
+ assertReq(canonical);
413
409
 
414
- const normalizedParams = canonical as ReqParams;
410
+ const normalizedParams = canonical;
415
411
  const path = normalizedParams.path;
416
412
  const absolutePath = toCwd(path, ctx.cwd);
417
413
  const mutationTargetPath = await resolveTarget(absolutePath);