pi-hashline-edit-pro 1.2.0 → 2.0.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 +17 -19
- package/index.ts +2 -0
- package/package.json +1 -1
- package/prompts/read-guidelines.md +1 -1
- package/prompts/replace-guidelines.md +6 -5
- package/prompts/undo-last-replace-guidelines.md +2 -1
- package/src/constants.ts +3 -3
- package/src/hash-store.ts +4 -0
- package/src/hashline/apply.ts +3 -3
- package/src/hashline/parse.ts +10 -15
- package/src/hashline/resolve.ts +35 -51
- package/src/read.ts +4 -2
- package/src/replace-normalize.ts +1 -20
- package/src/replace-render.ts +3 -7
- package/src/replace.ts +21 -40
package/README.md
CHANGED
|
@@ -42,8 +42,8 @@ kQm│}
|
|
|
42
42
|
```json
|
|
43
43
|
{
|
|
44
44
|
"path": "src/main.ts",
|
|
45
|
-
"
|
|
46
|
-
"
|
|
45
|
+
"hash_bounds": ["szJ", "szJ"],
|
|
46
|
+
"new_content": " console.log('hi');"
|
|
47
47
|
}
|
|
48
48
|
```
|
|
49
49
|
|
|
@@ -77,31 +77,30 @@ Edge cases:
|
|
|
77
77
|
|
|
78
78
|
The built-in `edit` tool is disabled — `replace` is the only edit path; call it with the hash anchors from `read` output.
|
|
79
79
|
|
|
80
|
-
Exactly one edit per call, with `
|
|
80
|
+
Exactly one edit per call, with `hash_bounds` and `new_content` at the top level of the request:
|
|
81
81
|
|
|
82
82
|
```json
|
|
83
83
|
{
|
|
84
84
|
"path": "src/main.ts",
|
|
85
|
-
"
|
|
86
|
-
"
|
|
85
|
+
"hash_bounds": ["szJ", "kQm"],
|
|
86
|
+
"new_content": " console.log('hi');\n}"
|
|
87
87
|
}
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
| Field | Description |
|
|
91
91
|
| --- | --- |
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
92
|
+
| `hash_bounds` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
|
|
93
|
+
| `new_content` | Replacement content as a single string with `\n` line separators; a trailing newline is the last line's ending, not an extra empty line. Use `""` to delete the range. |
|
|
94
94
|
|
|
95
95
|
Behavior:
|
|
96
96
|
|
|
97
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.
|
|
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: [...]}`.
|
|
99
98
|
- **Autocorrections** (all accompanied by a warning unless noted):
|
|
100
|
-
- A `HASH│` prefix accidentally left on a `
|
|
101
|
-
- Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `
|
|
99
|
+
- A `HASH│` prefix accidentally left on a `new_content` line is stripped.
|
|
100
|
+
- Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `new_content` have their markers stripped. Numbered deletion rows (`-1 foo`) and unified-diff lines are written literally — never silently altered.
|
|
102
101
|
- A reversed range (start hash after end hash) is swapped and applied.
|
|
103
102
|
- A duplicated boundary line — the classic `}`, `});`, or `} else {` pasted twice — is silently removed; the duplicate never reaches the file.
|
|
104
|
-
- `file_path` is accepted as an alias for `path
|
|
103
|
+
- `file_path` is accepted as an alias for `path`.
|
|
105
104
|
- **Response.** With auto-read enabled (the default), a successful edit returns the post-edit diff — the same `+HASH│` / `- │` / ` HASH│` rows the user sees — instead of the summary. With auto-read disabled, the edit reports `Successfully replaced in {path}. Added X line(s), removed Y line(s).` plus any warnings, and no diff is shown to the model. Warnings are appended in both modes. An edit that produces identical content reports `No changes made` and never rotates anchors. The post-edit diff is exposed to the host UI via `details.diff` — the TUI always shows it — and reaches the model-visible text only while auto-read is on.
|
|
106
105
|
- **Undo.** Every successful replace is undoable once via `undo_last_replace` — see [Undo](#undo).
|
|
107
106
|
|
|
@@ -121,7 +120,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
121
120
|
Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so the model gets immediate `HASH│content` anchors without a separate `read` call.
|
|
122
121
|
|
|
123
122
|
- A no-op `replace` produces no diff — the file is unchanged, so existing anchors remain valid.
|
|
124
|
-
- After `replace` / `undo_last_replace`, the success summary is replaced by the post-edit diff (the same `+HASH│` / `- │` / ` HASH│` rows used for replace) plus any warnings, so the model sees the change like a git diff instead of line counts; no anchor block is appended — call `read`
|
|
123
|
+
- After `replace` / `undo_last_replace`, the success summary is replaced by the post-edit diff (the same `+HASH│` / `- │` / ` HASH│` rows used for replace) plus any warnings, so the model sees the change like a git diff instead of line counts; no anchor block is appended — the diff rows themselves are the fresh anchors (`+HASH│` and ` HASH│` rows carry the current hashes, and unchanged lines keep their previous hashes), so follow-up edits can anchor on the diff directly; call `read` when you want the full file's anchors.
|
|
125
124
|
- With auto-read disabled, `replace` / `undo_last_replace` results keep the plain summary in the model-visible text — no diff and no anchor block reach the model (the post-edit diff is still shown to the user).
|
|
126
125
|
- 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.
|
|
127
126
|
- 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).
|
|
@@ -157,13 +156,12 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
|
|
|
157
156
|
|
|
158
157
|
| Code | Meaning |
|
|
159
158
|
| --- | --- |
|
|
160
|
-
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields
|
|
161
|
-
| `[E_BAD_REF]` | An anchor in `
|
|
159
|
+
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (e.g. `new_content` must be a string with `\n` line separators). |
|
|
160
|
+
| `[E_BAD_REF]` | An anchor in `hash_bounds` is not a bare 3-char hash. |
|
|
162
161
|
| `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
|
|
163
162
|
| `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
|
|
164
|
-
| `[E_INVALID_PATCH]` | A `
|
|
165
|
-
| `[E_BARE_HASH_PREFIX]` | A `
|
|
166
|
-
| `[E_LEGACY_SHAPE]` | The request uses an unsupported dialect: `oldText`/`newText` fields or a `changes` array. |
|
|
163
|
+
| `[E_INVALID_PATCH]` | A `new_content` line is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
|
|
164
|
+
| `[E_BARE_HASH_PREFIX]` | A `new_content` line starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
|
|
167
165
|
| `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
|
|
168
166
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
169
167
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
@@ -187,7 +185,7 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
|
|
|
187
185
|
- **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).
|
|
188
186
|
- **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo; files with mixed line endings are normalized to a single line ending on edit.
|
|
189
187
|
- **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.
|
|
190
|
-
- **One edit per call.** The request shape stays `{path,
|
|
188
|
+
- **One edit per call.** The request shape stays `{path, hash_bounds, new_content}` from schema through validation to application; there is no batching dialect.
|
|
191
189
|
|
|
192
190
|
## Troubleshooting
|
|
193
191
|
|
|
@@ -195,7 +193,7 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
|
|
|
195
193
|
- **Reset the hash store.** Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars). Quit pi, delete those three files, and the store is rebuilt on the next session. Anchor history is lost, but no project files are touched.
|
|
196
194
|
- **Upgrading.** A hash-allocation change clears the hash store once on the first run after upgrade — anchors are rebuilt on the next read and undo history is lost, but no project files are touched.
|
|
197
195
|
- **Corrupt store.** If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` (plus `-wal`/`-shm` variants) and rebuilt automatically; the quarantined files can be deleted once a healthy store exists.
|
|
198
|
-
- **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted.
|
|
196
|
+
- **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted. Legacy snapshots containing duplicate hashes are skipped and rebuilt on the next read.
|
|
199
197
|
- **`[E_UNDO_UNAVAILABLE]`.** The edit was refused because the undo record could not be written — check disk space and that the config directory is writable, then retry.
|
|
200
198
|
|
|
201
199
|
## Development
|
package/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { regRead, fmtReadPreview } from "./src/read";
|
|
|
7
7
|
import type { RMetrics } from "./src/replace-response";
|
|
8
8
|
import { extractWarnings } from "./src/replace-render";
|
|
9
9
|
import { MAX_HASH_LINES } from "./src/hashline";
|
|
10
|
+
import { AUTO_READ_MAX } from "./src/constants";
|
|
10
11
|
import {
|
|
11
12
|
readConfig,
|
|
12
13
|
toggleAutoRead,
|
|
@@ -81,6 +82,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
81
82
|
fileHashes,
|
|
82
83
|
absolutePath,
|
|
83
84
|
DEFAULT_MAX_BYTES,
|
|
85
|
+
AUTO_READ_MAX,
|
|
84
86
|
);
|
|
85
87
|
return {
|
|
86
88
|
content: [
|
package/package.json
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
- `read`: call before `replace` when you need fresh HASH anchors for a file.
|
|
2
|
-
- `read`: call again after any edit to that file — changed lines get new anchors.
|
|
2
|
+
- `read`: call again after any edit to that file — changed lines get new anchors (the post-edit diff rows from replace/undo also carry fresh anchors for the changed range).
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
- `replace`:
|
|
2
|
-
- `replace`:
|
|
1
|
+
- `replace`: hash_bounds must use only anchors from the most recent read of the same file.
|
|
2
|
+
- `replace`: hash_bounds marks the exact lines that are REMOVED, and new_content is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in new_content is deleted from the file — including closing braces and other structural lines.
|
|
3
3
|
- `replace`: minimize the replaced range — anchor only the lines that actually change, so few unchanged lines must be reproduced byte-exact.
|
|
4
|
-
- `replace`: to replace a single line, repeat its hash in both positions of
|
|
5
|
-
- `replace`:
|
|
6
|
-
- `replace`:
|
|
4
|
+
- `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.
|
|
5
|
+
- `replace`: new_content is a single string with \n line separators — never a JSON array. When copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
|
|
6
|
+
- `replace`: a trailing newline in new_content is the last line's ending, not an extra empty line; use an explicit empty line (e.g. ending with \n\n) to add a blank line.
|
|
7
|
+
- `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.
|
|
@@ -1 +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.
|
|
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.
|
package/src/constants.ts
CHANGED
|
@@ -5,6 +5,6 @@ export const MAX_READ_LINE_BYTES = 200 * 1024;
|
|
|
5
5
|
|
|
6
6
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
7
7
|
export const HASH_STORE_VERSION = 5;
|
|
8
|
-
export const
|
|
9
|
-
`[E_BAD_SHAPE] "
|
|
10
|
-
+ ` Do not
|
|
8
|
+
export const NEW_CONTENT_NOT_STRING_MSG =
|
|
9
|
+
`[E_BAD_SHAPE] "new_content" must be a string with \\n line separators, not an array.`
|
|
10
|
+
+ ` Do not pass an array of lines — pass the replacement text as one string: "line1\\nline2". Use "" to delete a range.`;
|
package/src/hash-store.ts
CHANGED
|
@@ -318,6 +318,10 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
|
|
|
318
318
|
const rows: [string, string, number, string, number][] = [];
|
|
319
319
|
for (const [key, value] of Object.entries(raw)) {
|
|
320
320
|
if (!isValidSnapshot(value)) continue;
|
|
321
|
+
if (new Set(value.hashes).size !== value.hashes.length) {
|
|
322
|
+
console.warn(`Skipped legacy snapshot with duplicate hashes for ${key}; it will be re-hashed on next read.`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
321
325
|
rows.push([
|
|
322
326
|
key,
|
|
323
327
|
contentChecksum(value.content),
|
package/src/hashline/apply.ts
CHANGED
|
@@ -65,8 +65,8 @@ function resToSpan(
|
|
|
65
65
|
): RESpan | NoopSpan {
|
|
66
66
|
const { fileLines, lineStarts } = lineIndex;
|
|
67
67
|
|
|
68
|
-
const startLine = edit.
|
|
69
|
-
const endLine = edit.
|
|
68
|
+
const startLine = edit.hash_bounds[0].line;
|
|
69
|
+
const endLine = edit.hash_bounds[1].line;
|
|
70
70
|
const originalLines = fileLines.slice(startLine - 1, endLine);
|
|
71
71
|
if (
|
|
72
72
|
originalLines.length === edit.content_lines.length &&
|
|
@@ -76,7 +76,7 @@ function resToSpan(
|
|
|
76
76
|
) {
|
|
77
77
|
return {
|
|
78
78
|
kind: "noop",
|
|
79
|
-
loc: edit.
|
|
79
|
+
loc: edit.hash_bounds[0].hash,
|
|
80
80
|
currentContent: originalLines.join("\n"),
|
|
81
81
|
};
|
|
82
82
|
}
|
package/src/hashline/parse.ts
CHANGED
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
ANCHOR_LEN,
|
|
3
3
|
ALPH_RE,
|
|
4
4
|
} from "./hash";
|
|
5
|
-
import {
|
|
5
|
+
import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
|
|
6
6
|
|
|
7
7
|
export type Anchor = { hash: string };
|
|
8
8
|
|
|
@@ -18,7 +18,7 @@ function diagRef(ref: string): string {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
if (trimmed.includes("│")) {
|
|
21
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}".
|
|
21
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed}". hash_bounds must contain the 3-char hash only — remove everything from "│" onward.`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
|
|
@@ -39,18 +39,13 @@ function parseRef(ref: string): Anchor {
|
|
|
39
39
|
|
|
40
40
|
export const parseHashRef = parseRef;
|
|
41
41
|
|
|
42
|
-
export function parseText(edit: string
|
|
43
|
-
if (edit
|
|
44
|
-
throw new Error(
|
|
42
|
+
export function parseText(edit: string): string[] {
|
|
43
|
+
if (typeof edit !== "string") {
|
|
44
|
+
throw new Error(NEW_CONTENT_NOT_STRING_MSG);
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
throw new Error(
|
|
52
|
-
`[E_BAD_SHAPE] "content_lines" entry at index ${lineBreakIndex} contains a \\r or \\n line break. Pass each line as its own array entry.`,
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
return edit;
|
|
46
|
+
const normalized = edit.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
47
|
+
if (normalized === "") return [];
|
|
48
|
+
const lines = normalized.split("\n");
|
|
49
|
+
if (normalized.endsWith("\n")) lines.pop();
|
|
50
|
+
return lines;
|
|
56
51
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, clipLine } from "../utils";
|
|
2
2
|
import { HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hash";
|
|
3
3
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
4
|
-
import {
|
|
4
|
+
import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
|
|
5
5
|
|
|
6
6
|
export type RAnchor = {
|
|
7
7
|
line: number;
|
|
@@ -9,10 +9,10 @@ export type RAnchor = {
|
|
|
9
9
|
hashMatched: boolean;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
export type HEdit = { content_lines: string[];
|
|
12
|
+
export type HEdit = { content_lines: string[]; hash_bounds: [Anchor, Anchor] };
|
|
13
13
|
export type RHEdit = {
|
|
14
14
|
content_lines: string[];
|
|
15
|
-
|
|
15
|
+
hash_bounds: [RAnchor, RAnchor];
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
interface HMismatch {
|
|
@@ -40,8 +40,8 @@ export interface NEdit {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export type HTEdit = {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
new_content: string;
|
|
44
|
+
hash_bounds: [string, string];
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
function resAnchorFromMap(
|
|
@@ -89,7 +89,7 @@ export function fmtMismatch(
|
|
|
89
89
|
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
90
90
|
if (notFound.length > 0) {
|
|
91
91
|
out.push(
|
|
92
|
-
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were read. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into
|
|
92
|
+
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were read. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_bounds of your next replace call.`
|
|
93
93
|
);
|
|
94
94
|
for (const m of notFound) {
|
|
95
95
|
const ctx = m.context;
|
|
@@ -107,7 +107,7 @@ export function fmtMismatch(
|
|
|
107
107
|
if (ambiguous.length > 0) {
|
|
108
108
|
if (out.length > 0) out.push("");
|
|
109
109
|
out.push(
|
|
110
|
-
`[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into
|
|
110
|
+
`[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_bounds of your next replace call.`
|
|
111
111
|
);
|
|
112
112
|
for (const m of ambiguous) {
|
|
113
113
|
const sample = (m.candidates ?? []).slice(0, 5);
|
|
@@ -130,13 +130,7 @@ export function fmtMismatch(
|
|
|
130
130
|
return out.join("\n");
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
const ITEM_KS = new Set(["
|
|
134
|
-
|
|
135
|
-
function isStrArr(value: unknown): value is string[] {
|
|
136
|
-
return (
|
|
137
|
-
Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
138
|
-
);
|
|
139
|
-
}
|
|
133
|
+
const ITEM_KS = new Set(["new_content", "hash_bounds"]);
|
|
140
134
|
|
|
141
135
|
function isStrPair(value: unknown): value is [string, string] {
|
|
142
136
|
return (
|
|
@@ -147,36 +141,22 @@ function isStrPair(value: unknown): value is [string, string] {
|
|
|
147
141
|
}
|
|
148
142
|
|
|
149
143
|
function assertItem(edit: Record<string, unknown>): void {
|
|
150
|
-
rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only {
|
|
144
|
+
rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { new_content, hash_bounds }.");
|
|
151
145
|
|
|
152
|
-
if ("
|
|
146
|
+
if ("hash_bounds" in edit && !isStrPair(edit.hash_bounds)) {
|
|
153
147
|
throw new Error(
|
|
154
|
-
`[E_BAD_SHAPE] Field "
|
|
148
|
+
`[E_BAD_SHAPE] Field "hash_bounds" must be a pair of anchor strings [start, end].`,
|
|
155
149
|
);
|
|
156
150
|
}
|
|
157
|
-
if (!("
|
|
158
|
-
throw new Error(`[E_BAD_SHAPE] The edit requires a "
|
|
151
|
+
if (!("new_content" in edit)) {
|
|
152
|
+
throw new Error(`[E_BAD_SHAPE] The edit requires a "new_content" field. Provide the replacement text (use "" to delete).`);
|
|
159
153
|
}
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
if (typeof val === "string") {
|
|
163
|
-
try {
|
|
164
|
-
const parsed = JSON.parse(val);
|
|
165
|
-
if (Array.isArray(parsed)) {
|
|
166
|
-
edit.content_lines = parsed;
|
|
167
|
-
} else {
|
|
168
|
-
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
169
|
-
}
|
|
170
|
-
} catch {
|
|
171
|
-
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
172
|
-
}
|
|
173
|
-
} else {
|
|
174
|
-
throw new Error(`[E_BAD_SHAPE] Field "content_lines" must be a string array.`);
|
|
175
|
-
}
|
|
154
|
+
if (typeof edit.new_content !== "string") {
|
|
155
|
+
throw new Error(NEW_CONTENT_NOT_STRING_MSG);
|
|
176
156
|
}
|
|
177
|
-
if (!isStrPair(edit.
|
|
157
|
+
if (!isStrPair(edit.hash_bounds)) {
|
|
178
158
|
throw new Error(
|
|
179
|
-
`[E_BAD_SHAPE] The edit requires a "
|
|
159
|
+
`[E_BAD_SHAPE] The edit requires a "hash_bounds" pair of anchor strings [start, end].`,
|
|
180
160
|
);
|
|
181
161
|
}
|
|
182
162
|
}
|
|
@@ -184,10 +164,10 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
184
164
|
export function resEdit(edit: HTEdit): HEdit {
|
|
185
165
|
assertItem(edit as Record<string, unknown>);
|
|
186
166
|
|
|
187
|
-
const replaceLines = parseText(edit.
|
|
167
|
+
const replaceLines = parseText(edit.new_content);
|
|
188
168
|
return {
|
|
189
169
|
content_lines: replaceLines,
|
|
190
|
-
|
|
170
|
+
hash_bounds: [parseHashRef(edit.hash_bounds[0]), parseHashRef(edit.hash_bounds[1])],
|
|
191
171
|
};
|
|
192
172
|
}
|
|
193
173
|
|
|
@@ -217,15 +197,19 @@ export function stripBarePrefixes(
|
|
|
217
197
|
});
|
|
218
198
|
if (stripped.length === 0) return edit;
|
|
219
199
|
const locations = stripped
|
|
220
|
-
.map((s) => `
|
|
200
|
+
.map((s) => `new_content line ${s.lineIndex + 1}`)
|
|
221
201
|
.join(", ");
|
|
222
202
|
const matchedCount = stripped.filter((s) => s.matched).length;
|
|
223
203
|
const evidence =
|
|
224
204
|
matchedCount === 0
|
|
225
205
|
? "none of the stripped hashes match current file lines"
|
|
226
206
|
: `${matchedCount} of ${stripped.length} stripped hash(es) match current file lines`;
|
|
207
|
+
const guidance =
|
|
208
|
+
matchedCount === 0
|
|
209
|
+
? " Verify that these lines were pasted from read output; literal content starting with 'HASH│' would be altered by this strip."
|
|
210
|
+
: "";
|
|
227
211
|
warnings.push(
|
|
228
|
-
`[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence})
|
|
212
|
+
`[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).${guidance}`
|
|
229
213
|
);
|
|
230
214
|
return { ...edit, content_lines: contentLines };
|
|
231
215
|
}
|
|
@@ -249,7 +233,7 @@ export function stripDiffPrefixes(
|
|
|
249
233
|
return line;
|
|
250
234
|
});
|
|
251
235
|
if (stripped.length === 0) return edit;
|
|
252
|
-
const locations = stripped.map((i) => `
|
|
236
|
+
const locations = stripped.map((i) => `new_content line ${i + 1}`).join(", ");
|
|
253
237
|
warnings.push(
|
|
254
238
|
`[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
|
|
255
239
|
);
|
|
@@ -265,7 +249,7 @@ export function swapReversedRanges(
|
|
|
265
249
|
for (let i = 0; i < fileHashes.length; i++) {
|
|
266
250
|
lineByHash.set(fileHashes[i]!, i + 1);
|
|
267
251
|
}
|
|
268
|
-
const [startRef, endRef] = edit.
|
|
252
|
+
const [startRef, endRef] = edit.hash_bounds;
|
|
269
253
|
const startLine = lineByHash.get(startRef.hash);
|
|
270
254
|
const endLine = lineByHash.get(endRef.hash);
|
|
271
255
|
if (
|
|
@@ -276,9 +260,9 @@ export function swapReversedRanges(
|
|
|
276
260
|
return edit;
|
|
277
261
|
}
|
|
278
262
|
warnings.push(
|
|
279
|
-
`[E_BAD_OP] Autocorrected:
|
|
263
|
+
`[E_BAD_OP] Autocorrected: hash_bounds was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
|
|
280
264
|
);
|
|
281
|
-
return { ...edit,
|
|
265
|
+
return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
|
|
282
266
|
}
|
|
283
267
|
|
|
284
268
|
function checkBoundaryDup(
|
|
@@ -330,21 +314,21 @@ export function valEdit(
|
|
|
330
314
|
};
|
|
331
315
|
|
|
332
316
|
abortIf(signal);
|
|
333
|
-
const startResolved = tryResolve(edit.
|
|
334
|
-
const endResolved = tryResolve(edit.
|
|
317
|
+
const startResolved = tryResolve(edit.hash_bounds[0]);
|
|
318
|
+
const endResolved = tryResolve(edit.hash_bounds[1]);
|
|
335
319
|
if (!startResolved || !endResolved) {
|
|
336
320
|
if (!startResolved && endResolved) {
|
|
337
|
-
const startMismatch = mismatches.findLast((m) => m.ref === edit.
|
|
321
|
+
const startMismatch = mismatches.findLast((m) => m.ref === edit.hash_bounds[0]);
|
|
338
322
|
if (startMismatch && startMismatch.kind === "not_found") startMismatch.context = endResolved;
|
|
339
323
|
} else if (startResolved && !endResolved) {
|
|
340
|
-
const endMismatch = mismatches.findLast((m) => m.ref === edit.
|
|
324
|
+
const endMismatch = mismatches.findLast((m) => m.ref === edit.hash_bounds[1]);
|
|
341
325
|
if (endMismatch && endMismatch.kind === "not_found") endMismatch.context = startResolved;
|
|
342
326
|
}
|
|
343
327
|
return { resolved: undefined, mismatches, boundaryWarnings };
|
|
344
328
|
}
|
|
345
329
|
if (startResolved.line > endResolved.line) {
|
|
346
330
|
throw new Error(
|
|
347
|
-
`[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.
|
|
331
|
+
`[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.hash_bounds[0].hash} and ${edit.hash_bounds[1].hash}).`,
|
|
348
332
|
);
|
|
349
333
|
}
|
|
350
334
|
const endLine = endResolved.line;
|
|
@@ -360,7 +344,7 @@ export function valEdit(
|
|
|
360
344
|
return {
|
|
361
345
|
resolved: {
|
|
362
346
|
content_lines: edit.content_lines,
|
|
363
|
-
|
|
347
|
+
hash_bounds: [startResolved, endResolved],
|
|
364
348
|
},
|
|
365
349
|
mismatches,
|
|
366
350
|
boundaryWarnings,
|
package/src/read.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createReadTool,
|
|
4
4
|
formatSize,
|
|
5
5
|
truncateHead,
|
|
6
|
+
DEFAULT_MAX_LINES,
|
|
6
7
|
type TruncationResult,
|
|
7
8
|
} from "@earendil-works/pi-coding-agent";
|
|
8
9
|
import { Type } from "typebox";
|
|
@@ -57,6 +58,7 @@ export async function fmtReadPreview(
|
|
|
57
58
|
precomputedHashes?: string[],
|
|
58
59
|
path?: string,
|
|
59
60
|
maxLineBytes = MAX_READ_LINE_BYTES,
|
|
61
|
+
maxTruncLines = DEFAULT_MAX_LINES,
|
|
60
62
|
): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number }> {
|
|
61
63
|
const allLines = visLines(text);
|
|
62
64
|
const totalLines = allLines.length;
|
|
@@ -99,7 +101,7 @@ export async function fmtReadPreview(
|
|
|
99
101
|
? `[Line ${row.lineNumber} is ${formatSize(row.bytes)}, exceeds ${formatSize(maxBytes)}; content not shown. Use bash: sed -n '${row.lineNumber}p' <path> | head -c ${maxBytes}]`
|
|
100
102
|
: fmtRegion([selectedHashes[index]!], [selected[index]!]),
|
|
101
103
|
);
|
|
102
|
-
const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes });
|
|
104
|
+
const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes, maxLines: maxTruncLines });
|
|
103
105
|
const shownRowCount = skippedTruncation.content === "" ? 0 : skippedTruncation.content.split("\n").length;
|
|
104
106
|
const lastShownLine = shownRowCount > 0 ? startLine + shownRowCount - 1 : startLine - 1;
|
|
105
107
|
const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
|
|
@@ -121,7 +123,7 @@ export async function fmtReadPreview(
|
|
|
121
123
|
};
|
|
122
124
|
}
|
|
123
125
|
|
|
124
|
-
const truncation = truncateHead(formatted, { maxBytes });
|
|
126
|
+
const truncation = truncateHead(formatted, { maxBytes, maxLines: maxTruncLines });
|
|
125
127
|
|
|
126
128
|
let preview = truncation.content;
|
|
127
129
|
let nextOffset: number | undefined;
|
package/src/replace-normalize.ts
CHANGED
|
@@ -1,19 +1,4 @@
|
|
|
1
|
-
import { isRec
|
|
2
|
-
import { CONTENT_LINES_NOT_STRING_MSG } from "./constants";
|
|
3
|
-
|
|
4
|
-
export function tryParseContentLines(record: Record<string, unknown>, key: string): void {
|
|
5
|
-
const val = record[key];
|
|
6
|
-
if (typeof val !== "string") return;
|
|
7
|
-
try {
|
|
8
|
-
const parsed = JSON.parse(val);
|
|
9
|
-
if (Array.isArray(parsed)) {
|
|
10
|
-
record[key] = parsed;
|
|
11
|
-
return;
|
|
12
|
-
}
|
|
13
|
-
} catch {
|
|
14
|
-
}
|
|
15
|
-
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
16
|
-
}
|
|
1
|
+
import { isRec } from "./utils";
|
|
17
2
|
|
|
18
3
|
export function normalizeFilePath(record: Record<string, unknown>): void {
|
|
19
4
|
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
@@ -31,9 +16,5 @@ export function normReq(input: unknown): unknown {
|
|
|
31
16
|
|
|
32
17
|
normalizeFilePath(record);
|
|
33
18
|
|
|
34
|
-
if (has(record, "content_lines") && typeof record.content_lines === "string") {
|
|
35
|
-
tryParseContentLines(record, "content_lines");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
19
|
return record;
|
|
39
20
|
}
|
package/src/replace-render.ts
CHANGED
|
@@ -32,18 +32,14 @@ export function getPreviewInput(
|
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
if (Array.isArray(normalized.
|
|
36
|
-
return { path: normalized.path, hash_range_inclusive: ["", ""] as [string, string], content_lines: [] };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (!Array.isArray(normalized.hash_range_inclusive) || !Array.isArray(normalized.content_lines)) {
|
|
35
|
+
if (!Array.isArray(normalized.hash_bounds) || typeof normalized.new_content !== "string") {
|
|
40
36
|
return null;
|
|
41
37
|
}
|
|
42
38
|
|
|
43
39
|
const request: ReqParams = {
|
|
44
40
|
path: normalized.path,
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
hash_bounds: normalized.hash_bounds as [string, string],
|
|
42
|
+
new_content: normalized.new_content,
|
|
47
43
|
};
|
|
48
44
|
return request;
|
|
49
45
|
}
|
package/src/replace.ts
CHANGED
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
type LineEnding,
|
|
13
13
|
} from "./replace-diff";
|
|
14
14
|
import { readNormFile } from "./file-reader";
|
|
15
|
-
import { normReq, normalizeFilePath
|
|
16
|
-
import { isRec,
|
|
15
|
+
import { normReq, normalizeFilePath } from "./replace-normalize";
|
|
16
|
+
import { isRec, rejectUnknownFields, abortIf } from "./utils";
|
|
17
17
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
18
18
|
import { applyEdit,
|
|
19
19
|
lineHashes,
|
|
@@ -45,12 +45,12 @@ import { loadP, loadGuide } from "./prompts";
|
|
|
45
45
|
import { saveUndo } from "./replace-undo";
|
|
46
46
|
import { loadHashStore, type HashStore } from "./hash-store";
|
|
47
47
|
|
|
48
|
-
const
|
|
48
|
+
const newContentSchema = Type.String({
|
|
49
49
|
description:
|
|
50
|
-
"Replacement content
|
|
50
|
+
"Replacement content as a single string with \\n line separators; a trailing newline is the last line's ending, not an extra empty line. Use \"\" to delete the range."
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
const
|
|
53
|
+
const hashBoundsSchema = Type.Array(
|
|
54
54
|
Type.String({ description: "A 3-char HASH from read output" }),
|
|
55
55
|
{
|
|
56
56
|
description: "Pair of 3-char hashes from read output marking the first and last line of the range to replace (inclusive).",
|
|
@@ -62,15 +62,15 @@ const hashRangeInclSchema = Type.Array(
|
|
|
62
62
|
export const editToolSchema = Type.Object(
|
|
63
63
|
{
|
|
64
64
|
path: Type.String({ description: "Path to edit" }),
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
hash_bounds: hashBoundsSchema,
|
|
66
|
+
new_content: newContentSchema,
|
|
67
67
|
},
|
|
68
68
|
{ additionalProperties: false },
|
|
69
69
|
);
|
|
70
70
|
export type ReqParams = {
|
|
71
71
|
path: string;
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
hash_bounds: [string, string];
|
|
73
|
+
new_content: string;
|
|
74
74
|
};
|
|
75
75
|
|
|
76
76
|
export type ReplaceDetails = {
|
|
@@ -100,20 +100,7 @@ interface PipelineResult {
|
|
|
100
100
|
|
|
101
101
|
const PREVIEW_DEBOUNCE_MS = 150;
|
|
102
102
|
|
|
103
|
-
const ROOT_KS = new Set(["path", "
|
|
104
|
-
|
|
105
|
-
const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines", "changes"];
|
|
106
|
-
|
|
107
|
-
export function assertNoLegacyKeys(request: unknown): void {
|
|
108
|
-
if (!isRec(request)) return;
|
|
109
|
-
for (const legacyKey of LEGACY_KS) {
|
|
110
|
-
if (has(request, legacyKey)) {
|
|
111
|
-
throw new Error(
|
|
112
|
-
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}.`
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
103
|
+
const ROOT_KS = new Set(["path", "new_content", "hash_bounds"]);
|
|
117
104
|
|
|
118
105
|
export function assertReq(
|
|
119
106
|
request: unknown,
|
|
@@ -122,17 +109,15 @@ export function assertReq(
|
|
|
122
109
|
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
123
110
|
}
|
|
124
111
|
|
|
125
|
-
assertNoLegacyKeys(request);
|
|
126
|
-
|
|
127
112
|
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
128
113
|
|
|
129
114
|
if (typeof request.path !== "string" || request.path.length === 0) {
|
|
130
115
|
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
|
|
131
116
|
}
|
|
132
117
|
|
|
133
|
-
if (!Array.isArray(request.
|
|
118
|
+
if (!Array.isArray(request.hash_bounds) || typeof request.new_content !== "string") {
|
|
134
119
|
throw new Error(
|
|
135
|
-
'[E_BAD_SHAPE] Edit request requires both "
|
|
120
|
+
'[E_BAD_SHAPE] Edit request requires both "hash_bounds" and "new_content" at the top level.',
|
|
136
121
|
);
|
|
137
122
|
}
|
|
138
123
|
}
|
|
@@ -149,8 +134,8 @@ function collectRemovedHashes(
|
|
|
149
134
|
originalHashes: string[],
|
|
150
135
|
): Set<string> {
|
|
151
136
|
const removedHashes = new Set<string>();
|
|
152
|
-
const startHash = edit.
|
|
153
|
-
const endHash = edit.
|
|
137
|
+
const startHash = edit.hash_bounds[0].hash;
|
|
138
|
+
const endHash = edit.hash_bounds[1].hash;
|
|
154
139
|
const startLine = originalHashes.indexOf(startHash);
|
|
155
140
|
const endLine = originalHashes.indexOf(endHash);
|
|
156
141
|
if (startLine >= 0 && endLine >= 0) {
|
|
@@ -171,8 +156,8 @@ function countLineChanges(
|
|
|
171
156
|
): { totalAddedLines: number; totalRemovedLines: number } {
|
|
172
157
|
if (isNoop) return { totalAddedLines: 0, totalRemovedLines: 0 };
|
|
173
158
|
let totalRemovedLines = 0;
|
|
174
|
-
const startLine = originalHashes.indexOf(edit.
|
|
175
|
-
const endLine = originalHashes.indexOf(edit.
|
|
159
|
+
const startLine = originalHashes.indexOf(edit.hash_bounds[0].hash);
|
|
160
|
+
const endLine = originalHashes.indexOf(edit.hash_bounds[1].hash);
|
|
176
161
|
if (startLine >= 0 && endLine >= 0) {
|
|
177
162
|
totalRemovedLines = Math.abs(endLine - startLine) + 1;
|
|
178
163
|
}
|
|
@@ -190,16 +175,16 @@ export async function execPipeline(
|
|
|
190
175
|
|
|
191
176
|
const path = params.path;
|
|
192
177
|
|
|
193
|
-
const
|
|
178
|
+
const edit = resEdit({
|
|
179
|
+
hash_bounds: params.hash_bounds,
|
|
180
|
+
new_content: params.new_content,
|
|
181
|
+
});
|
|
194
182
|
|
|
183
|
+
const hashStore = options?.store ?? await loadHashStore();
|
|
195
184
|
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
|
|
196
185
|
path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore },
|
|
197
186
|
);
|
|
198
187
|
|
|
199
|
-
const edit = resEdit({
|
|
200
|
-
hash_range_inclusive: params.hash_range_inclusive,
|
|
201
|
-
content_lines: params.content_lines,
|
|
202
|
-
});
|
|
203
188
|
const anchorResult = applyEdit(
|
|
204
189
|
originalNormalized,
|
|
205
190
|
edit,
|
|
@@ -307,13 +292,9 @@ export function buildToolDef(): ToolDef {
|
|
|
307
292
|
promptSnippet: E_SNIPPET,
|
|
308
293
|
promptGuidelines: E_GUIDE,
|
|
309
294
|
prepareArguments: (args: unknown) => {
|
|
310
|
-
assertNoLegacyKeys(args);
|
|
311
295
|
if (!isRec(args)) return args as any;
|
|
312
296
|
const record = { ...args };
|
|
313
297
|
normalizeFilePath(record);
|
|
314
|
-
if (has(record, "content_lines") && typeof record.content_lines === "string") {
|
|
315
|
-
tryParseContentLines(record, "content_lines");
|
|
316
|
-
}
|
|
317
298
|
return record;
|
|
318
299
|
},
|
|
319
300
|
renderShell: "default",
|