pi-hashline-edit-pro 4.0.1 → 4.2.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 -12
- package/index.ts +70 -23
- package/package.json +1 -1
- package/prompts/insert-guidelines.md +2 -1
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +1 -0
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -5
- package/prompts/replace.md +1 -1
- package/src/anchor-registry.ts +11 -8
- package/src/batch.ts +614 -0
- package/src/boundary-bypass.ts +6 -0
- package/src/commit.ts +1 -1
- package/src/config-ui.ts +108 -0
- package/src/config.ts +48 -1
- package/src/edit-common.ts +115 -1
- package/src/fs-write.ts +6 -0
- package/src/grep.ts +84 -1
- package/src/hashline/alphabet.ts +1 -1
- package/src/hashline/apply.ts +128 -84
- package/src/hashline/index.ts +3 -0
- package/src/hashline/resolve.ts +5 -4
- package/src/insert.ts +127 -74
- package/src/payload-contract.ts +50 -2
- package/src/read.ts +10 -11
- package/src/replace-render.ts +2 -2
- package/src/replace-response.ts +3 -1
- package/src/replace.ts +94 -37
package/src/hashline/apply.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf, splitLines } from "../utils";
|
|
1
|
+
import { abortIf, clipLine, splitLines } from "../utils";
|
|
2
2
|
import { _lineHashesPure } from "./hash";
|
|
3
3
|
import {
|
|
4
4
|
valEdit,
|
|
@@ -52,7 +52,7 @@ type NoopSpan = {
|
|
|
52
52
|
loc: string;
|
|
53
53
|
currentContent: string;
|
|
54
54
|
};
|
|
55
|
-
function assertNotEmpty(originalContent: string, result: string): void {
|
|
55
|
+
export function assertNotEmpty(originalContent: string, result: string): void {
|
|
56
56
|
if (originalContent.length > 0 && result.length === 0) {
|
|
57
57
|
throw new Error(
|
|
58
58
|
"[E_WOULD_EMPTY] A replace cannot empty a non-empty file. Use `write` to clear the file."
|
|
@@ -148,6 +148,122 @@ function assemble(
|
|
|
148
148
|
return content.slice(0, span.start) + span.replacement + content.slice(span.end);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
export interface PlannedEdit {
|
|
152
|
+
resolved: RHEdit;
|
|
153
|
+
warnings: string[];
|
|
154
|
+
autoFixes?: AutoFix[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
function throwStrictBoundaryDedup(edit: HEdit, boundaryDups: BDup[], resolved: RHEdit): never {
|
|
159
|
+
const startLine = resolved.hash_bounds[0].line;
|
|
160
|
+
const endLine = resolved.hash_bounds[1].line;
|
|
161
|
+
const range = startLine === endLine ? `line ${startLine}` : `lines ${startLine}-${endLine}`;
|
|
162
|
+
const seen = new Set<number>();
|
|
163
|
+
for (const dup of boundaryDups) seen.add(dup.replacementLineIndex);
|
|
164
|
+
const removed = [...seen].sort((a, b) => a - b).map((index) => `replacement_lines line ${index + 1}: ${clipLine(edit.content_lines[index] ?? "")}`);
|
|
165
|
+
throw new Error(`[E_BOUNDARY_STRICT] Strict boundary dedup rejects this edit: ${seen.size} replacement line(s) re-include edge lines (${range}). ${removed.join("; ")}. Resend without those lines.`);
|
|
166
|
+
}
|
|
167
|
+
export function planEdit(
|
|
168
|
+
content: string,
|
|
169
|
+
edit: HEdit,
|
|
170
|
+
precomputedHashes: string[],
|
|
171
|
+
options?: {
|
|
172
|
+
filePath?: string;
|
|
173
|
+
servedHashes?: ReadonlyMap<string, string>;
|
|
174
|
+
skipBoundaryDedup?: boolean;
|
|
175
|
+
strictBoundaryDedup?: boolean;
|
|
176
|
+
signal?: AbortSignal;
|
|
177
|
+
},
|
|
178
|
+
): PlannedEdit {
|
|
179
|
+
const signal = options?.signal;
|
|
180
|
+
abortIf(signal);
|
|
181
|
+
const lineIndex = buildIdx(content);
|
|
182
|
+
const fileHashes = precomputedHashes;
|
|
183
|
+
const warnings: string[] = [];
|
|
184
|
+
|
|
185
|
+
const rangeFixed = swapReversedRanges(edit, fileHashes, warnings);
|
|
186
|
+
const prefixFixed = stripDiffPrefixes(
|
|
187
|
+
stripBarePrefixes(rangeFixed, fileHashes, warnings),
|
|
188
|
+
warnings,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const { resolved: initialResolved, mismatches, boundaryDups } = valEdit(
|
|
192
|
+
prefixFixed,
|
|
193
|
+
lineIndex.fileLines,
|
|
194
|
+
fileHashes,
|
|
195
|
+
warnings,
|
|
196
|
+
signal,
|
|
197
|
+
);
|
|
198
|
+
if (mismatches.length || !initialResolved) {
|
|
199
|
+
const feedback = fmtMismatchWithHashes(
|
|
200
|
+
mismatches,
|
|
201
|
+
lineIndex.fileLines,
|
|
202
|
+
fileHashes,
|
|
203
|
+
options?.filePath,
|
|
204
|
+
);
|
|
205
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
warnUnicodeEsc(prefixFixed, warnings);
|
|
209
|
+
|
|
210
|
+
if (options?.strictBoundaryDedup === true && boundaryDups.length > 0) {
|
|
211
|
+
throwStrictBoundaryDedup(prefixFixed, boundaryDups, initialResolved);
|
|
212
|
+
}
|
|
213
|
+
let resolved = initialResolved;
|
|
214
|
+
let autoFixes: AutoFix[] | undefined;
|
|
215
|
+
if (boundaryDups.length > 0 && !(options?.skipBoundaryDedup === true)) {
|
|
216
|
+
autoFixes = [];
|
|
217
|
+
const correctedEdit: HEdit = {
|
|
218
|
+
...prefixFixed,
|
|
219
|
+
content_lines: [...prefixFixed.content_lines],
|
|
220
|
+
};
|
|
221
|
+
const seen = new Set<number>();
|
|
222
|
+
const uniqueDups: BDup[] = [];
|
|
223
|
+
for (const dup of boundaryDups) {
|
|
224
|
+
if (seen.has(dup.replacementLineIndex)) continue;
|
|
225
|
+
seen.add(dup.replacementLineIndex);
|
|
226
|
+
uniqueDups.push(dup);
|
|
227
|
+
}
|
|
228
|
+
const dupsByIndex = uniqueDups.sort(
|
|
229
|
+
(a, b) => b.replacementLineIndex - a.replacementLineIndex,
|
|
230
|
+
);
|
|
231
|
+
for (const dup of dupsByIndex) {
|
|
232
|
+
const idx = dup.replacementLineIndex;
|
|
233
|
+
if (idx < 0 || idx >= correctedEdit.content_lines.length) continue;
|
|
234
|
+
const removed = correctedEdit.content_lines.splice(idx, 1)[0];
|
|
235
|
+
autoFixes.push({ kind: dup.kind, removedLine: removed, removedLineIndex: idx });
|
|
236
|
+
}
|
|
237
|
+
const correctedResult = valEdit(
|
|
238
|
+
correctedEdit,
|
|
239
|
+
lineIndex.fileLines,
|
|
240
|
+
fileHashes,
|
|
241
|
+
warnings,
|
|
242
|
+
signal,
|
|
243
|
+
);
|
|
244
|
+
if (correctedResult.mismatches.length || !correctedResult.resolved) {
|
|
245
|
+
const feedback = fmtMismatchWithHashes(
|
|
246
|
+
correctedResult.mismatches,
|
|
247
|
+
lineIndex.fileLines,
|
|
248
|
+
fileHashes,
|
|
249
|
+
options?.filePath,
|
|
250
|
+
);
|
|
251
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
252
|
+
}
|
|
253
|
+
resolved = correctedResult.resolved;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (options?.servedHashes) {
|
|
257
|
+
abortIf(signal);
|
|
258
|
+
assertRangeServed(resolved, lineIndex.fileLines, fileHashes, options.servedHashes, options?.filePath);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
resolved,
|
|
263
|
+
warnings,
|
|
264
|
+
...(autoFixes ? { autoFixes } : {}),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
151
267
|
export function applyEdit(
|
|
152
268
|
content: string,
|
|
153
269
|
edit: HEdit,
|
|
@@ -156,6 +272,7 @@ export function applyEdit(
|
|
|
156
272
|
filePath?: string,
|
|
157
273
|
servedHashes?: ReadonlyMap<string, string>,
|
|
158
274
|
skipBoundaryDedup?: boolean,
|
|
275
|
+
strictBoundaryDedup?: boolean,
|
|
159
276
|
): {
|
|
160
277
|
content: string;
|
|
161
278
|
firstChangedLine: number | undefined;
|
|
@@ -164,88 +281,15 @@ export function applyEdit(
|
|
|
164
281
|
noopEdit?: NEdit;
|
|
165
282
|
autoFixes?: AutoFix[];
|
|
166
283
|
} {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const rangeFixed = swapReversedRanges(edit, fileHashes, warnings);
|
|
177
|
-
const prefixFixed = stripDiffPrefixes(
|
|
178
|
-
stripBarePrefixes(rangeFixed, fileHashes, warnings),
|
|
179
|
-
warnings,
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
const { resolved: initialResolved, mismatches, boundaryDups } = valEdit(
|
|
183
|
-
prefixFixed,
|
|
184
|
-
lineIndex.fileLines,
|
|
185
|
-
fileHashes,
|
|
186
|
-
warnings,
|
|
187
|
-
signal,
|
|
188
|
-
);
|
|
189
|
-
if (mismatches.length || !initialResolved) {
|
|
190
|
-
const feedback = fmtMismatchWithHashes(
|
|
191
|
-
mismatches,
|
|
192
|
-
lineIndex.fileLines,
|
|
193
|
-
fileHashes,
|
|
194
|
-
filePath,
|
|
195
|
-
);
|
|
196
|
-
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
warnUnicodeEsc(prefixFixed, warnings);
|
|
200
|
-
|
|
201
|
-
let resolved = initialResolved;
|
|
202
|
-
let autoFixes: AutoFix[] | undefined;
|
|
203
|
-
if (boundaryDups.length > 0 && !skipBoundaryDedup) {
|
|
204
|
-
autoFixes = [];
|
|
205
|
-
const correctedEdit: HEdit = {
|
|
206
|
-
...prefixFixed,
|
|
207
|
-
content_lines: [...prefixFixed.content_lines],
|
|
208
|
-
};
|
|
209
|
-
const seen = new Set<number>();
|
|
210
|
-
const uniqueDups: BDup[] = [];
|
|
211
|
-
for (const dup of boundaryDups) {
|
|
212
|
-
if (seen.has(dup.replacementLineIndex)) continue;
|
|
213
|
-
seen.add(dup.replacementLineIndex);
|
|
214
|
-
uniqueDups.push(dup);
|
|
215
|
-
}
|
|
216
|
-
const dupsByIndex = uniqueDups.sort(
|
|
217
|
-
(a, b) => b.replacementLineIndex - a.replacementLineIndex,
|
|
218
|
-
);
|
|
219
|
-
for (const dup of dupsByIndex) {
|
|
220
|
-
const idx = dup.replacementLineIndex;
|
|
221
|
-
if (idx < 0 || idx >= correctedEdit.content_lines.length) continue;
|
|
222
|
-
const removed = correctedEdit.content_lines.splice(idx, 1)[0];
|
|
223
|
-
autoFixes.push({ kind: dup.kind, removedLine: removed, removedLineIndex: idx });
|
|
224
|
-
}
|
|
225
|
-
const correctedResult = valEdit(
|
|
226
|
-
correctedEdit,
|
|
227
|
-
lineIndex.fileLines,
|
|
228
|
-
fileHashes,
|
|
229
|
-
warnings,
|
|
230
|
-
signal,
|
|
231
|
-
);
|
|
232
|
-
if (correctedResult.mismatches.length || !correctedResult.resolved) {
|
|
233
|
-
const feedback = fmtMismatchWithHashes(
|
|
234
|
-
correctedResult.mismatches,
|
|
235
|
-
lineIndex.fileLines,
|
|
236
|
-
fileHashes,
|
|
237
|
-
filePath,
|
|
238
|
-
);
|
|
239
|
-
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
240
|
-
}
|
|
241
|
-
resolved = correctedResult.resolved;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (servedHashes) {
|
|
245
|
-
abortIf(signal);
|
|
246
|
-
assertRangeServed(resolved, lineIndex.fileLines, fileHashes, servedHashes, filePath);
|
|
247
|
-
}
|
|
248
|
-
|
|
284
|
+
abortIf(signal);
|
|
285
|
+
if (precomputedHashes === undefined) {
|
|
286
|
+
throw new Error("[E_BAD_SHAPE] applyEdit requires the file's allocated anchors; derive them via lineHashes(content, path) first.");
|
|
287
|
+
}
|
|
288
|
+
const planned = planEdit(content, edit, precomputedHashes, { filePath, servedHashes, skipBoundaryDedup, strictBoundaryDedup, signal });
|
|
289
|
+
const lineIndex = buildIdx(content);
|
|
290
|
+
const warnings = planned.warnings;
|
|
291
|
+
const resolved = planned.resolved;
|
|
292
|
+
const autoFixes = planned.autoFixes;
|
|
249
293
|
const spanResult = resToSpan(resolved, content, lineIndex);
|
|
250
294
|
if (spanResult.kind === "noop") {
|
|
251
295
|
return {
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached } from "../utils";
|
|
2
|
-
import { HASH_SEP, HASH_RUN, stripRowPrefix, canon } from "./hash";
|
|
3
2
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
3
|
+
import { HASH_SEP, stripRowPrefix, canon } from "./hash";
|
|
4
|
+
import { HASH_RUN } from "./alphabet";
|
|
4
5
|
import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
5
6
|
import { contentChecksum } from "./hasher";
|
|
6
7
|
import { hashSource } from "./hash";
|
|
@@ -97,7 +98,7 @@ export function fmtMismatchWithHashes(
|
|
|
97
98
|
if (notFound.length > 0) {
|
|
98
99
|
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
99
100
|
out.push(
|
|
100
|
-
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file changed since read. Call read() for fresh anchors.`
|
|
101
|
+
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file changed since read. Call read()${filePath ? ` on ${filePath}` : ""} for fresh anchors.`
|
|
101
102
|
);
|
|
102
103
|
for (const m of notFound) {
|
|
103
104
|
const ctx = m.context;
|
|
@@ -536,8 +537,8 @@ export function assertRangeServed(
|
|
|
536
537
|
: `${mismatchLines.length} of ${rangeLength} line(s) in the replaced range (lines ${startLine}-${endLine})${location} do not match`;
|
|
537
538
|
const capHint =
|
|
538
539
|
rangeLength > shownLength
|
|
539
|
-
? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read() with offset=${startLine + shownLength} to see the rest.]`
|
|
540
|
-
: "";
|
|
540
|
+
? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read()${filePath ? ` on ${filePath}` : ""} with offset=${startLine + shownLength} to see the rest.]`
|
|
541
|
+
: "\n\nRetry with the fresh anchors above without a read.";
|
|
541
542
|
const message =
|
|
542
543
|
`[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
|
|
543
544
|
throw new RangeStaleError(message, shownHashes, shownMap);
|
package/src/insert.ts
CHANGED
|
@@ -3,76 +3,72 @@ import { Type } from "typebox";
|
|
|
3
3
|
import { constants } from "fs";
|
|
4
4
|
import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
|
|
5
5
|
import { commitEdit } from "./commit";
|
|
6
|
+
import { batchMemberFor, executeBatchMember, noteBatchFailure } from "./batch";
|
|
6
7
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
7
|
-
import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
|
|
8
|
+
import { MAX_HASH_LINES, parseHashRef, resEdit, resolveAnchorLine, type Anchor, type HEdit } from "./hashline";
|
|
8
9
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
9
10
|
import { loadP, loadGuide } from "./prompts";
|
|
10
|
-
import { normReq } from "./payload-contract";
|
|
11
|
-
import { decodeStringArray, isRec,
|
|
11
|
+
import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
|
|
12
|
+
import { decodeStringArray, isRec, splitLines } from "./utils";
|
|
12
13
|
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
13
|
-
import {
|
|
14
|
+
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
14
15
|
import type { RPreview, RRState } from "./replace-render";
|
|
15
|
-
|
|
16
|
+
export { assertInsertReq, type InsertReq };
|
|
16
17
|
|
|
17
|
-
const
|
|
18
|
+
const insertAnchorSchema = Type.String({
|
|
19
|
+
description:
|
|
20
|
+
'Bare 4-char anchor from a read row (the text before the `│` separator), never the row content. A pasted diff row or `anchor│` prefix is stripped with a warning. The anchor line is preserved; lines go after or before it.',
|
|
21
|
+
});
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
23
|
+
const insertDirectionSchema = Type.Union(
|
|
24
|
+
[Type.Literal("after"), Type.Literal("before")],
|
|
25
|
+
{ description: '"after" or "before"' },
|
|
26
|
+
);
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (4-char anchor from read output).');
|
|
32
|
-
}
|
|
33
|
-
if (request.direction !== "before" && request.direction !== "after") {
|
|
34
|
-
throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
|
|
35
|
-
}
|
|
36
|
-
if (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
|
|
37
|
-
throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
|
|
28
|
+
const insertLinesSchema = Type.Array(
|
|
29
|
+
Type.String({
|
|
30
|
+
description: "One line to insert; never embed \\n inside an element.",
|
|
31
|
+
}),
|
|
32
|
+
{
|
|
33
|
+
description: 'One string per line; [""] is a blank line; never include the anchor line.',
|
|
38
34
|
}
|
|
39
|
-
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const insertPathRequiredSchema = Type.String({
|
|
38
|
+
description:
|
|
39
|
+
"Path to the file the anchor was served for; required and must match anchor ownership. The anchor still resolves the target.",
|
|
40
|
+
});
|
|
40
41
|
|
|
41
42
|
const insertToolSchema = Type.Object(
|
|
42
43
|
{
|
|
43
|
-
anchor:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}),
|
|
47
|
-
direction: Type.Union(
|
|
48
|
-
[
|
|
49
|
-
Type.Literal("after"),
|
|
50
|
-
Type.Literal("before"),
|
|
51
|
-
],
|
|
52
|
-
{ description: '"after" or "before"' },
|
|
53
|
-
),
|
|
54
|
-
lines: Type.Array(
|
|
55
|
-
Type.String({
|
|
56
|
-
description:
|
|
57
|
-
"One line to insert; never embed \\n inside an element.",
|
|
58
|
-
}),
|
|
59
|
-
{
|
|
60
|
-
description:
|
|
61
|
-
'One string per line; [""] is a blank line; never include the anchor line.',
|
|
62
|
-
}
|
|
63
|
-
),
|
|
44
|
+
anchor: insertAnchorSchema,
|
|
45
|
+
direction: insertDirectionSchema,
|
|
46
|
+
lines: insertLinesSchema,
|
|
64
47
|
},
|
|
65
48
|
{ additionalProperties: false },
|
|
66
49
|
);
|
|
67
50
|
|
|
68
|
-
function
|
|
51
|
+
export function buildInsertToolSchema(requirePath: boolean): typeof insertToolSchema {
|
|
52
|
+
if (!requirePath) return insertToolSchema;
|
|
53
|
+
return Type.Object(
|
|
54
|
+
{
|
|
55
|
+
path: insertPathRequiredSchema,
|
|
56
|
+
anchor: insertAnchorSchema,
|
|
57
|
+
direction: insertDirectionSchema,
|
|
58
|
+
lines: insertLinesSchema,
|
|
59
|
+
},
|
|
60
|
+
{ additionalProperties: false },
|
|
61
|
+
) as typeof insertToolSchema;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[] } {
|
|
69
65
|
const trimmedAnchor = raw.trim();
|
|
70
66
|
const warnings: string[] = [];
|
|
71
67
|
const anchorText = stripAnchorRow(trimmedAnchor, "anchor entry", warnings);
|
|
72
68
|
return { ref: parseHashRef(anchorText), warnings };
|
|
73
69
|
}
|
|
74
70
|
|
|
75
|
-
function buildInsertEdit(
|
|
71
|
+
export function buildInsertEdit(
|
|
76
72
|
req: InsertReq,
|
|
77
73
|
preload: NormFile,
|
|
78
74
|
ref: Anchor,
|
|
@@ -97,17 +93,28 @@ function buildInsertEdit(
|
|
|
97
93
|
export async function insertPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
|
|
98
94
|
try {
|
|
99
95
|
const normalized = normReq(request);
|
|
96
|
+
const previewFixes: string[] = [];
|
|
100
97
|
if (isRec(normalized)) {
|
|
101
98
|
const expanded = decodeStringArray(normalized.lines);
|
|
102
|
-
if (expanded)
|
|
99
|
+
if (expanded) {
|
|
100
|
+
previewFixes.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
|
|
101
|
+
normalized.lines = expanded;
|
|
102
|
+
}
|
|
103
103
|
}
|
|
104
104
|
assertInsertReq(normalized);
|
|
105
|
-
const
|
|
106
|
-
const
|
|
105
|
+
const previewReq = normalized as InsertReq;
|
|
106
|
+
const { ref, warnings: previewAnchorWarnings } = parseInsertAnchor(previewReq.anchor);
|
|
107
|
+
await throwIfStrictInput([...previewFixes, ...previewAnchorWarnings]);
|
|
108
|
+
const targetPath = await resolveEditTargetWithRequirement({
|
|
109
|
+
anchor: previewReq.anchor,
|
|
110
|
+
providedPath: previewReq.path,
|
|
111
|
+
cwd,
|
|
112
|
+
});
|
|
107
113
|
const preload = await readNormFile(targetPath, cwd, {
|
|
108
114
|
accessMode: constants.R_OK,
|
|
109
115
|
maxLines: MAX_HASH_LINES,
|
|
110
116
|
noPersist: true,
|
|
117
|
+
allocation: "shadow",
|
|
111
118
|
signal,
|
|
112
119
|
});
|
|
113
120
|
const { editParams } = buildInsertEdit(normalized, preload, ref, targetPath);
|
|
@@ -151,15 +158,20 @@ function getInsertInput(args: unknown): { path?: string; anchor?: string; direct
|
|
|
151
158
|
|
|
152
159
|
type InsertToolDef = ToolDefinition<any, ReplaceDetails, RRState> & { renderShell?: "default" | "self" };
|
|
153
160
|
|
|
154
|
-
export function buildInsertToolDef(): InsertToolDef {
|
|
161
|
+
export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): InsertToolDef {
|
|
162
|
+
const prompted = withInsertPrompts({
|
|
163
|
+
description: loadP("../prompts/insert.md"),
|
|
164
|
+
snippet: loadP("../prompts/insert-snippet.md"),
|
|
165
|
+
guidelines: loadGuide("../prompts/insert-guidelines.md"),
|
|
166
|
+
}, flags);
|
|
155
167
|
return {
|
|
156
168
|
name: "insert",
|
|
157
169
|
label: "Insert",
|
|
158
|
-
description:
|
|
159
|
-
promptSnippet:
|
|
160
|
-
promptGuidelines:
|
|
170
|
+
description: prompted.description,
|
|
171
|
+
promptSnippet: prompted.snippet,
|
|
172
|
+
promptGuidelines: prompted.guidelines,
|
|
161
173
|
...editToolBase,
|
|
162
|
-
parameters:
|
|
174
|
+
parameters: buildInsertToolSchema(flags.requirePath),
|
|
163
175
|
renderCall: editRenderCallWrapper(insertPreview, getInsertInput, "insert"),
|
|
164
176
|
renderResult: editRenderResultWrapper,
|
|
165
177
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
@@ -174,37 +186,78 @@ export function buildInsertToolDef(): InsertToolDef {
|
|
|
174
186
|
}
|
|
175
187
|
assertInsertReq(canonical);
|
|
176
188
|
const req = canonical;
|
|
177
|
-
const targetPath =
|
|
178
|
-
|
|
189
|
+
const targetPath = await resolveEditTargetWithRequirement({
|
|
190
|
+
anchor: req.anchor,
|
|
191
|
+
providedPath: req.path,
|
|
192
|
+
cwd: ctx.cwd,
|
|
193
|
+
}).catch((error: unknown) => {
|
|
194
|
+
const member = batchMemberFor(_toolCallId);
|
|
195
|
+
if (member) noteBatchFailure(member, error);
|
|
196
|
+
throw error;
|
|
197
|
+
});
|
|
198
|
+
let ref: Anchor;
|
|
199
|
+
let anchorWarnings: string[];
|
|
200
|
+
try {
|
|
201
|
+
({ ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor));
|
|
202
|
+
await throwIfStrictInput([...anchorWarnings, ...insertWarnings]);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const member = batchMemberFor(_toolCallId);
|
|
205
|
+
if (member) noteBatchFailure(member, error);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
179
208
|
return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
209
|
+
const member = batchMemberFor(_toolCallId);
|
|
180
210
|
const preload = await readNormFile(targetPath, ctx.cwd, {
|
|
181
211
|
signal,
|
|
182
212
|
accessMode: constants.R_OK | constants.W_OK,
|
|
183
213
|
maxLines: MAX_HASH_LINES,
|
|
184
214
|
});
|
|
185
215
|
const { editParams, anchorLine } = buildInsertEdit(req, preload, ref, targetPath);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
216
|
+
if (!member) {
|
|
217
|
+
const pipe = await execPipeline(targetPath, editParams, ctx.cwd, {
|
|
218
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
219
|
+
signal,
|
|
220
|
+
preloadedNorm: preload,
|
|
221
|
+
skipBoundaryDedup: true,
|
|
222
|
+
});
|
|
223
|
+
return commitEdit(pipe, {
|
|
224
|
+
path: pipe.path,
|
|
225
|
+
absolutePath,
|
|
226
|
+
mutationTargetPath,
|
|
227
|
+
signal,
|
|
228
|
+
verb: "inserted",
|
|
229
|
+
noopNoun: "Insertion",
|
|
230
|
+
foldedAnchorLines: anchorLine === undefined ? 0 : 1,
|
|
231
|
+
prefixWarnings: [...anchorWarnings, ...insertWarnings],
|
|
232
|
+
onApplied: () => clearBoundaryBypass(mutationTargetPath),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
let hedit: HEdit;
|
|
236
|
+
const resWarnings: string[] = [];
|
|
237
|
+
try {
|
|
238
|
+
hedit = resEdit(editParams, resWarnings);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
noteBatchFailure(member, error);
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
return executeBatchMember({
|
|
244
|
+
kind: "insert",
|
|
245
|
+
member,
|
|
246
|
+
targetPath,
|
|
195
247
|
mutationTargetPath,
|
|
248
|
+
cwd: ctx.cwd,
|
|
196
249
|
signal,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
250
|
+
hedit,
|
|
251
|
+
extraWarnings: [...anchorWarnings, ...insertWarnings, ...resWarnings],
|
|
252
|
+
skipBoundaryDedup: true,
|
|
253
|
+
strictBoundaryDedup: false,
|
|
254
|
+
foldedLines: anchorLine === undefined ? 0 : 1,
|
|
202
255
|
});
|
|
203
256
|
});
|
|
204
257
|
},
|
|
205
258
|
};
|
|
206
259
|
}
|
|
207
260
|
|
|
208
|
-
export function regInsert(pi: ExtensionAPI): void {
|
|
209
|
-
pi.registerTool(buildInsertToolDef());
|
|
261
|
+
export function regInsert(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
|
|
262
|
+
pi.registerTool(buildInsertToolDef(flags));
|
|
210
263
|
}
|
package/src/payload-contract.ts
CHANGED
|
@@ -21,6 +21,10 @@ const removeToSchema = Type.String({
|
|
|
21
21
|
description:
|
|
22
22
|
"Bare 4-char anchor from a read row (the text before the `│` separator), never the row content. Marks the LAST line to remove (inclusive)",
|
|
23
23
|
});
|
|
24
|
+
const pathRequiredSchema = Type.String({
|
|
25
|
+
description:
|
|
26
|
+
"Path to the file the anchors were served for; required and must match anchor ownership. Anchors still resolve the target.",
|
|
27
|
+
});
|
|
24
28
|
|
|
25
29
|
export const editToolSchema = Type.Object(
|
|
26
30
|
{
|
|
@@ -31,19 +35,35 @@ export const editToolSchema = Type.Object(
|
|
|
31
35
|
{ additionalProperties: false },
|
|
32
36
|
);
|
|
33
37
|
|
|
38
|
+
export function buildEditToolSchema(requirePath: boolean): typeof editToolSchema {
|
|
39
|
+
if (!requirePath) return editToolSchema;
|
|
40
|
+
return Type.Object(
|
|
41
|
+
{
|
|
42
|
+
path: pathRequiredSchema,
|
|
43
|
+
remove_from: removeFromSchema,
|
|
44
|
+
remove_to: removeToSchema,
|
|
45
|
+
replacement_lines: replacementLinesSchema,
|
|
46
|
+
},
|
|
47
|
+
{ additionalProperties: false },
|
|
48
|
+
) as typeof editToolSchema;
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
export type ReqParams = {
|
|
52
|
+
path?: string;
|
|
35
53
|
remove_from: string;
|
|
36
54
|
remove_to: string;
|
|
37
55
|
replacement_lines: string[];
|
|
38
56
|
};
|
|
39
57
|
|
|
40
|
-
const ROOT_KS = new Set(["remove_from", "remove_to", "replacement_lines"]);
|
|
41
|
-
|
|
58
|
+
const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
|
|
42
59
|
export function assertReq(request: unknown): asserts request is ReqParams {
|
|
43
60
|
if (!isRec(request)) {
|
|
44
61
|
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
45
62
|
}
|
|
46
63
|
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
64
|
+
if (request.path !== undefined && typeof request.path !== "string") {
|
|
65
|
+
throw new Error('[E_BAD_SHAPE] Edit request field "path" must be a string when provided.');
|
|
66
|
+
}
|
|
47
67
|
if (
|
|
48
68
|
typeof request.remove_from !== "string" ||
|
|
49
69
|
typeof request.remove_to !== "string" ||
|
|
@@ -89,3 +109,31 @@ export function getPreviewInput(args: unknown): { path?: string; remove_from: st
|
|
|
89
109
|
replacement_lines: normalized.replacement_lines as string[],
|
|
90
110
|
};
|
|
91
111
|
}
|
|
112
|
+
|
|
113
|
+
const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
|
|
114
|
+
|
|
115
|
+
export interface InsertReq {
|
|
116
|
+
path?: string;
|
|
117
|
+
anchor: string;
|
|
118
|
+
direction: "before" | "after";
|
|
119
|
+
lines: string[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function assertInsertReq(request: unknown): asserts request is InsertReq {
|
|
123
|
+
if (!isRec(request)) {
|
|
124
|
+
throw new Error("[E_BAD_SHAPE] Insert request must be an object.");
|
|
125
|
+
}
|
|
126
|
+
rejectUnknownFields(request, INSERT_KS, "Insert request");
|
|
127
|
+
if (request.path !== undefined && typeof request.path !== "string") {
|
|
128
|
+
throw new Error('[E_BAD_SHAPE] Insert request field "path" must be a string when provided.');
|
|
129
|
+
}
|
|
130
|
+
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
131
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (4-char anchor from read output).');
|
|
132
|
+
}
|
|
133
|
+
if (request.direction !== "before" && request.direction !== "after") {
|
|
134
|
+
throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
|
|
137
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
|
|
138
|
+
}
|
|
139
|
+
}
|