pi-hashline-edit-pro 1.0.4 → 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 +10 -5
- package/index.ts +10 -1
- package/package.json +2 -2
- package/prompts/read.md +1 -1
- package/src/constants.ts +1 -0
- package/src/file-kind.ts +27 -0
- package/src/file-reader.ts +2 -2
- package/src/hash-store.ts +77 -3
- package/src/hashline/hash.ts +1 -0
- package/src/hashline/hasher.ts +1 -1
- package/src/read.ts +34 -5
- package/src/replace-diff.ts +12 -5
- package/src/replace-undo.ts +54 -20
- package/src/replace.ts +11 -9
package/README.md
CHANGED
|
@@ -62,10 +62,13 @@ 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.
|
|
68
70
|
- **Binary and directory paths** are rejected with a descriptive error.
|
|
71
|
+
- **UTF-16/UTF-32 encoded text** (detected via BOM) is rejected with `[E_NOT_TEXT]` — editing such a file would decode it as `U+FFFD` garbage and rewrite it as corrupted UTF-8.
|
|
69
72
|
- **Empty files** are returned as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
|
|
70
73
|
- **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
74
|
- **Files over 238,328 lines** are rejected with `[E_FILE_TOO_LARGE]` (see [Hashing](#hashing)).
|
|
@@ -92,7 +95,7 @@ Exactly one edit per call, with `hash_range_inclusive` and `content_lines` at th
|
|
|
92
95
|
Behavior:
|
|
93
96
|
|
|
94
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.
|
|
95
|
-
- **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: [...]}`.
|
|
96
99
|
- **Autocorrections** (all accompanied by a warning unless noted):
|
|
97
100
|
- A `HASH│` prefix accidentally left on a `content_lines` entry is stripped.
|
|
98
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.
|
|
@@ -119,14 +122,16 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
|
|
|
119
122
|
|
|
120
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.
|
|
121
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).
|
|
122
126
|
- Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
|
|
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.
|
|
123
128
|
|
|
124
129
|
## Undo
|
|
125
130
|
|
|
126
131
|
`undo_last_replace` reverts the most recent successful `replace` on a file, restoring the exact previous content — BOM and line endings included — and the previous anchors.
|
|
127
132
|
|
|
128
133
|
- History is per-file and single-level: only the most recent replace can be reverted.
|
|
129
|
-
- History is in-
|
|
134
|
+
- History is persisted in the hash store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) and survives session restarts; a failed `write` does not clear it.
|
|
130
135
|
- A successful `write` clears the history for that file.
|
|
131
136
|
- Call `read` after an undo to get fresh anchors for follow-up edits.
|
|
132
137
|
- **Safety guard.** If the file was modified or deleted since the last replace, `undo_last_replace` refuses with `[E_UNDO_STALE]` rather than overwriting those changes.
|
|
@@ -155,12 +160,12 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
|
|
|
155
160
|
| `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
|
|
156
161
|
| `[E_INVALID_PATCH]` | A `content_lines` entry is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
|
|
157
162
|
| `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
|
|
158
|
-
| `[E_LEGACY_SHAPE]` | The request uses
|
|
163
|
+
| `[E_LEGACY_SHAPE]` | The request uses an unsupported dialect: `oldText`/`newText` fields or a `changes` array. |
|
|
159
164
|
| `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
|
|
160
165
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
161
166
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
162
167
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
163
|
-
| `[E_NOT_TEXT]` | The path is a directory, binary file, or
|
|
168
|
+
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
|
164
169
|
| `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
|
|
165
170
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
|
|
166
171
|
|
|
@@ -176,7 +181,7 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
|
|
|
176
181
|
|
|
177
182
|
- **Stale anchors fail, per line.** A hash mismatch means that line's content changed since the last `read`. The error says so and, when only one anchor of a pair is stale, shows the current lines around the still-valid anchor so the range can be re-located without a full re-read. Mismatched anchors are never silently relocated to a "close enough" line — correctness over convenience.
|
|
178
183
|
- **Autocorrection only when the intent is unambiguous**, and always visible: hash-prefix and diff-row stripping produce a warning; the boundary-duplication fix is silent because the duplicate never reaches the file. Literal content is never silently altered when the intent is ambiguous (numbered deletion rows and unified-diff lines are written verbatim).
|
|
179
|
-
- **Byte-exact preservation.** UTF-8 BOMs, CRLF
|
|
184
|
+
- **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo.
|
|
180
185
|
- **Atomic and ordered writes.** Files are written via temp-file-then-rename; symlink chains are resolved so the target is updated without replacing the symlink; hard-linked files are updated in place; concurrent edits to the same underlying file serialize through a per-target mutation queue.
|
|
181
186
|
- **One edit per call.** The request shape stays `{path, hash_range_inclusive, content_lines}` from schema through validation to application; there is no batching dialect.
|
|
182
187
|
|
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";
|
|
@@ -59,7 +60,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
59
60
|
const writtenPath = (event.input as Record<string, unknown>)?.path;
|
|
60
61
|
if (typeof writtenPath === "string") {
|
|
61
62
|
try {
|
|
62
|
-
clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
|
|
63
|
+
await clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
|
|
63
64
|
} catch (error) {
|
|
64
65
|
console.error("Failed to clear undo after write:", error);
|
|
65
66
|
}
|
|
@@ -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 {
|
|
@@ -108,6 +110,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
108
110
|
};
|
|
109
111
|
} catch (error) {
|
|
110
112
|
console.error("Auto-read after write/replace failed:", error);
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
+
return {
|
|
115
|
+
content: [
|
|
116
|
+
...(event.content ?? []),
|
|
117
|
+
{ type: "text", text: `\n\n--- Auto-read failed: ${message} ---` },
|
|
118
|
+
],
|
|
119
|
+
};
|
|
111
120
|
}
|
|
112
121
|
});
|
|
113
122
|
}
|
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/prompts/read.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file; each line returned as HASH│content with a 3-char alphanumeric hash. No line numbers — use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
|
|
1
|
+
Read a text file; each line returned as HASH│content with a 3-char alphanumeric hash. No line numbers — use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; UTF-16/UTF-32 (BOM) → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
|
package/src/constants.ts
CHANGED
package/src/file-kind.ts
CHANGED
|
@@ -15,6 +15,26 @@ const TEXT_TYPES = new Set<string>([
|
|
|
15
15
|
"application/x-ms-regedit",
|
|
16
16
|
]);
|
|
17
17
|
|
|
18
|
+
function detectTextBom(sample: Uint8Array): string | undefined {
|
|
19
|
+
if (
|
|
20
|
+
sample.length >= 4 &&
|
|
21
|
+
sample[0] === 0xff &&
|
|
22
|
+
sample[1] === 0xfe &&
|
|
23
|
+
sample[2] === 0x00 &&
|
|
24
|
+
sample[3] === 0x00
|
|
25
|
+
) return "UTF-32LE";
|
|
26
|
+
if (
|
|
27
|
+
sample.length >= 4 &&
|
|
28
|
+
sample[0] === 0x00 &&
|
|
29
|
+
sample[1] === 0x00 &&
|
|
30
|
+
sample[2] === 0xfe &&
|
|
31
|
+
sample[3] === 0xff
|
|
32
|
+
) return "UTF-32BE";
|
|
33
|
+
if (sample.length >= 2 && sample[0] === 0xff && sample[1] === 0xfe) return "UTF-16LE";
|
|
34
|
+
if (sample.length >= 2 && sample[0] === 0xfe && sample[1] === 0xff) return "UTF-16BE";
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
function isTextType(mimeType: string): boolean {
|
|
19
39
|
return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
|
|
20
40
|
}
|
|
@@ -66,6 +86,13 @@ export async function loadFileKindAndText(
|
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
const sample = buffer.subarray(0, bytesRead);
|
|
89
|
+
const textBom = detectTextBom(sample);
|
|
90
|
+
if (textBom) {
|
|
91
|
+
return {
|
|
92
|
+
kind: "binary",
|
|
93
|
+
description: `${textBom} encoded text`
|
|
94
|
+
};
|
|
95
|
+
}
|
|
69
96
|
const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
|
|
70
97
|
if (
|
|
71
98
|
detectedMimeType !== undefined &&
|
package/src/file-reader.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { lineHashes } from "./hashline";
|
|
|
4
4
|
import { loadFileKindAndText, type LFile } from "./file-kind";
|
|
5
5
|
import { resolveTarget } from "./fs-write";
|
|
6
6
|
import { toCwd } from "./paths";
|
|
7
|
-
import { detectEnding, toLF, stripBOM } from "./replace-diff";
|
|
7
|
+
import { detectEnding, toLF, stripBOM, type LineEnding } from "./replace-diff";
|
|
8
8
|
import { abortIf } from "./utils";
|
|
9
9
|
import { valKind, valAccess } from "./validation";
|
|
10
10
|
import { visLines } from "./utils";
|
|
@@ -13,7 +13,7 @@ export interface NormFile {
|
|
|
13
13
|
absolutePath: string;
|
|
14
14
|
normalized: string;
|
|
15
15
|
bom: string;
|
|
16
|
-
originalEnding:
|
|
16
|
+
originalEnding: LineEnding;
|
|
17
17
|
fileHashes: string[];
|
|
18
18
|
hadUtf8DecodeErrors: boolean;
|
|
19
19
|
}
|
package/src/hash-store.ts
CHANGED
|
@@ -12,6 +12,9 @@ interface Prepared {
|
|
|
12
12
|
allPaths: (...params: SqlParams) => Record<string, unknown>[];
|
|
13
13
|
deleteOne: (...params: SqlParams) => void;
|
|
14
14
|
upsert: (...params: SqlParams) => void;
|
|
15
|
+
undoUpsert: (...params: SqlParams) => void;
|
|
16
|
+
undoGet: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
17
|
+
undoDelete: (...params: SqlParams) => void;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export interface HashStore {
|
|
@@ -19,6 +22,14 @@ export interface HashStore {
|
|
|
19
22
|
readonly engine: "node:sqlite";
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
export interface UndoRecord {
|
|
26
|
+
content: string;
|
|
27
|
+
bom: string;
|
|
28
|
+
ending: string;
|
|
29
|
+
hashes: string[];
|
|
30
|
+
resultContent: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
interface LegacySnapshot {
|
|
23
34
|
content: string;
|
|
24
35
|
hashes: string[];
|
|
@@ -72,27 +83,49 @@ function buildStore(
|
|
|
72
83
|
"value TEXT NOT NULL" +
|
|
73
84
|
")"
|
|
74
85
|
);
|
|
86
|
+
db.exec(
|
|
87
|
+
"CREATE TABLE IF NOT EXISTS undo (" +
|
|
88
|
+
"path TEXT PRIMARY KEY, " +
|
|
89
|
+
"content TEXT NOT NULL, " +
|
|
90
|
+
"bom TEXT NOT NULL, " +
|
|
91
|
+
"ending TEXT NOT NULL, " +
|
|
92
|
+
"hashes TEXT NOT NULL, " +
|
|
93
|
+
"result_content TEXT NOT NULL, " +
|
|
94
|
+
"updated_at INTEGER NOT NULL" +
|
|
95
|
+
")"
|
|
96
|
+
);
|
|
75
97
|
const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
|
|
76
98
|
if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
|
|
77
99
|
db.exec("DELETE FROM snapshots");
|
|
100
|
+
db.exec("DELETE FROM undo");
|
|
78
101
|
}
|
|
79
102
|
db.prepare(
|
|
80
103
|
"INSERT INTO meta (key, value) VALUES ('version', ?) " +
|
|
81
104
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
82
105
|
).run(String(HASH_STORE_VERSION));
|
|
83
106
|
const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
|
|
84
|
-
const allStmt = db.prepare("SELECT path FROM snapshots");
|
|
107
|
+
const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo");
|
|
85
108
|
const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
|
|
86
109
|
const upsertStmt = db.prepare(
|
|
87
110
|
"INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
|
|
88
111
|
"ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at"
|
|
89
112
|
);
|
|
90
|
-
|
|
113
|
+
const undoUpsertStmt = db.prepare(
|
|
114
|
+
"INSERT INTO undo (path, content, bom, ending, hashes, result_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) " +
|
|
115
|
+
"ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, updated_at = excluded.updated_at"
|
|
116
|
+
);
|
|
117
|
+
const undoGetStmt = db.prepare(
|
|
118
|
+
"SELECT content, bom, ending, hashes, result_content FROM undo WHERE path = ?"
|
|
119
|
+
);
|
|
120
|
+
const undoDelStmt = db.prepare("DELETE FROM undo WHERE path = ?");
|
|
91
121
|
const stmts: Prepared = {
|
|
92
122
|
get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
|
|
93
123
|
allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
|
|
94
124
|
deleteOne: (...params) => { delStmt.run(...params); },
|
|
95
125
|
upsert: (...params) => { upsertStmt.run(...params); },
|
|
126
|
+
undoUpsert: (...params) => { undoUpsertStmt.run(...params); },
|
|
127
|
+
undoGet: (...params) => undoGetStmt.get(...params) as Record<string, unknown> | undefined,
|
|
128
|
+
undoDelete: (...params) => { undoDelStmt.run(...params); },
|
|
96
129
|
};
|
|
97
130
|
|
|
98
131
|
return { db, stmts };
|
|
@@ -295,6 +328,44 @@ export function deleteSnapshot(store: HashStore, path: string): void {
|
|
|
295
328
|
store.stmts.deleteOne(path);
|
|
296
329
|
}
|
|
297
330
|
|
|
331
|
+
export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): void {
|
|
332
|
+
store.stmts.undoUpsert(
|
|
333
|
+
path,
|
|
334
|
+
entry.content,
|
|
335
|
+
entry.bom,
|
|
336
|
+
entry.ending,
|
|
337
|
+
JSON.stringify(entry.hashes),
|
|
338
|
+
entry.resultContent,
|
|
339
|
+
Date.now(),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function getUndoEntry(store: HashStore, path: string): UndoRecord | undefined {
|
|
344
|
+
const row = store.stmts.undoGet(path);
|
|
345
|
+
if (!row) return undefined;
|
|
346
|
+
try {
|
|
347
|
+
const parsed = JSON.parse(row.hashes as string);
|
|
348
|
+
if (!Array.isArray(parsed) || !parsed.every((h) => typeof h === "string")) {
|
|
349
|
+
store.stmts.undoDelete(path);
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
content: row.content as string,
|
|
354
|
+
bom: row.bom as string,
|
|
355
|
+
ending: row.ending as string,
|
|
356
|
+
hashes: parsed as string[],
|
|
357
|
+
resultContent: row.result_content as string,
|
|
358
|
+
};
|
|
359
|
+
} catch {
|
|
360
|
+
store.stmts.undoDelete(path);
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function deleteUndo(store: HashStore, path: string): void {
|
|
366
|
+
store.stmts.undoDelete(path);
|
|
367
|
+
}
|
|
368
|
+
|
|
298
369
|
export async function pruneMissing(store: HashStore): Promise<void> {
|
|
299
370
|
const rows = store.stmts.allPaths() as { path: string }[];
|
|
300
371
|
const missing: string[] = [];
|
|
@@ -307,6 +378,9 @@ export async function pruneMissing(store: HashStore): Promise<void> {
|
|
|
307
378
|
}
|
|
308
379
|
if (missing.length === 0) return;
|
|
309
380
|
withStore(() => {
|
|
310
|
-
for (const path of missing)
|
|
381
|
+
for (const path of missing) {
|
|
382
|
+
store.stmts.deleteOne(path);
|
|
383
|
+
store.stmts.undoDelete(path);
|
|
384
|
+
}
|
|
311
385
|
});
|
|
312
386
|
}
|
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-diff.ts
CHANGED
|
@@ -5,10 +5,15 @@ import {
|
|
|
5
5
|
HASH_SEP,
|
|
6
6
|
} from "./hashline";
|
|
7
7
|
|
|
8
|
-
export
|
|
9
|
-
|
|
8
|
+
export type LineEnding = "\r\n" | "\n" | "\r";
|
|
9
|
+
|
|
10
|
+
export function detectEnding(content: string): LineEnding {
|
|
10
11
|
const lfIdx = content.indexOf("\n");
|
|
11
|
-
if (lfIdx === -1
|
|
12
|
+
if (lfIdx === -1) {
|
|
13
|
+
return content.indexOf("\r") >= 0 ? "\r" : "\n";
|
|
14
|
+
}
|
|
15
|
+
const crlfIdx = content.indexOf("\r\n");
|
|
16
|
+
if (crlfIdx === -1) return "\n";
|
|
12
17
|
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
13
18
|
}
|
|
14
19
|
|
|
@@ -18,9 +23,11 @@ export function toLF(text: string): string {
|
|
|
18
23
|
|
|
19
24
|
export function restoreEndings(
|
|
20
25
|
text: string,
|
|
21
|
-
ending:
|
|
26
|
+
ending: LineEnding,
|
|
22
27
|
): string {
|
|
23
|
-
|
|
28
|
+
if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
|
|
29
|
+
if (ending === "\r") return text.replace(/\n/g, "\r");
|
|
30
|
+
return text;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
export function stripBOM(content: string): { bom: string; text: string } {
|
package/src/replace-undo.ts
CHANGED
|
@@ -2,11 +2,11 @@ import { readFile } from "fs/promises";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
|
-
import { loadHashStore, upsertSnapshot } from "./hash-store";
|
|
5
|
+
import { loadHashStore, upsertSnapshot, upsertUndo, getUndoEntry, deleteUndo } from "./hash-store";
|
|
6
6
|
import { contentChecksum } from "./hashline/hasher";
|
|
7
7
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
8
8
|
import { toCwd } from "./paths";
|
|
9
|
-
import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
|
|
9
|
+
import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./replace-diff";
|
|
10
10
|
import { cntDiff, splitLines, errCode } from "./utils";
|
|
11
11
|
import { loadP, loadGuide } from "./prompts";
|
|
12
12
|
import { buildMetrics } from "./replace-response";
|
|
@@ -14,26 +14,60 @@ import { changedRange } from "./hashline";
|
|
|
14
14
|
export interface UndoEntry {
|
|
15
15
|
content: string;
|
|
16
16
|
bom: string;
|
|
17
|
-
originalEnding:
|
|
17
|
+
originalEnding: LineEnding;
|
|
18
18
|
hashes: string[];
|
|
19
19
|
resultContent: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
export async function saveUndo(path: string, entry: UndoEntry): Promise<boolean> {
|
|
23
|
+
try {
|
|
24
|
+
const store = await loadHashStore();
|
|
25
|
+
upsertUndo(store, path, {
|
|
26
|
+
content: entry.content,
|
|
27
|
+
bom: entry.bom,
|
|
28
|
+
ending: entry.originalEnding,
|
|
29
|
+
hashes: entry.hashes,
|
|
30
|
+
resultContent: entry.resultContent,
|
|
31
|
+
});
|
|
32
|
+
return true;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error("Failed to persist undo entry:", error);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
26
37
|
}
|
|
27
38
|
|
|
28
|
-
export function getUndo(path: string): UndoEntry | undefined {
|
|
29
|
-
|
|
39
|
+
export async function getUndo(path: string): Promise<UndoEntry | undefined> {
|
|
40
|
+
try {
|
|
41
|
+
const store = await loadHashStore();
|
|
42
|
+
const record = getUndoEntry(store, path);
|
|
43
|
+
if (!record) return undefined;
|
|
44
|
+
const originalEnding = record.ending;
|
|
45
|
+
if (originalEnding !== "\r\n" && originalEnding !== "\n" && originalEnding !== "\r") {
|
|
46
|
+
await deleteUndo(store, path);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
content: record.content,
|
|
51
|
+
bom: record.bom,
|
|
52
|
+
originalEnding,
|
|
53
|
+
hashes: record.hashes,
|
|
54
|
+
resultContent: record.resultContent,
|
|
55
|
+
};
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error("Failed to load undo entry:", error);
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
30
60
|
}
|
|
31
61
|
|
|
32
|
-
export function clearUndo(path: string): void {
|
|
33
|
-
|
|
62
|
+
export async function clearUndo(path: string): Promise<void> {
|
|
63
|
+
try {
|
|
64
|
+
const store = await loadHashStore();
|
|
65
|
+
deleteUndo(store, path);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error("Failed to clear undo entry:", error);
|
|
68
|
+
}
|
|
34
69
|
}
|
|
35
70
|
|
|
36
|
-
|
|
37
71
|
export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
38
72
|
pi.registerTool({
|
|
39
73
|
name: "undo_last_replace",
|
|
@@ -52,7 +86,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
52
86
|
const absolutePath = toCwd(path, ctx.cwd);
|
|
53
87
|
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
54
88
|
|
|
55
|
-
const undo = getUndo(mutationTargetPath);
|
|
89
|
+
const undo = await getUndo(mutationTargetPath);
|
|
56
90
|
if (!undo) {
|
|
57
91
|
return {
|
|
58
92
|
content: [
|
|
@@ -67,16 +101,14 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
67
101
|
}
|
|
68
102
|
|
|
69
103
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
70
|
-
let
|
|
104
|
+
let currentRaw: string | undefined;
|
|
71
105
|
try {
|
|
72
|
-
|
|
73
|
-
const { text: currentStripped } = stripBOM(currentRaw);
|
|
74
|
-
currentNormalized = toLF(currentStripped);
|
|
106
|
+
currentRaw = await readFile(mutationTargetPath, "utf-8");
|
|
75
107
|
} catch (error) {
|
|
76
108
|
if (errCode(error) !== "ENOENT") throw error;
|
|
77
109
|
}
|
|
78
110
|
|
|
79
|
-
if (
|
|
111
|
+
if (currentRaw === undefined) {
|
|
80
112
|
return {
|
|
81
113
|
content: [
|
|
82
114
|
{
|
|
@@ -88,7 +120,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
88
120
|
details: {},
|
|
89
121
|
};
|
|
90
122
|
}
|
|
91
|
-
if (
|
|
123
|
+
if (currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)) {
|
|
92
124
|
return {
|
|
93
125
|
content: [
|
|
94
126
|
{
|
|
@@ -101,6 +133,8 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
101
133
|
};
|
|
102
134
|
}
|
|
103
135
|
|
|
136
|
+
const { text: currentStripped } = stripBOM(currentRaw);
|
|
137
|
+
const currentNormalized = toLF(currentStripped);
|
|
104
138
|
const diffResult = genDiff(undo.content, currentNormalized, 0);
|
|
105
139
|
const linesAddedByReplace = cntDiff(diffResult.diff, "+");
|
|
106
140
|
const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
|
|
@@ -118,7 +152,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
118
152
|
console.error("Failed to restore hash store snapshot after undo:", error);
|
|
119
153
|
}
|
|
120
154
|
|
|
121
|
-
clearUndo(mutationTargetPath);
|
|
155
|
+
await clearUndo(mutationTargetPath);
|
|
122
156
|
|
|
123
157
|
const parts: string[] = [
|
|
124
158
|
`Undone last replace on ${path}.`,
|
package/src/replace.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { constants } from "fs";
|
|
|
9
9
|
import {
|
|
10
10
|
genDiff,
|
|
11
11
|
restoreEndings,
|
|
12
|
+
type LineEnding,
|
|
12
13
|
} from "./replace-diff";
|
|
13
14
|
import { readNormFile } from "./file-reader";
|
|
14
15
|
import { normReq, normalizeFilePath, tryParseContentLines } from "./replace-normalize";
|
|
@@ -86,7 +87,7 @@ interface PipelineResult {
|
|
|
86
87
|
originalNormalized: string;
|
|
87
88
|
result: string;
|
|
88
89
|
bom: string;
|
|
89
|
-
originalEnding:
|
|
90
|
+
originalEnding: LineEnding;
|
|
90
91
|
hadUtf8DecodeErrors: boolean;
|
|
91
92
|
warnings: string[];
|
|
92
93
|
noopEdit?: NEdit;
|
|
@@ -100,7 +101,7 @@ interface PipelineResult {
|
|
|
100
101
|
|
|
101
102
|
const ROOT_KS = new Set(["path", "content_lines", "hash_range_inclusive"]);
|
|
102
103
|
|
|
103
|
-
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"];
|
|
104
105
|
|
|
105
106
|
export function assertNoLegacyKeys(request: unknown): void {
|
|
106
107
|
if (!isRec(request)) return;
|
|
@@ -250,11 +251,6 @@ export async function compPreview(
|
|
|
250
251
|
): Promise<RPreview> {
|
|
251
252
|
try {
|
|
252
253
|
const normalized = normReq(request);
|
|
253
|
-
if (isRec(request) && Array.isArray(request.changes)) {
|
|
254
|
-
return {
|
|
255
|
-
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).`
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
254
|
assertReq(normalized);
|
|
259
255
|
const { path, originalNormalized, result, resultHashes } = await execPipeline(
|
|
260
256
|
normalized,
|
|
@@ -409,8 +405,9 @@ export function buildToolDef(): ToolDef {
|
|
|
409
405
|
|
|
410
406
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
411
407
|
const canonical = normReq(params);
|
|
408
|
+
assertReq(canonical);
|
|
412
409
|
|
|
413
|
-
const normalizedParams = canonical
|
|
410
|
+
const normalizedParams = canonical;
|
|
414
411
|
const path = normalizedParams.path;
|
|
415
412
|
const absolutePath = toCwd(path, ctx.cwd);
|
|
416
413
|
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
@@ -465,13 +462,18 @@ export function buildToolDef(): ToolDef {
|
|
|
465
462
|
absolutePath,
|
|
466
463
|
bom + restoreEndings(result, originalEnding),
|
|
467
464
|
);
|
|
468
|
-
saveUndo(mutationTargetPath, {
|
|
465
|
+
const undoPersisted = await saveUndo(mutationTargetPath, {
|
|
469
466
|
content: originalNormalized,
|
|
470
467
|
bom,
|
|
471
468
|
originalEnding,
|
|
472
469
|
hashes: originalHashes,
|
|
473
470
|
resultContent: result,
|
|
474
471
|
});
|
|
472
|
+
if (!undoPersisted) {
|
|
473
|
+
warnings.push(
|
|
474
|
+
"Undo history could not be persisted; undo_last_replace will not be available for this edit.",
|
|
475
|
+
);
|
|
476
|
+
}
|
|
475
477
|
const updatedSnapshotId = (await fileSnap(absolutePath))
|
|
476
478
|
.snapshotId;
|
|
477
479
|
|