pi-hashline-edit-pro 2.2.0 → 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 +3 -2
- package/package.json +10 -3
- package/prompts/replace-guidelines.md +2 -1
- package/prompts/undo-last-replace-guidelines.md +1 -1
- package/src/hash-store.ts +18 -0
- package/src/hashline/parse.ts +2 -3
- package/src/hashline/resolve.ts +43 -19
- package/src/replace.ts +48 -3
package/README.md
CHANGED
|
@@ -86,14 +86,15 @@ One edit per call, with `hash_bounds` and `new_content` at the top level:
|
|
|
86
86
|
| Field | Description |
|
|
87
87
|
| --- | --- |
|
|
88
88
|
| `hash_bounds` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
|
|
89
|
-
| `new_content` | Replacement content as a single string with `\n` line separators; a trailing
|
|
89
|
+
| `new_content` | Replacement content as a single string with `\n` line separators; every `\n` separates lines, so a trailing `\n` adds a final empty line — mirror the replaced range's lines exactly, blank lines included (a replacement that is only blank lines is written as one `\n` per blank line). Use `""` to delete the range. |
|
|
90
90
|
|
|
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
|
|
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,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
"extension",
|
|
17
17
|
"hashline",
|
|
18
18
|
"hash-anchored",
|
|
19
|
-
"
|
|
19
|
+
"hash",
|
|
20
|
+
"anchors",
|
|
21
|
+
"read",
|
|
22
|
+
"replace",
|
|
23
|
+
"edit",
|
|
24
|
+
"undo",
|
|
25
|
+
"file-editing",
|
|
26
|
+
"stable"
|
|
20
27
|
],
|
|
21
28
|
"license": "MIT",
|
|
22
29
|
"files": [
|
|
@@ -2,5 +2,6 @@
|
|
|
2
2
|
- `replace`: minimize the replaced range — anchor only the lines that actually change, so few unchanged lines must be reproduced byte-exact.
|
|
3
3
|
- `replace`: to replace a single line, repeat its hash in both positions of hash_bounds: ["<HASH>", "<HASH>"] — never extend the range to neighboring lines for a one-line edit.
|
|
4
4
|
- `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
|
|
5
|
-
- `replace`:
|
|
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
|
+
}
|
package/src/hashline/parse.ts
CHANGED
|
@@ -45,7 +45,6 @@ export function parseText(edit: string): string[] {
|
|
|
45
45
|
}
|
|
46
46
|
const normalized = edit.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
47
47
|
if (normalized === "") return [];
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return lines;
|
|
48
|
+
if (/^\n+$/.test(normalized)) return new Array(normalized.length).fill("");
|
|
49
|
+
return normalized.split("\n");
|
|
51
50
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
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,
|
|
444
|
-
...lastNewBeforeDups(edit.content_lines, rangeLines,
|
|
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,11 +44,11 @@ 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:
|
|
50
|
-
"Replacement content as a single string with \\n line separators; a trailing
|
|
51
|
+
"Replacement content as a single string with \\n line separators; every \\n separates lines, so a trailing \\n adds a final empty line. Mirror the replaced range's lines exactly, blank lines included. A replacement that is only blank lines is written as one \\n per blank line. Use \"\" to delete the range."
|
|
51
52
|
});
|
|
52
53
|
|
|
53
54
|
const hashBoundsSchema = Type.Array(
|
|
@@ -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;
|