pi-hashline-edit-pro 6.5.0 → 7.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 +59 -60
- package/package.json +2 -2
- package/src/anchor-registry.ts +15 -22
- package/src/auto-read-all.ts +2 -2
- package/src/constants.ts +1 -1
- package/src/copy-move.ts +11 -10
- package/src/edit-common.ts +11 -20
- package/src/edit-fidelity.ts +20 -1
- package/src/grep.ts +1 -10
- package/src/hashline/alphabet.ts +3 -3
- package/src/hashline/anchor-table.json +1 -1
- package/src/hashline/parse.ts +21 -14
- package/src/hashline/resolve.ts +23 -9
- package/src/insert.ts +9 -13
- package/src/payload-contract.ts +30 -22
- package/src/prompts.ts +1 -1
- package/src/read.ts +4 -6
- package/src/replace-diff.ts +1 -1
- package/src/replace-match.ts +36 -28
- package/src/replace-response.ts +5 -4
- package/src/replace.ts +12 -2
- package/src/write-hook.ts +4 -3
- package/tool-prompts/copy-guidelines.md +1 -1
- package/tool-prompts/copy.md +1 -1
- package/tool-prompts/grep-guidelines.md +1 -2
- package/tool-prompts/grep.md +1 -1
- package/tool-prompts/insert.md +1 -1
- package/tool-prompts/move.md +2 -2
- package/tool-prompts/read.md +1 -1
- package/tool-prompts/replace-match-guidelines.md +1 -1
- package/tool-prompts/replace-match-snippet.md +1 -1
- package/tool-prompts/replace-match.md +1 -1
- package/tool-prompts/replace.md +1 -1
- package/tool-prompts/transfer-guidelines.md +1 -1
package/src/hashline/parse.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ANCHOR_LEN,
|
|
3
|
-
|
|
3
|
+
HASH_RUN,
|
|
4
4
|
} from "./hash";
|
|
5
5
|
import { ALPH_RE } from "./alphabet";
|
|
6
6
|
import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
|
|
7
7
|
import { splitWithEndings } from "../line-endings";
|
|
8
8
|
import type { LineEnding } from "../normalize";
|
|
9
9
|
|
|
10
|
-
const HASH_EXTRACT_RE = new RegExp(
|
|
10
|
+
const HASH_EXTRACT_RE = new RegExp(HASH_RUN);
|
|
11
11
|
|
|
12
12
|
export type Anchor = { hash: string };
|
|
13
13
|
|
|
@@ -15,11 +15,11 @@ function diagRef(ref: string): string {
|
|
|
15
15
|
const trimmed = ref.trim();
|
|
16
16
|
|
|
17
17
|
if (!trimmed.length) {
|
|
18
|
-
return `[E_BAD_REF] Invalid anchor. Expected a
|
|
18
|
+
return `[E_BAD_REF] Invalid anchor. Expected a 5-character anchor (lowercase letters, e.g. "abcde") from read or grep output.`;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
if (/^\d+/.test(trimmed)) {
|
|
22
|
-
return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "
|
|
22
|
+
return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "abcde") with no line numbers or trailing content; anchors come from a read or grep row and are exactly 5 lowercase letters.`;
|
|
23
23
|
}
|
|
24
24
|
if (trimmed.includes("│") && trimmed.includes("\n")) {
|
|
25
25
|
const lines = trimmed.split(/\r?\n/);
|
|
@@ -28,29 +28,36 @@ function diagRef(ref: string): string {
|
|
|
28
28
|
const hashRe = HASH_EXTRACT_RE;
|
|
29
29
|
const firstMatch = first.match(hashRe);
|
|
30
30
|
const lastMatch = last.match(hashRe);
|
|
31
|
-
const firstHash = firstMatch?.[0] ?? "
|
|
32
|
-
const lastHash = lastMatch?.[0] ?? "
|
|
31
|
+
const firstHash = firstMatch?.[0] ?? "abcde";
|
|
32
|
+
const lastHash = lastMatch?.[0] ?? "abcde";
|
|
33
33
|
const preview = first.slice(0, 60);
|
|
34
|
-
return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare
|
|
34
|
+
return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 5-character anchor (lowercase letters, e.g. "abcde"), not a block with HASH│content. Received ${lines.length} lines starting "${preview}…" — use only the first hash "${firstHash}" as remove_from and "${lastHash}" as remove_to, and put the new content (without HASH│) in text.`;
|
|
35
35
|
}
|
|
36
36
|
if (trimmed.includes("│")) {
|
|
37
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the
|
|
37
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed.slice(0, 60)}": use only the 5-character anchor, drop everything from "│" onward (any line number, gutter, and content).`;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a
|
|
40
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed.slice(0, 60)}". Expected a 5-character anchor (lowercase letters, e.g. "abcde") from read or grep output.`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export function parseHashRef(ref: string): Anchor {
|
|
43
|
+
export function parseHashRef(ref: string, warnings?: string[], label = "anchor"): Anchor {
|
|
44
44
|
const trimmed = ref.trim();
|
|
45
|
+
const unquoted = trimmed.length >= 2 && (trimmed[0] === '"' || trimmed[0] === "'") && trimmed[trimmed.length - 1] === trimmed[0]
|
|
46
|
+
? trimmed.slice(1, -1).trim()
|
|
47
|
+
: trimmed;
|
|
45
48
|
|
|
46
49
|
if (
|
|
47
|
-
|
|
48
|
-
ALPH_RE.test(
|
|
50
|
+
unquoted.length === ANCHOR_LEN &&
|
|
51
|
+
ALPH_RE.test(unquoted)
|
|
49
52
|
) {
|
|
50
|
-
|
|
53
|
+
const hash = unquoted.toLowerCase();
|
|
54
|
+
if (hash !== unquoted) {
|
|
55
|
+
warnings?.push(`[W_ANCHOR_CASE] ${label} "${unquoted}" was lowercased to "${hash}".`);
|
|
56
|
+
}
|
|
57
|
+
return { hash };
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
throw new Error(diagRef(
|
|
60
|
+
throw new Error(diagRef(unquoted));
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
const JSON_ENVELOPE_RE = /^\s*\["(.*)"\]\.\s*$/;
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -130,17 +130,17 @@ const ITEM_KS = new Set(["text", "remove_from", "remove_to"]);
|
|
|
130
130
|
function assertBounds(edit: Record<string, unknown>): void {
|
|
131
131
|
if ("remove_from" in edit && typeof edit.remove_from !== "string") {
|
|
132
132
|
throw new Error(
|
|
133
|
-
`[E_BAD_SHAPE] Field "remove_from" must be an anchor string (
|
|
133
|
+
`[E_BAD_SHAPE] Field "remove_from" must be an anchor string (5-char anchor).`,
|
|
134
134
|
);
|
|
135
135
|
}
|
|
136
136
|
if ("remove_to" in edit && typeof edit.remove_to !== "string") {
|
|
137
137
|
throw new Error(
|
|
138
|
-
`[E_BAD_SHAPE] Field "remove_to" must be an anchor string (
|
|
138
|
+
`[E_BAD_SHAPE] Field "remove_to" must be an anchor string (5-char anchor).`,
|
|
139
139
|
);
|
|
140
140
|
}
|
|
141
141
|
if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
|
|
142
142
|
throw new Error(
|
|
143
|
-
`[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (
|
|
143
|
+
`[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (5-char anchors from read output).`,
|
|
144
144
|
);
|
|
145
145
|
}
|
|
146
146
|
}
|
|
@@ -175,18 +175,32 @@ function resolveParsedEdit(removeFrom: string, removeTo: string, parsed: ParsedT
|
|
|
175
175
|
}) as [string, string];
|
|
176
176
|
return {
|
|
177
177
|
content_lines: replaceLines,
|
|
178
|
-
hash_bounds: [parseHashRef(bounds[0]), parseHashRef(bounds[1])],
|
|
178
|
+
hash_bounds: [parseHashRef(bounds[0], warnings, "remove_from"), parseHashRef(bounds[1], warnings, "remove_to")],
|
|
179
179
|
...(parsed.separators.some((separator) => separator !== undefined) ? { content_separators: parsed.separators } : {}),
|
|
180
180
|
};
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
export const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
|
|
184
184
|
|
|
185
|
+
const NUMBERED_ANCHOR_ROW_RE = new RegExp(`^\\s*\\d+\\s*[:-]?\\s*│\\s*(${HASH_RUN})│`);
|
|
186
|
+
|
|
187
|
+
const PAREN_LINE_SUFFIX_RE = new RegExp(`^(${HASH_RUN})\\s*\\(\\s*(?:line|lines)\\s+\\d+(?:\\s*[-–—]\\s*\\d+)?\\s*\\)$`, "i");
|
|
188
|
+
|
|
185
189
|
export function stripAnchorRow(
|
|
186
190
|
trimmed: string,
|
|
187
191
|
entryLabel: string,
|
|
188
192
|
warnings?: string[],
|
|
189
193
|
): string {
|
|
194
|
+
const numbered = trimmed.match(NUMBERED_ANCHOR_ROW_RE);
|
|
195
|
+
if (numbered) {
|
|
196
|
+
warnings?.push(`[W_BAD_REF] Stripped line-numbered row from ${entryLabel} "${clipLine(trimmed, 48)}".`);
|
|
197
|
+
return numbered[1]!;
|
|
198
|
+
}
|
|
199
|
+
const paren = trimmed.match(PAREN_LINE_SUFFIX_RE);
|
|
200
|
+
if (paren) {
|
|
201
|
+
warnings?.push(`[W_BAD_REF] Stripped "(line N)" suffix from ${entryLabel} "${clipLine(trimmed, 48)}".`);
|
|
202
|
+
return paren[1]!;
|
|
203
|
+
}
|
|
190
204
|
const match = trimmed.match(ANCHOR_ROW_RE);
|
|
191
205
|
if (!match) return trimmed;
|
|
192
206
|
const marker =
|
|
@@ -435,17 +449,17 @@ export function assertRangeServed(
|
|
|
435
449
|
rows.push(fmtRow(hash, clipLine(content)));
|
|
436
450
|
}
|
|
437
451
|
const location = filePath ? ` in ${filePath}` : "";
|
|
452
|
+
const rangeDescription = `the replaced range (lines ${startLine}-${endLine})${location}`;
|
|
438
453
|
const first = mismatchLines[0]!;
|
|
439
|
-
const mismatchText =
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
: `${mismatchLines.length} of ${rangeLength} line(s) in the replaced range (lines ${startLine}-${endLine})${location} do not match`;
|
|
454
|
+
const mismatchText = mismatchLines.length === 1
|
|
455
|
+
? `Line ${first} of ${rangeDescription} was never shown or changed on disk`
|
|
456
|
+
: `${mismatchLines.length} of ${rangeLength} line(s) in ${rangeDescription} were never shown or changed on disk`;
|
|
443
457
|
const capHint =
|
|
444
458
|
rangeLength > shownLength
|
|
445
459
|
? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read()${filePath ? ` on ${filePath}` : ""} with offset=${startLine + shownLength} to see the rest.]`
|
|
446
460
|
: "\n\nRetry with the fresh anchors above without a read.";
|
|
447
461
|
const message =
|
|
448
|
-
`[E_RANGE_STALE] ${mismatchText}
|
|
462
|
+
`[E_RANGE_STALE] ${mismatchText}. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
|
|
449
463
|
throw new RangeStaleError(message, shownHashes, shownMap);
|
|
450
464
|
}
|
|
451
465
|
|
package/src/insert.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { LineEnding } from "./normalize";
|
|
|
11
11
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
12
12
|
import { withAnchorSession } from "./anchor-registry";
|
|
13
13
|
import { loadP, loadGuidelines } from "./prompts";
|
|
14
|
-
import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
|
|
14
|
+
import { assertInsertReq, normReq, pathRequiredSchema, type InsertReq } from "./payload-contract";
|
|
15
15
|
import { anchorCarryIndex, isRec, literalEscapeHints, splitLines } from "./utils";
|
|
16
16
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
17
17
|
import type { RPreview, RRState } from "./replace-render";
|
|
@@ -20,23 +20,19 @@ export { assertInsertReq, type InsertReq };
|
|
|
20
20
|
|
|
21
21
|
const insertAnchorSchema = Type.String({
|
|
22
22
|
description:
|
|
23
|
-
"
|
|
23
|
+
"5-char anchor of the line to insert next to.",
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
const insertDirectionSchema = Type.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
const insertDirectionSchema = Type.Unsafe<"after" | "before">({
|
|
27
|
+
type: "string",
|
|
28
|
+
enum: ["after", "before"],
|
|
29
|
+
description: '"after" or "before"',
|
|
30
|
+
});
|
|
30
31
|
const insertTextSchema = Type.String({
|
|
31
32
|
description:
|
|
32
33
|
`The exact text to insert; an empty string inserts one blank line. ${TEXT_ENDING_NOTE} Never include the anchor line.`,
|
|
33
34
|
});
|
|
34
35
|
|
|
35
|
-
const insertPathRequiredSchema = Type.String({
|
|
36
|
-
description:
|
|
37
|
-
"Path to the file the anchor was served for; required and must match anchor ownership.",
|
|
38
|
-
});
|
|
39
|
-
|
|
40
36
|
const insertToolSchema = Type.Object(
|
|
41
37
|
{
|
|
42
38
|
anchor: insertAnchorSchema,
|
|
@@ -50,7 +46,7 @@ export function buildInsertToolSchema(requirePath: boolean): typeof insertToolSc
|
|
|
50
46
|
if (!requirePath) return insertToolSchema;
|
|
51
47
|
return Type.Object(
|
|
52
48
|
{
|
|
53
|
-
path:
|
|
49
|
+
path: pathRequiredSchema,
|
|
54
50
|
anchor: insertAnchorSchema,
|
|
55
51
|
direction: insertDirectionSchema,
|
|
56
52
|
text: insertTextSchema,
|
|
@@ -63,7 +59,7 @@ export function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[
|
|
|
63
59
|
const trimmedAnchor = raw.trim();
|
|
64
60
|
const warnings: string[] = [];
|
|
65
61
|
const anchorText = stripAnchorRow(trimmedAnchor, "anchor entry", warnings);
|
|
66
|
-
return { ref: parseHashRef(anchorText), warnings };
|
|
62
|
+
return { ref: parseHashRef(anchorText, warnings, "anchor"), warnings };
|
|
67
63
|
}
|
|
68
64
|
|
|
69
65
|
export function buildInsertEdit(
|
package/src/payload-contract.ts
CHANGED
|
@@ -10,14 +10,14 @@ const textSchema = Type.String({
|
|
|
10
10
|
|
|
11
11
|
const removeFromSchema = Type.String({
|
|
12
12
|
description:
|
|
13
|
-
"
|
|
13
|
+
"5-char anchor of the FIRST line to remove.",
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
const removeToSchema = Type.String({
|
|
17
17
|
description:
|
|
18
|
-
"
|
|
18
|
+
"5-char anchor of the LAST line to remove.",
|
|
19
19
|
});
|
|
20
|
-
const pathRequiredSchema = Type.String({
|
|
20
|
+
export const pathRequiredSchema = Type.String({
|
|
21
21
|
description:
|
|
22
22
|
"Path to the file the anchors were served for; required and must match anchor ownership.",
|
|
23
23
|
});
|
|
@@ -59,11 +59,19 @@ export type RawReqParams = {
|
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
const ROOT_KS = new Set(["path", "remove_from", "remove_to", "text"]);
|
|
62
|
+
|
|
63
|
+
const REPLACE_MATCH_SHAPE_KEYS = ["match", "replacement", "old_string", "new_string"];
|
|
64
|
+
|
|
65
|
+
function replaceMatchFieldHint(request: Record<string, unknown>): string | undefined {
|
|
66
|
+
if (!REPLACE_MATCH_SHAPE_KEYS.some((key) => key in request)) return undefined;
|
|
67
|
+
return 'This looks like a replace_match request: to change part of a line, call replace_match with "replace_from", "replace_to", "match", and "replacement"; replace takes "remove_from", "remove_to", and "text".';
|
|
68
|
+
}
|
|
69
|
+
|
|
62
70
|
export function assertReq(request: unknown): asserts request is ReqParams {
|
|
63
71
|
if (!isRec(request)) {
|
|
64
72
|
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
65
73
|
}
|
|
66
|
-
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
74
|
+
rejectUnknownFields(request, ROOT_KS, "Edit request", replaceMatchFieldHint(request));
|
|
67
75
|
if (request.path !== undefined && typeof request.path !== "string") {
|
|
68
76
|
throw new Error('[E_BAD_SHAPE] Edit request field "path" must be a string when provided.');
|
|
69
77
|
}
|
|
@@ -124,7 +132,7 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
|
|
|
124
132
|
throw new Error('[E_BAD_SHAPE] Insert request field "path" must be a string when provided.');
|
|
125
133
|
}
|
|
126
134
|
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
127
|
-
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (
|
|
135
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (5-char anchor from read output).');
|
|
128
136
|
}
|
|
129
137
|
if (request.direction !== "before" && request.direction !== "after") {
|
|
130
138
|
throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
|
|
@@ -154,13 +162,13 @@ export function assertTransferReq(request: unknown): asserts request is Transfer
|
|
|
154
162
|
throw new Error('[E_BAD_SHAPE] Copy/move request field "path" must be a string when provided.');
|
|
155
163
|
}
|
|
156
164
|
if (typeof request.source_from !== "string" || request.source_from.length === 0) {
|
|
157
|
-
throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_from" string (
|
|
165
|
+
throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_from" string (5-char anchor from read output).');
|
|
158
166
|
}
|
|
159
167
|
if (typeof request.source_to !== "string" || request.source_to.length === 0) {
|
|
160
|
-
throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_to" string (
|
|
168
|
+
throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_to" string (5-char anchor from read output).');
|
|
161
169
|
}
|
|
162
170
|
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
163
|
-
throw new Error('[E_BAD_SHAPE] Copy/move request requires an "anchor" string (
|
|
171
|
+
throw new Error('[E_BAD_SHAPE] Copy/move request requires an "anchor" string (5-char anchor from read output).');
|
|
164
172
|
}
|
|
165
173
|
if (request.direction !== "before" && request.direction !== "after") {
|
|
166
174
|
throw new Error('[E_BAD_SHAPE] Copy/move request "direction" must be "before" or "after" (the anchor is the destination line).');
|
|
@@ -177,22 +185,27 @@ export interface ReplaceMatchReq {
|
|
|
177
185
|
replacement: string;
|
|
178
186
|
}
|
|
179
187
|
|
|
180
|
-
export function
|
|
188
|
+
export function singleLineMatch(raw: string): string {
|
|
181
189
|
const text = toLF(raw);
|
|
182
190
|
const fragment = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
183
191
|
const lineBreak = fragment.indexOf("\n");
|
|
184
192
|
if (lineBreak >= 0) {
|
|
185
193
|
const column = [...fragment.slice(0, lineBreak)].length + 1;
|
|
186
194
|
throw new Error(
|
|
187
|
-
`[E_BAD_SHAPE]
|
|
195
|
+
`[E_BAD_SHAPE] match must stay within one line, but the value contains a line break at column ${column}. A trailing line break is accepted as the line's own terminator; a match that spans lines belongs to replace.`,
|
|
188
196
|
);
|
|
189
197
|
}
|
|
190
|
-
if (
|
|
198
|
+
if (fragment.length === 0) {
|
|
191
199
|
throw new Error('[E_BAD_SHAPE] Replace-match request field "match" must be a non-empty string holding the exact text to find.');
|
|
192
200
|
}
|
|
193
201
|
return fragment;
|
|
194
202
|
}
|
|
195
203
|
|
|
204
|
+
export function replacementFragment(raw: string): string {
|
|
205
|
+
const text = toLF(raw);
|
|
206
|
+
return text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
207
|
+
}
|
|
208
|
+
|
|
196
209
|
export function assertReplaceMatchReq(request: unknown): asserts request is ReplaceMatchReq {
|
|
197
210
|
if (!isRec(request)) {
|
|
198
211
|
throw new Error("[E_BAD_SHAPE] Replace-match request must be an object.");
|
|
@@ -213,8 +226,7 @@ export function assertReplaceMatchReq(request: unknown): asserts request is Repl
|
|
|
213
226
|
if (within.replace_from.length === 0 || within.replace_to.length === 0) {
|
|
214
227
|
throw new Error('[E_BAD_SHAPE] Replace-match request requires non-empty "replace_from" and "replace_to" anchors.');
|
|
215
228
|
}
|
|
216
|
-
|
|
217
|
-
singleLineFragment("replacement", within.replacement);
|
|
229
|
+
singleLineMatch(within.match);
|
|
218
230
|
assertNoNul([within.replacement]);
|
|
219
231
|
}
|
|
220
232
|
|
|
@@ -245,23 +257,19 @@ export function getReplaceMatchInput(args: unknown): { path?: string; replace_fr
|
|
|
245
257
|
|
|
246
258
|
const replaceMatchFromSchema = Type.String({
|
|
247
259
|
description:
|
|
248
|
-
"
|
|
260
|
+
"5-char anchor of the FIRST line of the range searched.",
|
|
249
261
|
});
|
|
250
262
|
const replaceMatchToSchema = Type.String({
|
|
251
263
|
description:
|
|
252
|
-
"
|
|
264
|
+
"5-char anchor of the LAST line of the range searched; same as `replace_from` for a single line.",
|
|
253
265
|
});
|
|
254
266
|
const replaceMatchMatchSchema = Type.String({
|
|
255
267
|
description:
|
|
256
|
-
"The exact text to find inside the selected lines, copied from the served row: only the fragment that changes, never text that `replacement` leaves unchanged. It must stay within one line and hold text: a single trailing line break is accepted as the line's own terminator, a line break anywhere else is refused, and a value that is only a line break leaves nothing to find and is refused. Every occurrence inside the range is replaced; the text around each match is left untouched.
|
|
268
|
+
"The exact text to find inside the selected lines, copied from the served row: only the fragment that changes, never text that `replacement` leaves unchanged. It must stay within one line and hold text: a single trailing line break is accepted as the line's own terminator, a line break anywhere else is refused, and a value that is only a line break leaves nothing to find and is refused. Every occurrence inside the range is replaced; the text around each match is left untouched.",
|
|
257
269
|
});
|
|
258
270
|
const replaceMatchReplacementSchema = Type.String({
|
|
259
271
|
description:
|
|
260
|
-
`The exact replacement for every matched occurrence within the range. "" deletes the matches.
|
|
261
|
-
});
|
|
262
|
-
const replaceMatchPathRequiredSchema = Type.String({
|
|
263
|
-
description:
|
|
264
|
-
"Path to the file the anchors were served for; required and must match anchor ownership.",
|
|
272
|
+
`The exact replacement for every matched occurrence within the range. "" deletes the matches. A single trailing line break is accepted and dropped (a value that is only a line break counts as ""); an interior or leading line break splits the replacement into lines, and new lines inherit the file's line endings. The text around each match is kept byte-for-byte.`,
|
|
265
273
|
});
|
|
266
274
|
|
|
267
275
|
export const replaceMatchToolSchema = Type.Object(
|
|
@@ -278,7 +286,7 @@ export function buildReplaceMatchToolSchema(requirePath: boolean): typeof replac
|
|
|
278
286
|
if (!requirePath) return replaceMatchToolSchema;
|
|
279
287
|
return Type.Object(
|
|
280
288
|
{
|
|
281
|
-
path:
|
|
289
|
+
path: pathRequiredSchema,
|
|
282
290
|
replace_from: replaceMatchFromSchema,
|
|
283
291
|
replace_to: replaceMatchToSchema,
|
|
284
292
|
match: replaceMatchMatchSchema,
|
package/src/prompts.ts
CHANGED
|
@@ -14,7 +14,7 @@ const GUIDELINE_IDS_BY_NAME: Record<string, readonly string[]> = {
|
|
|
14
14
|
"copy-guidelines.md": ["copy-single-anchor"],
|
|
15
15
|
"move-guidelines.md": ["move-same-place"],
|
|
16
16
|
"transfer-guidelines.md": ["transfer-boundary-serving", "transfer-same-file", "transfer-cross-file", "transfer-batch-move"],
|
|
17
|
-
"grep-guidelines.md": ["grep-literal"
|
|
17
|
+
"grep-guidelines.md": ["grep-literal"],
|
|
18
18
|
"undo-last-change-guidelines.md": ["undo-single-level", "undo-write-clears", "undo-cross-file-move"],
|
|
19
19
|
};
|
|
20
20
|
|
package/src/read.ts
CHANGED
|
@@ -17,9 +17,9 @@ import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES, parseHashRef,
|
|
|
17
17
|
import { toCwd } from "./paths";
|
|
18
18
|
import { abortIf, isRec, makePrepareArguments, numberedRead, visLines, splitLines } from "./utils";
|
|
19
19
|
import { loadP, loadGuidelines, type Guideline } from "./prompts";
|
|
20
|
-
import { withReadPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
20
|
+
import { withReadPrompts, DEFAULT_EDIT_FLAGS, staleAnchorMessage, type EditToolFlags } from "./edit-common";
|
|
21
21
|
import { valAccess } from "./validation";
|
|
22
|
-
import { withAnchorSession, adoptAnchors, ownerOf,
|
|
22
|
+
import { withAnchorSession, adoptAnchors, ownerOf, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
23
23
|
import { serveRows } from "./served";
|
|
24
24
|
import { snapshotCache } from "./hash-store/cache";
|
|
25
25
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -208,9 +208,7 @@ export function resolveReadOffset(
|
|
|
208
208
|
const ref = parseHashRef(offset);
|
|
209
209
|
const owner = ownerOf(ref.hash);
|
|
210
210
|
if (owner === undefined) {
|
|
211
|
-
|
|
212
|
-
const hint = folded.length > 0 ? ` Anchors are case-sensitive; ${folded.map((match) => `"${match.anchor}"`).join(", ")} differs only in case.` : "";
|
|
213
|
-
throw new Error(`[E_STALE_ANCHOR] "${ref.hash}" is not owned in this session.${hint} Use a line number, or read again for fresh anchors.`);
|
|
211
|
+
throw new Error(staleAnchorMessage(ref.hash, "Use a line number, or read again for fresh anchors."));
|
|
214
212
|
}
|
|
215
213
|
if (owner.path !== resolvedPath) {
|
|
216
214
|
throw new Error(`[E_STALE_ANCHOR] "${ref.hash}" is owned by ${owner.path}. Use a line number, or read ${owner.path} to use that anchor.`);
|
|
@@ -320,7 +318,7 @@ function buildReadToolSchema(codemode: boolean) {
|
|
|
320
318
|
limit: Type.Optional(
|
|
321
319
|
Type.Integer({
|
|
322
320
|
minimum: 1,
|
|
323
|
-
description: "Maximum number of lines to read from offset",
|
|
321
|
+
description: "Maximum number of lines to read from offset; counted from the anchor row when `offset` is an anchor",
|
|
324
322
|
}),
|
|
325
323
|
),
|
|
326
324
|
...(codemode
|
package/src/replace-diff.ts
CHANGED
|
@@ -114,7 +114,7 @@ export function disambiguateDuplicateAnchors(diff: string): string {
|
|
|
114
114
|
if (!match || match[1] !== "-") return line;
|
|
115
115
|
if (!live.has(match[2]!)) return line;
|
|
116
116
|
changed = true;
|
|
117
|
-
return
|
|
117
|
+
return `-${" ".repeat(ANCHOR_LEN)}│${line.slice(match[0].length)}`;
|
|
118
118
|
});
|
|
119
119
|
return changed ? out.join("\n") : diff;
|
|
120
120
|
}
|
package/src/replace-match.ts
CHANGED
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
buildReplaceMatchToolSchema,
|
|
13
13
|
getReplaceMatchInput,
|
|
14
14
|
normReq,
|
|
15
|
-
|
|
15
|
+
replacementFragment,
|
|
16
|
+
singleLineMatch,
|
|
16
17
|
type ReplaceMatchReq,
|
|
17
18
|
} from "./payload-contract";
|
|
18
19
|
import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
|
|
@@ -46,26 +47,6 @@ export interface MatchPlan {
|
|
|
46
47
|
servedOverride?: ReadonlyMap<string, string>;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
function replaceAllOccurrences(
|
|
50
|
-
line: string,
|
|
51
|
-
matchText: string,
|
|
52
|
-
replacementText: string,
|
|
53
|
-
): { text: string; count: number } {
|
|
54
|
-
const parts: string[] = [];
|
|
55
|
-
let cursor = 0;
|
|
56
|
-
let count = 0;
|
|
57
|
-
for (;;) {
|
|
58
|
-
const found = line.indexOf(matchText, cursor);
|
|
59
|
-
if (found < 0) break;
|
|
60
|
-
parts.push(line.slice(cursor, found), replacementText);
|
|
61
|
-
cursor = found + matchText.length;
|
|
62
|
-
count += 1;
|
|
63
|
-
}
|
|
64
|
-
if (count === 0) return { text: line, count: 0 };
|
|
65
|
-
parts.push(line.slice(cursor));
|
|
66
|
-
return { text: parts.join(""), count };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
50
|
function linesToPayloadText(lines: string[]): string {
|
|
70
51
|
if (lines.length === 0) return "";
|
|
71
52
|
const joined = lines.join("\n");
|
|
@@ -143,7 +124,33 @@ export function parseMatchAnchors(req: ReplaceMatchReq): { refs: MatchRefs; warn
|
|
|
143
124
|
const warnings: string[] = [];
|
|
144
125
|
const from = stripAnchorRow(req.replace_from.trim(), "replace_from entry", warnings);
|
|
145
126
|
const to = stripAnchorRow(req.replace_to.trim(), "replace_to entry", warnings);
|
|
146
|
-
return { refs: { from: parseHashRef(from), to: parseHashRef(to) }, warnings };
|
|
127
|
+
return { refs: { from: parseHashRef(from, warnings, "replace_from"), to: parseHashRef(to, warnings, "replace_to") }, warnings };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function replacementLinesFor(raw: string): string[] {
|
|
131
|
+
const fragment = replacementFragment(raw);
|
|
132
|
+
return fragment.length === 0 ? [""] : fragment.split("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function replaceInLine(
|
|
136
|
+
line: string,
|
|
137
|
+
matchText: string,
|
|
138
|
+
replacementLines: readonly string[],
|
|
139
|
+
): { lines: string[]; count: number } {
|
|
140
|
+
const lines: string[] = [""];
|
|
141
|
+
let cursor = 0;
|
|
142
|
+
let count = 0;
|
|
143
|
+
for (;;) {
|
|
144
|
+
const found = line.indexOf(matchText, cursor);
|
|
145
|
+
if (found < 0) break;
|
|
146
|
+
lines[lines.length - 1] += line.slice(cursor, found) + replacementLines[0]!;
|
|
147
|
+
for (let index = 1; index < replacementLines.length; index++) lines.push(replacementLines[index]!);
|
|
148
|
+
cursor = found + matchText.length;
|
|
149
|
+
count += 1;
|
|
150
|
+
}
|
|
151
|
+
if (count === 0) return { lines: [line], count: 0 };
|
|
152
|
+
lines[lines.length - 1] += line.slice(cursor);
|
|
153
|
+
return { lines, count };
|
|
147
154
|
}
|
|
148
155
|
|
|
149
156
|
export function buildReplaceMatchEdit(
|
|
@@ -153,8 +160,8 @@ export function buildReplaceMatchEdit(
|
|
|
153
160
|
displayPath: string,
|
|
154
161
|
served?: ReadonlyMap<string, string>,
|
|
155
162
|
): MatchPlan {
|
|
156
|
-
const matchText =
|
|
157
|
-
const
|
|
163
|
+
const matchText = singleLineMatch(req.match);
|
|
164
|
+
const fragmentLines = replacementLinesFor(req.replacement);
|
|
158
165
|
const fileLines = splitLines(preload.normalized);
|
|
159
166
|
const fromLine = resolveAnchorLine(refs.from, fileLines, preload.fileHashes, displayPath);
|
|
160
167
|
const toLine = resolveAnchorLine(refs.to, fileLines, preload.fileHashes, displayPath);
|
|
@@ -163,11 +170,12 @@ export function buildReplaceMatchEdit(
|
|
|
163
170
|
assertBoundaryLinesServed(fileLines, preload.fileHashes, served, start, end, displayPath);
|
|
164
171
|
const rangeLines = fileLines.slice(start - 1, end);
|
|
165
172
|
let count = 0;
|
|
166
|
-
const replacementLines =
|
|
167
|
-
|
|
173
|
+
const replacementLines: string[] = [];
|
|
174
|
+
for (const line of rangeLines) {
|
|
175
|
+
const result = replaceInLine(line, matchText, fragmentLines);
|
|
168
176
|
count += result.count;
|
|
169
|
-
|
|
170
|
-
}
|
|
177
|
+
replacementLines.push(...result.lines);
|
|
178
|
+
}
|
|
171
179
|
if (count === 0) {
|
|
172
180
|
throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, linesToPayloadText(rangeLines), matchText));
|
|
173
181
|
}
|
package/src/replace-response.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { HASH_SEP, type NEdit } from "./hashline";
|
|
|
2
2
|
import type { ReplaceDetails } from "./replace";
|
|
3
3
|
import { genDiff, genPatch, type DiffSpan } from "./replace-diff";
|
|
4
4
|
import { visLines, clipLine } from "./utils";
|
|
5
|
-
import { annotateLiteralEscapeHints, fidelityHints } from "./edit-fidelity";
|
|
5
|
+
import { annotateLiteralEscapeHints, dedupeLiteralEscapeHints, fidelityHints } from "./edit-fidelity";
|
|
6
6
|
import { anchoredLine, anchoredLinesFromDiff, diffAnchorsOmitted, toEditVerb, type EditStructured, type EditVerb, type Json } from "./structured";
|
|
7
7
|
|
|
8
8
|
export type TResult = {
|
|
@@ -164,7 +164,8 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
164
164
|
});
|
|
165
165
|
const { warnings: noticeWarnings, hints } = splitNotices(fidelity.length > 0 ? [...(warnings ?? []), ...fidelity] : warnings);
|
|
166
166
|
const annotatedHints = annotateLiteralEscapeHints(hints, result, spans, resultHashes);
|
|
167
|
-
const
|
|
167
|
+
const dedupedHints = dedupeLiteralEscapeHints(annotatedHints);
|
|
168
|
+
const noticesBlock = `${warnBlock(noticeWarnings)}${hintBlock(dedupedHints)}`;
|
|
168
169
|
const successPrefix = `Successfully ${verb} in ${path}.`;
|
|
169
170
|
const lineSummary = addedLines > 0 || removedLines > 0
|
|
170
171
|
? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
|
|
@@ -203,7 +204,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
203
204
|
text,
|
|
204
205
|
diff: diffResult.diff,
|
|
205
206
|
warnings: [...noticeWarnings],
|
|
206
|
-
hints: [...
|
|
207
|
+
hints: [...dedupedHints],
|
|
207
208
|
firstChangedLine: editMeta.firstChangedLine ?? diffResult.firstChangedLine ?? null,
|
|
208
209
|
anchors,
|
|
209
210
|
anchorsOmitted,
|
|
@@ -220,7 +221,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
220
221
|
metrics,
|
|
221
222
|
diffLineNumbers: diffResult.lineNumbers.map((line) => line ?? null),
|
|
222
223
|
...(noticeWarnings.length ? { warnings: [...noticeWarnings] } : {}),
|
|
223
|
-
...(
|
|
224
|
+
...(dedupedHints.length ? { hints: [...dedupedHints] } : {}),
|
|
224
225
|
},
|
|
225
226
|
structuredContent,
|
|
226
227
|
};
|
package/src/replace.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./replace-diff";
|
|
11
11
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
12
12
|
import { editToolSchema, buildEditToolSchema, type ReqParams, type RawReqParams, assertReq, normReq } from "./payload-contract";
|
|
13
|
-
import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
|
|
13
|
+
import { isRec, literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
|
|
14
14
|
import { editResultSchema, withStructuredErrors } from "./structured";
|
|
15
15
|
import { loadP, loadGuidelines } from "./prompts";
|
|
16
16
|
import { type FileIdentity } from "./fs-write";
|
|
@@ -293,6 +293,16 @@ type ToolDef = ToolDefinition<
|
|
|
293
293
|
RRState
|
|
294
294
|
> & { renderShell?: "default" | "self" };
|
|
295
295
|
|
|
296
|
+
const prepareRemoveArguments = makePrepareArguments("remove");
|
|
297
|
+
|
|
298
|
+
function prepareReplaceArguments(args: unknown): unknown {
|
|
299
|
+
const prepared = prepareRemoveArguments(args);
|
|
300
|
+
if (isRec(prepared) && typeof prepared.text !== "string" && ("match" in prepared || "replacement" in prepared)) {
|
|
301
|
+
throw new Error('[E_BAD_SHAPE] Replace request received "match"/"replacement" instead of "remove_from"/"remove_to"/"text". To change part of a line, call replace_match with "replace_from", "replace_to", "match", and "replacement".');
|
|
302
|
+
}
|
|
303
|
+
return prepared;
|
|
304
|
+
}
|
|
305
|
+
|
|
296
306
|
export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef {
|
|
297
307
|
const prompted = withReplacePrompts({
|
|
298
308
|
description: loadP("../tool-prompts/replace.md"),
|
|
@@ -309,7 +319,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
309
319
|
promptSnippet: prompted.snippet,
|
|
310
320
|
promptGuidelines: prompted.guidelines,
|
|
311
321
|
...editToolBase,
|
|
312
|
-
prepareArguments:
|
|
322
|
+
prepareArguments: prepareReplaceArguments,
|
|
313
323
|
renderCall: editRenderCallWrapper(compPreview),
|
|
314
324
|
renderResult: editRenderResultWrapper,
|
|
315
325
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
package/src/write-hook.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { HASH_RUN } from "./hashline/alphabet";
|
|
3
3
|
import { HASH_SEP } from "./hashline/hash";
|
|
4
4
|
import { sessionContextReadable, servedForPath, withAnchorSession, type AnchorSessionContext } from "./anchor-registry";
|
|
5
5
|
import { resolveInCwd } from "./fs-write";
|
|
6
6
|
import { abortIf, splitLines, isRec } from "./utils";
|
|
7
7
|
import type { ModelLike } from "./model-gate";
|
|
8
8
|
|
|
9
|
-
const HASH_ECHO_RE = new RegExp(`^(?: *[0-9]+(?: │ |[:-]│ ))?[+ -]?(${
|
|
9
|
+
const HASH_ECHO_RE = new RegExp(`^(?: *[0-9]+(?: │ |[:-]│ ))?[+ -]?(${HASH_RUN})${HASH_SEP}`);
|
|
10
10
|
|
|
11
11
|
function searchEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
|
|
12
12
|
for (let i = 0; i < lines.length; i++) {
|
|
13
13
|
const match = HASH_ECHO_RE.exec(lines[i]!);
|
|
14
|
-
|
|
14
|
+
const hash = match?.[1]?.toLowerCase();
|
|
15
|
+
if (hash !== undefined && served.has(hash)) return { line: i + 1, hash };
|
|
15
16
|
}
|
|
16
17
|
return undefined;
|
|
17
18
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
- `copy`: the same anchor in `source_from` and `source_to` copies one line
|
|
1
|
+
- `copy`: the same anchor in `source_from` and `source_to` copies one line.
|
package/tool-prompts/copy.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Duplicate a range of lines at another anchor
|
|
1
|
+
Duplicate a range of lines at another anchor. The source rows and anchors stay live; the copied lines get fresh anchors in the destination's post-edit diff and keep their source line endings.
|
|
2
2
|
|
|
3
3
|
A copy duplicates the content its source anchors were served from, so a same-message edit to the source file does not change what is copied.
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
- `grep`: set `literal: true`
|
|
2
|
-
- `grep`: `N:│` rows are matches and `N-│` rows are context;
|
|
1
|
+
- `grep`: set `literal: true` to match the text exactly, when characters such as `(`, `[`, `.`, or `*` in it must not act as regex.
|
package/tool-prompts/grep.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search text files with ripgrep. Hits come back as `lineNumber:│ anchor│content` rows and context lines as `lineNumber-│ anchor│content`, editable with replace, insert, copy, or move without a new read
|
|
1
|
+
Search text files with ripgrep. Hits come back as `lineNumber:│ anchor│content` rows and context lines as `lineNumber-│ anchor│content`, editable with replace, insert, copy, or move without a new read. A row returned by `grep` is already a served row, so you do not need to `read` the file to edit that line. The `=== path ===` header and line numbers locate the match. Patterns use ripgrep's Rust regex syntax (no backreferences or look-around) through a linear-time engine. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When output is cut by the match limit, raise `limit`; when cut by rows or bytes, refine `pattern` or narrow `path`.
|
package/tool-prompts/insert.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Insert text
|
|
1
|
+
Insert text next to an existing line without removing anything. The anchor line stays in place, and `anchor` is a bare 5-character anchor from any served `anchor│content` row.
|
package/tool-prompts/move.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Relocate a range of lines to another anchor
|
|
1
|
+
Relocate a range of lines to another anchor. Within one file the destination must sit outside the source range. Rows between the source and the destination keep their content but may be re-anchored, so read that span again if you plan to edit it.
|
|
2
2
|
|
|
3
|
-
A cross-file move
|
|
3
|
+
A cross-file move whose source file also gets other edits in the same message runs outside any batch: same-message edits to its destination file can then either abort or make the move fail, so send that move in a separate message.
|