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 +5 -2
- package/index.ts +2 -0
- package/package.json +2 -2
- package/src/constants.ts +1 -0
- package/src/hashline/hash.ts +1 -0
- package/src/hashline/hasher.ts +1 -1
- package/src/read.ts +34 -5
- package/src/replace-undo.ts +6 -6
- package/src/replace.ts +3 -7
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 `[
|
|
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
|
|
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.
|
|
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
package/src/hashline/hash.ts
CHANGED
package/src/hashline/hasher.ts
CHANGED
|
@@ -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
|
|
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
|
|
93
|
-
|
|
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:
|
|
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) {
|
package/src/replace-undo.ts
CHANGED
|
@@ -101,16 +101,14 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
104
|
-
let
|
|
104
|
+
let currentRaw: string | undefined;
|
|
105
105
|
try {
|
|
106
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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);
|