pi-hashline-edit-pro 2.8.4 → 3.0.1
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 +24 -24
- package/package.json +2 -2
- package/prompts/grep-guidelines.md +3 -3
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +1 -3
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +2 -2
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +4 -5
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +1 -2
- package/prompts/undo-last-change.md +1 -1
- package/src/commit.ts +1 -1
- package/src/config.ts +1 -1
- package/src/constants.ts +3 -1
- package/src/edit-common.ts +1 -1
- package/src/fs-write.ts +6 -5
- package/src/grep.ts +26 -14
- package/src/hash-store.ts +4 -3
- package/src/hashline/alphabet.ts +30 -7
- package/src/hashline/anchor-table.json +1 -0
- package/src/hashline/hash.ts +6 -28
- package/src/hashline/parse.ts +15 -9
- package/src/hashline/resolve.ts +3 -3
- package/src/insert.ts +24 -9
- package/src/payload-contract.ts +5 -5
- package/src/replace-diff.ts +58 -1
- package/src/replace-render.ts +17 -2
- package/src/replace-undo.ts +3 -0
- package/src/replace.ts +11 -3
- package/src/served.ts +2 -1
- package/src/utils.ts +82 -1
package/src/hashline/hash.ts
CHANGED
|
@@ -7,36 +7,20 @@ import {
|
|
|
7
7
|
persistSnapshot,
|
|
8
8
|
} from "../hash-store";
|
|
9
9
|
import { xxh32, initHasher } from "./hasher";
|
|
10
|
-
import { HASH_LEN,
|
|
11
|
-
export { initHasher, HASH_LEN,
|
|
10
|
+
import { HASH_LEN, ANCHOR_COUNT, anchorAt, anchorIndex, HASH_CLASS, HASH_RUN } from "./alphabet";
|
|
11
|
+
export { initHasher, HASH_LEN, HASH_CLASS, HASH_RUN };
|
|
12
12
|
|
|
13
13
|
export const ANCHOR_LEN = HASH_LEN;
|
|
14
14
|
|
|
15
15
|
export const HASH_SEP = "│";
|
|
16
16
|
|
|
17
|
-
export const HASH_SPACE =
|
|
17
|
+
export const HASH_SPACE = ANCHOR_COUNT;
|
|
18
18
|
export const MAX_HASH_LINES = HASH_SPACE;
|
|
19
19
|
|
|
20
|
-
export const HASH_PROBE_STRIDE =
|
|
21
|
-
|
|
22
|
-
function idxToHash(idx: number): string {
|
|
23
|
-
let out = "";
|
|
24
|
-
for (let j = 0; j < HASH_LEN; j++) {
|
|
25
|
-
out = ALPH[idx % ALPH.length]! + out;
|
|
26
|
-
idx = Math.floor(idx / ALPH.length);
|
|
27
|
-
}
|
|
28
|
-
return out;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const hashCache = new Map<number, string>();
|
|
20
|
+
export const HASH_PROBE_STRIDE = 571;
|
|
32
21
|
|
|
33
22
|
function hashAt(idx: number): string {
|
|
34
|
-
|
|
35
|
-
if (hash === undefined) {
|
|
36
|
-
hash = idxToHash(idx);
|
|
37
|
-
hashCache.set(idx, hash);
|
|
38
|
-
}
|
|
39
|
-
return hash;
|
|
23
|
+
return anchorAt(idx);
|
|
40
24
|
}
|
|
41
25
|
|
|
42
26
|
export const HL_PREFIX_PLUS_RE = new RegExp(
|
|
@@ -182,13 +166,7 @@ export async function lineHashes(
|
|
|
182
166
|
}
|
|
183
167
|
|
|
184
168
|
function hashToIndex(hash: string): number {
|
|
185
|
-
|
|
186
|
-
for (let j = 0; j < HASH_LEN; j++) {
|
|
187
|
-
const charIdx = ALPH.indexOf(hash[j]!);
|
|
188
|
-
if (charIdx < 0) return -1;
|
|
189
|
-
idx = idx * ALPH.length + charIdx;
|
|
190
|
-
}
|
|
191
|
-
return idx;
|
|
169
|
+
return anchorIndex(hash);
|
|
192
170
|
}
|
|
193
171
|
|
|
194
172
|
function nearestNew(
|
package/src/hashline/parse.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ANCHOR_LEN,
|
|
3
|
-
ALPH_RE,
|
|
4
3
|
HASH_CLASS,
|
|
5
4
|
} from "./hash";
|
|
5
|
+
import { ALPH_RE } from "./alphabet";
|
|
6
6
|
import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
|
|
7
7
|
|
|
8
8
|
const HASH_EXTRACT_RE = new RegExp(HASH_CLASS);
|
|
@@ -13,11 +13,11 @@ function diagRef(ref: string): string {
|
|
|
13
13
|
const trimmed = ref.trim();
|
|
14
14
|
|
|
15
15
|
if (!trimmed.length) {
|
|
16
|
-
return `[E_BAD_REF] Invalid anchor. Expected a
|
|
16
|
+
return `[E_BAD_REF] Invalid anchor. Expected a 4-char alphanumeric anchor (e.g. "Hasu").`;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
if (/^\d+/.test(trimmed)) {
|
|
20
|
-
return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "
|
|
20
|
+
return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "Hasu"): no line numbers or trailing content.`;
|
|
21
21
|
}
|
|
22
22
|
if (trimmed.includes("│") && trimmed.includes("\n")) {
|
|
23
23
|
const lines = trimmed.split(/\r?\n/);
|
|
@@ -26,16 +26,16 @@ function diagRef(ref: string): string {
|
|
|
26
26
|
const hashRe = HASH_EXTRACT_RE;
|
|
27
27
|
const firstMatch = first.match(hashRe);
|
|
28
28
|
const lastMatch = last.match(hashRe);
|
|
29
|
-
const firstHash = firstMatch?.[0] ?? "
|
|
30
|
-
const lastHash = lastMatch?.[0] ?? "
|
|
29
|
+
const firstHash = firstMatch?.[0] ?? "Hasu";
|
|
30
|
+
const lastHash = lastMatch?.[0] ?? "Hasu";
|
|
31
31
|
const preview = first.slice(0, 60);
|
|
32
|
-
return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare
|
|
32
|
+
return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 4-char hash (e.g. "Hasu"), 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 replacement_lines.`;
|
|
33
33
|
}
|
|
34
34
|
if (trimmed.includes("│")) {
|
|
35
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the
|
|
35
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 4-char anchor, drop everything from "│" onward.`;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a
|
|
38
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 4-char alphanumeric anchor (e.g. "Hasu").`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function parseRef(ref: string): Anchor {
|
|
@@ -60,7 +60,13 @@ function unwrapJsonEnvelope(line: string, warnings?: string[]): string {
|
|
|
60
60
|
if (!match) return line;
|
|
61
61
|
const withoutDot = line.trim().slice(0, -1);
|
|
62
62
|
try {
|
|
63
|
-
JSON.parse(withoutDot);
|
|
63
|
+
const parsed: unknown = JSON.parse(withoutDot);
|
|
64
|
+
if (Array.isArray(parsed) && parsed.length === 1 && typeof parsed[0] === "string") {
|
|
65
|
+
warnings?.push(
|
|
66
|
+
'[E_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.',
|
|
67
|
+
);
|
|
68
|
+
return parsed[0];
|
|
69
|
+
}
|
|
64
70
|
return line;
|
|
65
71
|
} catch {
|
|
66
72
|
warnings?.push(
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -159,12 +159,12 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
159
159
|
|
|
160
160
|
if ("remove_from" in edit && typeof edit.remove_from !== "string") {
|
|
161
161
|
throw new Error(
|
|
162
|
-
`[E_BAD_SHAPE] Field "remove_from" must be an anchor string (
|
|
162
|
+
`[E_BAD_SHAPE] Field "remove_from" must be an anchor string (4-char anchor).`,
|
|
163
163
|
);
|
|
164
164
|
}
|
|
165
165
|
if ("remove_to" in edit && typeof edit.remove_to !== "string") {
|
|
166
166
|
throw new Error(
|
|
167
|
-
`[E_BAD_SHAPE] Field "remove_to" must be an anchor string (
|
|
167
|
+
`[E_BAD_SHAPE] Field "remove_to" must be an anchor string (4-char anchor).`,
|
|
168
168
|
);
|
|
169
169
|
}
|
|
170
170
|
if (!("replacement_lines" in edit)) {
|
|
@@ -175,7 +175,7 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
175
175
|
}
|
|
176
176
|
if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
|
|
177
177
|
throw new Error(
|
|
178
|
-
`[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (
|
|
178
|
+
`[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (4-char anchors from read output).`,
|
|
179
179
|
);
|
|
180
180
|
}
|
|
181
181
|
}
|
package/src/insert.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./
|
|
|
8
8
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
9
9
|
import { loadP, loadGuide } from "./prompts";
|
|
10
10
|
import { normReq } from "./payload-contract";
|
|
11
|
-
import { isRec, rejectUnknownFields, splitLines } from "./utils";
|
|
11
|
+
import { decodeStringArray, isRec, rejectUnknownFields, splitLines } from "./utils";
|
|
12
12
|
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
13
13
|
import type { RPreview, RRState } from "./replace-render";
|
|
14
14
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
|
|
@@ -31,7 +31,7 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
|
|
|
31
31
|
throw new Error('[E_BAD_SHAPE] Insert request requires a non-empty "path" string.');
|
|
32
32
|
}
|
|
33
33
|
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
34
|
-
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (
|
|
34
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (4-char anchor from read output).');
|
|
35
35
|
}
|
|
36
36
|
if (request.direction !== "before" && request.direction !== "after") {
|
|
37
37
|
throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
|
|
@@ -49,23 +49,23 @@ const insertToolSchema = Type.Object(
|
|
|
49
49
|
}),
|
|
50
50
|
anchor: Type.String({
|
|
51
51
|
description:
|
|
52
|
-
'Bare
|
|
52
|
+
'Bare 4-char anchor from a read row like `Hasu│content`, never the content. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped with a warning. The anchor line is preserved; lines go after or before it.',
|
|
53
53
|
}),
|
|
54
54
|
direction: Type.Union(
|
|
55
55
|
[
|
|
56
|
-
Type.Literal("after"
|
|
57
|
-
Type.Literal("before"
|
|
56
|
+
Type.Literal("after"),
|
|
57
|
+
Type.Literal("before"),
|
|
58
58
|
],
|
|
59
59
|
{ description: '"after" or "before"' },
|
|
60
60
|
),
|
|
61
61
|
lines: Type.Array(
|
|
62
62
|
Type.String({
|
|
63
63
|
description:
|
|
64
|
-
"One line to insert
|
|
64
|
+
"One line to insert; never embed \\n inside an element.",
|
|
65
65
|
}),
|
|
66
66
|
{
|
|
67
67
|
description:
|
|
68
|
-
'
|
|
68
|
+
'One string per line; [""] is a blank line; never include the anchor line.',
|
|
69
69
|
}
|
|
70
70
|
),
|
|
71
71
|
},
|
|
@@ -101,15 +101,20 @@ function buildInsertEdit(
|
|
|
101
101
|
return { editParams, anchorLine };
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
export async function insertPreview(request: unknown, cwd: string): Promise<RPreview> {
|
|
104
|
+
export async function insertPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
|
|
105
105
|
try {
|
|
106
106
|
const normalized = normReq(request);
|
|
107
|
+
if (isRec(normalized)) {
|
|
108
|
+
const expanded = decodeStringArray(normalized.lines);
|
|
109
|
+
if (expanded) normalized.lines = expanded;
|
|
110
|
+
}
|
|
107
111
|
assertInsertReq(normalized);
|
|
108
112
|
const { ref } = parseInsertAnchor(normalized.anchor);
|
|
109
113
|
const preload = await readNormFile(normalized.path, cwd, {
|
|
110
114
|
accessMode: constants.R_OK,
|
|
111
115
|
maxLines: MAX_HASH_LINES,
|
|
112
116
|
noPersist: true,
|
|
117
|
+
signal,
|
|
113
118
|
});
|
|
114
119
|
const { editParams } = buildInsertEdit(normalized, preload, ref);
|
|
115
120
|
const pipe = await execPipeline(editParams, cwd, {
|
|
@@ -117,9 +122,11 @@ export async function insertPreview(request: unknown, cwd: string): Promise<RPre
|
|
|
117
122
|
noPersist: true,
|
|
118
123
|
preloadedNorm: preload,
|
|
119
124
|
skipBoundaryDedup: true,
|
|
125
|
+
signal,
|
|
120
126
|
});
|
|
121
127
|
return previewFromPipe(pipe);
|
|
122
128
|
} catch (error: unknown) {
|
|
129
|
+
if (signal?.aborted) throw error;
|
|
123
130
|
return previewError(error);
|
|
124
131
|
}
|
|
125
132
|
}
|
|
@@ -163,6 +170,14 @@ export function buildInsertToolDef(): InsertToolDef {
|
|
|
163
170
|
renderResult: editRenderResultWrapper,
|
|
164
171
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
165
172
|
const canonical = normReq(params);
|
|
173
|
+
const insertWarnings: string[] = [];
|
|
174
|
+
if (isRec(canonical)) {
|
|
175
|
+
const expanded = decodeStringArray(canonical.lines);
|
|
176
|
+
if (expanded) {
|
|
177
|
+
insertWarnings.push('[E_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
|
|
178
|
+
canonical.lines = expanded;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
166
181
|
assertInsertReq(canonical);
|
|
167
182
|
const req = canonical;
|
|
168
183
|
const path = req.path;
|
|
@@ -188,7 +203,7 @@ export function buildInsertToolDef(): InsertToolDef {
|
|
|
188
203
|
verb: "inserted",
|
|
189
204
|
noopNoun: "Insertion",
|
|
190
205
|
foldedAnchorLines: anchorLine === undefined ? 0 : 1,
|
|
191
|
-
prefixWarnings: anchorWarnings,
|
|
206
|
+
prefixWarnings: [...anchorWarnings, ...insertWarnings],
|
|
192
207
|
onApplied: () => clearBoundaryBypass(mutationTargetPath),
|
|
193
208
|
});
|
|
194
209
|
});
|
package/src/payload-contract.ts
CHANGED
|
@@ -4,22 +4,22 @@ import { isRec, normalizeFilePath, rejectUnknownFields } from "./utils";
|
|
|
4
4
|
const replacementLinesSchema = Type.Array(
|
|
5
5
|
Type.String({
|
|
6
6
|
description:
|
|
7
|
-
"One replacement line
|
|
7
|
+
"One replacement line; never embed \\n inside an element.",
|
|
8
8
|
}),
|
|
9
9
|
{
|
|
10
10
|
description:
|
|
11
|
-
"
|
|
11
|
+
"One string per line. Use [] to delete the range.",
|
|
12
12
|
},
|
|
13
13
|
);
|
|
14
14
|
|
|
15
15
|
const removeFromSchema = Type.String({
|
|
16
16
|
description:
|
|
17
|
-
"Bare
|
|
17
|
+
"Bare 4-char anchor from a read row like `Hasu│content` (the leftmost column), never the row content. Marks the FIRST line to remove (inclusive)",
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
const removeToSchema = Type.String({
|
|
21
21
|
description:
|
|
22
|
-
"Bare
|
|
22
|
+
"Bare 4-char anchor from a read row like `Hasu│content` (the leftmost column), never the row content. Marks the LAST line to remove (inclusive)",
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
export const editToolSchema = Type.Object(
|
|
@@ -27,7 +27,7 @@ export const editToolSchema = Type.Object(
|
|
|
27
27
|
path: Type.Optional(
|
|
28
28
|
Type.String({
|
|
29
29
|
description:
|
|
30
|
-
"Path to edit
|
|
30
|
+
"Path to edit; always provide it explicitly — it is only auto-resolved from the anchors as a fallback.",
|
|
31
31
|
}),
|
|
32
32
|
),
|
|
33
33
|
remove_from: removeFromSchema,
|
package/src/replace-diff.ts
CHANGED
|
@@ -4,7 +4,10 @@ import {
|
|
|
4
4
|
_lineHashesPure,
|
|
5
5
|
ANCHOR_LEN,
|
|
6
6
|
HASH_SEP,
|
|
7
|
+
changedRange,
|
|
7
8
|
} from "./hashline";
|
|
9
|
+
import { MAX_DIFF_INPUT_BYTES } from "./constants";
|
|
10
|
+
import { splitLines } from "./utils";
|
|
8
11
|
import {
|
|
9
12
|
detectEnding,
|
|
10
13
|
toLF,
|
|
@@ -48,9 +51,63 @@ export function genDiff(
|
|
|
48
51
|
oldContentHashes?: string[],
|
|
49
52
|
limits?: DiffLimits,
|
|
50
53
|
): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
|
|
51
|
-
const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
|
|
52
54
|
const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
|
|
53
55
|
const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
56
|
+
if (!limits?.unlimited && Buffer.byteLength(oldContent, "utf-8") + Buffer.byteLength(newContent, "utf-8") > MAX_DIFF_INPUT_BYTES) {
|
|
57
|
+
const guardedRange = changedRange(oldContent, newContent);
|
|
58
|
+
const guardNote = `[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`;
|
|
59
|
+
if (!guardedRange || !newContentHashes) {
|
|
60
|
+
return { diff: ` ...\n${guardNote}`, firstChangedLine: guardedRange?.firstChangedLine, lineNumbers: [undefined, undefined] };
|
|
61
|
+
}
|
|
62
|
+
const newLines = splitLines(newContent);
|
|
63
|
+
const oldLines = splitLines(oldContent);
|
|
64
|
+
const first = guardedRange.firstChangedLine;
|
|
65
|
+
const last = guardedRange.lastChangedLine;
|
|
66
|
+
const suffixLen = newLines.length - last;
|
|
67
|
+
const oldLast = oldLines.length - suffixLen;
|
|
68
|
+
const beforeStart = Math.max(1, first - contextLines);
|
|
69
|
+
const afterEnd = Math.min(newLines.length, last + contextLines);
|
|
70
|
+
const guarded: string[] = [];
|
|
71
|
+
const guardedNumbers: (number|undefined)[] = [];
|
|
72
|
+
let guardedBytes = 0;
|
|
73
|
+
const pushGuarded = (text: string, num?: number): boolean => {
|
|
74
|
+
const size = Buffer.byteLength(text, "utf-8") + 1;
|
|
75
|
+
if (guardedBytes + size > maxBytes) return false;
|
|
76
|
+
guardedBytes += size;
|
|
77
|
+
guarded.push(text);
|
|
78
|
+
guardedNumbers.push(num);
|
|
79
|
+
return true;
|
|
80
|
+
};
|
|
81
|
+
const pushGuardedRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): boolean => {
|
|
82
|
+
const full = fmtDiffLine(prefix, line, hash);
|
|
83
|
+
if (Buffer.byteLength(full, "utf-8") > maxLineBytes) {
|
|
84
|
+
const marker = `[Row is ${formatSize(Buffer.byteLength(full, "utf-8"))}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
|
|
85
|
+
return pushGuarded(fmtDiffLine(prefix, marker, hash), num);
|
|
86
|
+
}
|
|
87
|
+
return pushGuarded(full, num);
|
|
88
|
+
};
|
|
89
|
+
if (beforeStart > 1) pushGuarded(" ...", undefined);
|
|
90
|
+
for (let n = beforeStart; n < first; n++) {
|
|
91
|
+
if (!pushGuardedRow(" ", newLines[n - 1]!, newContentHashes[n - 1], n)) break;
|
|
92
|
+
}
|
|
93
|
+
if (oldContent.length > 0) {
|
|
94
|
+
for (let n = first; n <= Math.min(oldLast, oldLines.length); n++) {
|
|
95
|
+
if (!pushGuardedRow("-", oldLines[n - 1]!, oldContentHashes?.[n - 1], n)) break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (let n = first; n <= last; n++) {
|
|
99
|
+
if (!pushGuardedRow("+", newLines[n - 1]!, newContentHashes[n - 1], n)) break;
|
|
100
|
+
}
|
|
101
|
+
for (let n = last + 1; n <= afterEnd; n++) {
|
|
102
|
+
if (!pushGuardedRow(" ", newLines[n - 1]!, newContentHashes[n - 1], n)) break;
|
|
103
|
+
}
|
|
104
|
+
guarded.push(" ...");
|
|
105
|
+
guardedNumbers.push(undefined);
|
|
106
|
+
guarded.push(guardNote);
|
|
107
|
+
guardedNumbers.push(undefined);
|
|
108
|
+
return { diff: guarded.join("\n"), firstChangedLine: first, lineNumbers: guardedNumbers };
|
|
109
|
+
}
|
|
110
|
+
const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
|
|
54
111
|
|
|
55
112
|
const parts = Diff.diffLines(oldContent, newContent);
|
|
56
113
|
const output: string[] = [];
|
package/src/replace-render.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type RRState = {
|
|
|
19
19
|
preview?: RPreview;
|
|
20
20
|
previewGeneration?: number;
|
|
21
21
|
previewTimer?: ReturnType<typeof setTimeout>;
|
|
22
|
+
previewAbort?: AbortController;
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
type DiffRowKind = "added" | "removed" | "context";
|
|
@@ -232,7 +233,7 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
|
|
|
232
233
|
}
|
|
233
234
|
|
|
234
235
|
export function makeRenderCall(
|
|
235
|
-
preview: (args: unknown, cwd: string) => Promise<RPreview>,
|
|
236
|
+
preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
|
|
236
237
|
options: { getInput?: (args: unknown) => { path?: string } | null; toolName?: string } = {},
|
|
237
238
|
) {
|
|
238
239
|
const getInput = options.getInput ?? getPreviewInput;
|
|
@@ -244,6 +245,10 @@ export function makeRenderCall(
|
|
|
244
245
|
clearTimeout(context.state.previewTimer);
|
|
245
246
|
context.state.previewTimer = undefined;
|
|
246
247
|
}
|
|
248
|
+
if (context.state.previewAbort) {
|
|
249
|
+
context.state.previewAbort.abort();
|
|
250
|
+
context.state.previewAbort = undefined;
|
|
251
|
+
}
|
|
247
252
|
};
|
|
248
253
|
if (context.executionStarted) {
|
|
249
254
|
cancelPendingPreview();
|
|
@@ -265,8 +270,12 @@ export function makeRenderCall(
|
|
|
265
270
|
context.state.previewGeneration = previewGeneration;
|
|
266
271
|
context.state.previewTimer = setTimeout(() => {
|
|
267
272
|
context.state.previewTimer = undefined;
|
|
268
|
-
|
|
273
|
+
const controller = new AbortController();
|
|
274
|
+
context.state.previewAbort = controller;
|
|
275
|
+
preview(args, context.cwd, controller.signal)
|
|
269
276
|
.then((result) => {
|
|
277
|
+
if (controller.signal.aborted) return;
|
|
278
|
+
if (context.state.previewAbort === controller) context.state.previewAbort = undefined;
|
|
270
279
|
if (
|
|
271
280
|
context.state.argsKey === argsKey &&
|
|
272
281
|
context.state.previewGeneration === previewGeneration
|
|
@@ -276,6 +285,8 @@ export function makeRenderCall(
|
|
|
276
285
|
}
|
|
277
286
|
})
|
|
278
287
|
.catch((err: unknown) => {
|
|
288
|
+
if (controller.signal.aborted) return;
|
|
289
|
+
if (context.state.previewAbort === controller) context.state.previewAbort = undefined;
|
|
279
290
|
if (
|
|
280
291
|
context.state.argsKey === argsKey &&
|
|
281
292
|
context.state.previewGeneration === previewGeneration
|
|
@@ -311,6 +322,10 @@ export function renderEditResult(
|
|
|
311
322
|
clearTimeout(renderState.previewTimer);
|
|
312
323
|
renderState.previewTimer = undefined;
|
|
313
324
|
}
|
|
325
|
+
if (renderState.previewAbort) {
|
|
326
|
+
renderState.previewAbort.abort();
|
|
327
|
+
renderState.previewAbort = undefined;
|
|
328
|
+
}
|
|
314
329
|
renderState.preview = undefined;
|
|
315
330
|
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
316
331
|
}
|
package/src/replace-undo.ts
CHANGED
|
@@ -105,6 +105,9 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
105
105
|
},
|
|
106
106
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
107
107
|
const path = params.path;
|
|
108
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
109
|
+
throw new Error('[E_BAD_SHAPE] Undo request requires a non-empty "path" string.');
|
|
110
|
+
}
|
|
108
111
|
const { resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
|
|
109
112
|
|
|
110
113
|
const undo = await getUndo(mutationTargetPath);
|
package/src/replace.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "./replace-diff";
|
|
10
10
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
11
11
|
import { editToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
|
|
12
|
-
import { isRec } from "./utils";
|
|
12
|
+
import { decodeStringArray, isRec } from "./utils";
|
|
13
13
|
import { loadP, loadGuide } from "./prompts";
|
|
14
14
|
import { type FileIdentity } from "./fs-write";
|
|
15
15
|
import { applyEdit,
|
|
@@ -160,11 +160,17 @@ export async function execPipeline(
|
|
|
160
160
|
const path = params.path;
|
|
161
161
|
|
|
162
162
|
const editWarnings: string[] = [];
|
|
163
|
+
let replacementLines = params.replacement_lines;
|
|
164
|
+
const expandedReplacement = decodeStringArray(replacementLines);
|
|
165
|
+
if (expandedReplacement) {
|
|
166
|
+
editWarnings.push('[E_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.');
|
|
167
|
+
replacementLines = expandedReplacement;
|
|
168
|
+
}
|
|
163
169
|
const edit = resEdit(
|
|
164
170
|
{
|
|
165
171
|
remove_from: params.remove_from,
|
|
166
172
|
remove_to: params.remove_to,
|
|
167
|
-
replacement_lines:
|
|
173
|
+
replacement_lines: replacementLines,
|
|
168
174
|
},
|
|
169
175
|
editWarnings,
|
|
170
176
|
);
|
|
@@ -246,6 +252,7 @@ export function previewError(error: unknown): RPreview {
|
|
|
246
252
|
export async function compPreview(
|
|
247
253
|
request: unknown,
|
|
248
254
|
cwd: string,
|
|
255
|
+
signal?: AbortSignal,
|
|
249
256
|
): Promise<RPreview> {
|
|
250
257
|
try {
|
|
251
258
|
const normalized = normReq(request);
|
|
@@ -253,10 +260,11 @@ export async function compPreview(
|
|
|
253
260
|
const pipe = await execPipeline(
|
|
254
261
|
normalized,
|
|
255
262
|
cwd,
|
|
256
|
-
{ accessMode: constants.R_OK, noPersist: true },
|
|
263
|
+
{ accessMode: constants.R_OK, noPersist: true, signal },
|
|
257
264
|
);
|
|
258
265
|
return previewFromPipe(pipe);
|
|
259
266
|
} catch (error: unknown) {
|
|
267
|
+
if (signal?.aborted) throw error;
|
|
260
268
|
return previewError(error);
|
|
261
269
|
}
|
|
262
270
|
}
|
package/src/served.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadHashStore, parseStoredServed, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
|
|
2
|
+
import { withBusyRetry } from "./hash-store/retry";
|
|
2
3
|
import { HASH_CLASS } from "./hashline/alphabet";
|
|
3
4
|
import { contentChecksum } from "./hashline/hasher";
|
|
4
5
|
|
|
@@ -37,7 +38,7 @@ export function buildServedMap(fileHashes: string[], fileLines: string[], wanted
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export function getServed(store: HashStore, path: string): Map<string, string> | undefined {
|
|
40
|
-
const row = store.stmts.servedGet(path);
|
|
41
|
+
const row = withBusyRetry(() => store.stmts.servedGet(path));
|
|
41
42
|
const parsed = parseStoredServed(row, () => store.stmts.servedDelete(path));
|
|
42
43
|
if (!parsed) return undefined;
|
|
43
44
|
return parsed;
|
package/src/utils.ts
CHANGED
|
@@ -114,7 +114,7 @@ export function getCached<K, V>(map: Map<K, V>, key: K, compute: (key: K) => V):
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
export function isHashRow(line: string): boolean {
|
|
117
|
-
return /^[A-Za-z0-9]{
|
|
117
|
+
return /^[A-Za-z0-9]{4}│/.test(line);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
function gutterWidth(max: number, fallback: number): number {
|
|
@@ -168,3 +168,84 @@ function formatLineLimit(displayPath: string, limit: number, count: number | und
|
|
|
168
168
|
const detail = count === undefined ? `has more than ${limit}` : `has ${count}`;
|
|
169
169
|
return `[E_FILE_TOO_LARGE] ${displayPath} ${detail} lines, exceeding the ${limit}-line hashline limit. For very large files, use write.`;
|
|
170
170
|
}
|
|
171
|
+
|
|
172
|
+
function escapeRawControl(value: string): string {
|
|
173
|
+
if (value === "\b") return "\\b";
|
|
174
|
+
if (value === "\t") return "\\t";
|
|
175
|
+
if (value === "\n") return "\\n";
|
|
176
|
+
if (value === "\f") return "\\f";
|
|
177
|
+
if (value === "\r") return "\\r";
|
|
178
|
+
const hex = value.charCodeAt(0).toString(16).padStart(4, "0");
|
|
179
|
+
return `\\u${hex}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function escapeControls(value: string): string {
|
|
183
|
+
let out = "";
|
|
184
|
+
for (const char of value) {
|
|
185
|
+
out += char.charCodeAt(0) < 32 ? escapeRawControl(char) : char;
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function decodeArraySegment(segment: string): string | undefined {
|
|
191
|
+
const trimmed = segment.trim();
|
|
192
|
+
if (trimmed.length < 2 || !trimmed.startsWith('"') || !trimmed.endsWith('"')) return undefined;
|
|
193
|
+
try {
|
|
194
|
+
const cleaned = escapeControls(trimmed);
|
|
195
|
+
const parsed: unknown = JSON.parse(cleaned);
|
|
196
|
+
return typeof parsed === "string" ? parsed : undefined;
|
|
197
|
+
} catch {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function splitArraySegments(inner: string): string[] | undefined {
|
|
203
|
+
const segments: string[] = [];
|
|
204
|
+
let current = "";
|
|
205
|
+
let inQuotes = false;
|
|
206
|
+
let escaped = false;
|
|
207
|
+
for (const char of inner) {
|
|
208
|
+
if (inQuotes) {
|
|
209
|
+
current += char;
|
|
210
|
+
if (escaped) escaped = false;
|
|
211
|
+
else if (char === "\\") escaped = true;
|
|
212
|
+
else if (char === '"') inQuotes = false;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (char === '"') {
|
|
216
|
+
inQuotes = true;
|
|
217
|
+
current += char;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (char === ",") {
|
|
221
|
+
segments.push(current);
|
|
222
|
+
current = "";
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
current += char;
|
|
226
|
+
}
|
|
227
|
+
if (inQuotes || escaped) return undefined;
|
|
228
|
+
segments.push(current);
|
|
229
|
+
return segments;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function decodeArrayText(value: unknown): string[] | undefined {
|
|
233
|
+
if (typeof value !== "string") return undefined;
|
|
234
|
+
const trimmed = value.trim();
|
|
235
|
+
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return undefined;
|
|
236
|
+
const segments = splitArraySegments(trimmed.slice(1, -1));
|
|
237
|
+
if (!segments) return undefined;
|
|
238
|
+
const decoded: string[] = [];
|
|
239
|
+
for (const segment of segments) {
|
|
240
|
+
const part = decodeArraySegment(segment);
|
|
241
|
+
if (part === undefined) return undefined;
|
|
242
|
+
decoded.push(part);
|
|
243
|
+
}
|
|
244
|
+
return decoded.length > 0 ? decoded : undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function decodeStringArray(value: unknown): string[] | undefined {
|
|
248
|
+
if (typeof value === "string") return decodeArrayText(value);
|
|
249
|
+
if (Array.isArray(value) && value.length === 1) return decodeArrayText(value[0]);
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|