pi-hashline-edit-pro 3.0.0 → 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/package.json +1 -1
- package/src/constants.ts +2 -0
- package/src/edit-common.ts +1 -1
- package/src/fs-write.ts +6 -5
- package/src/grep.ts +21 -9
- package/src/hash-store.ts +4 -3
- package/src/hashline/parse.ts +7 -1
- package/src/insert.ts +18 -3
- 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 +81 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
package/src/constants.ts
CHANGED
|
@@ -6,6 +6,8 @@ export const MAX_OVERSIZED_WARNING_LINES = 100;
|
|
|
6
6
|
export const MAX_HASH_SOURCE_BYTES = 500;
|
|
7
7
|
export const MAX_GREP_LINE_BYTES = 500;
|
|
8
8
|
|
|
9
|
+
export const MAX_DIFF_INPUT_BYTES = 1024 * 1024;
|
|
10
|
+
|
|
9
11
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
10
12
|
export const HASH_STORE_VERSION = 7;
|
|
11
13
|
export const NEW_CONTENT_NOT_ARRAY_MSG =
|
package/src/edit-common.ts
CHANGED
|
@@ -16,7 +16,7 @@ export function editRenderResultWrapper(
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function editRenderCallWrapper(
|
|
19
|
-
preview: (args: unknown, cwd: string) => Promise<RPreview>,
|
|
19
|
+
preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
|
|
20
20
|
getInput?: (args: unknown) => { path?: string } | null,
|
|
21
21
|
toolName?: string,
|
|
22
22
|
) {
|
package/src/fs-write.ts
CHANGED
|
@@ -100,20 +100,21 @@ export async function resolveTarget(path: string): Promise<string> {
|
|
|
100
100
|
const TEMP_PREFIX = ".tmp-";
|
|
101
101
|
const TEMP_UUID_RE = /^\.tmp-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
102
102
|
const STALE_TEMP_MS = 60 * 60 * 1000;
|
|
103
|
-
const sweptDirs = new
|
|
103
|
+
const sweptDirs = new Map<string, number>();
|
|
104
104
|
|
|
105
105
|
async function sweepStaleTemps(dir: string): Promise<void> {
|
|
106
|
-
|
|
107
|
-
sweptDirs.
|
|
106
|
+
const sweepNow = Date.now();
|
|
107
|
+
const lastSweep = sweptDirs.get(dir);
|
|
108
|
+
if (lastSweep !== undefined && sweepNow - lastSweep < STALE_TEMP_MS) return;
|
|
109
|
+
sweptDirs.set(dir, sweepNow);
|
|
108
110
|
try {
|
|
109
111
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
110
|
-
const now = Date.now();
|
|
111
112
|
for (const entry of entries) {
|
|
112
113
|
if (!entry.isFile() || !TEMP_UUID_RE.test(entry.name)) continue;
|
|
113
114
|
const tempPath = join(dir, entry.name);
|
|
114
115
|
try {
|
|
115
116
|
const stats = await stat(tempPath);
|
|
116
|
-
if (
|
|
117
|
+
if (sweepNow - stats.mtimeMs > STALE_TEMP_MS) {
|
|
117
118
|
await rm(tempPath, { force: true });
|
|
118
119
|
}
|
|
119
120
|
} catch {
|
package/src/grep.ts
CHANGED
|
@@ -274,10 +274,16 @@ function makeHitFromIndices(
|
|
|
274
274
|
};
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
let cachedRgPath: string | undefined;
|
|
278
|
+
export function clearRgPathCache(): void {
|
|
279
|
+
cachedRgPath = undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
277
282
|
async function resolveRgPath(): Promise<string> {
|
|
283
|
+
if (cachedRgPath !== undefined) return cachedRgPath;
|
|
278
284
|
try {
|
|
279
285
|
const r = spawnSync("rg", ["--version"], { stdio: "pipe" });
|
|
280
|
-
if (!r.error && r.status === 0) return "rg";
|
|
286
|
+
if (!r.error && r.status === 0) { cachedRgPath = "rg"; return "rg"; }
|
|
281
287
|
} catch {}
|
|
282
288
|
try {
|
|
283
289
|
const { homedir } = await import("os");
|
|
@@ -287,7 +293,7 @@ async function resolveRgPath(): Promise<string> {
|
|
|
287
293
|
const bin = join(base, "bin", process.platform === "win32" ? "rg.exe" : "rg");
|
|
288
294
|
if (existsSync(bin)) {
|
|
289
295
|
const r = spawnSync(bin, ["--version"], { stdio: "pipe" });
|
|
290
|
-
if (!r.error && r.status === 0) return bin;
|
|
296
|
+
if (!r.error && r.status === 0) { cachedRgPath = bin; return bin; }
|
|
291
297
|
}
|
|
292
298
|
} catch {}
|
|
293
299
|
try {
|
|
@@ -300,7 +306,7 @@ async function resolveRgPath(): Promise<string> {
|
|
|
300
306
|
const mod = await import("file://" + toolsManagerPath);
|
|
301
307
|
if (mod.ensureTool) {
|
|
302
308
|
const p = await mod.ensureTool("rg", true);
|
|
303
|
-
if (p) return p;
|
|
309
|
+
if (p) { cachedRgPath = p; return p; }
|
|
304
310
|
}
|
|
305
311
|
} catch {}
|
|
306
312
|
throw new Error("[E_ACCESS] ripgrep (rg) is required for grep but was not found. Install ripgrep or ensure pi can download it to ~/.pi/agent/bin.");
|
|
@@ -366,6 +372,7 @@ async function collectRgMatches(
|
|
|
366
372
|
});
|
|
367
373
|
child.on("error", (error) => {
|
|
368
374
|
cleanup();
|
|
375
|
+
if (rgPath === cachedRgPath) cachedRgPath = undefined;
|
|
369
376
|
reject(error);
|
|
370
377
|
});
|
|
371
378
|
child.on("close", (code) => {
|
|
@@ -494,17 +501,22 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
494
501
|
const sortedNums = [...allNums].sort((a, b) => a - b);
|
|
495
502
|
const indices = sortedNums.map((n) => n - 1).filter((n) => n >= 0);
|
|
496
503
|
if (countOnly) {
|
|
504
|
+
if (globRegex) {
|
|
505
|
+
const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
|
|
506
|
+
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
507
|
+
if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
|
|
508
|
+
}
|
|
497
509
|
const norm = await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
|
|
498
510
|
if (!norm) continue;
|
|
499
511
|
const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, indices.length);
|
|
500
512
|
const display = displayRowsForHit(hit);
|
|
501
513
|
totalRows += display.length;
|
|
502
514
|
for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
|
|
503
|
-
const
|
|
504
|
-
if (
|
|
505
|
-
const add = Math.min(hit.matchCount,
|
|
515
|
+
const remainingCountOnly = limit - matches;
|
|
516
|
+
if (remainingCountOnly > 0) {
|
|
517
|
+
const add = Math.min(hit.matchCount, remainingCountOnly);
|
|
506
518
|
matches += add;
|
|
507
|
-
if (hit.matchCount >
|
|
519
|
+
if (hit.matchCount > remainingCountOnly) limitTruncated = true;
|
|
508
520
|
} else {
|
|
509
521
|
limitTruncated = true;
|
|
510
522
|
}
|
|
@@ -515,13 +527,13 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
515
527
|
limitTruncated = true;
|
|
516
528
|
break;
|
|
517
529
|
}
|
|
518
|
-
const norm = await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
|
|
519
|
-
if (!norm) continue;
|
|
520
530
|
if (globRegex) {
|
|
521
531
|
const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
|
|
522
532
|
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
523
533
|
if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
|
|
524
534
|
}
|
|
535
|
+
const norm = await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
|
|
536
|
+
if (!norm) continue;
|
|
525
537
|
const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
|
|
526
538
|
if (!hit) continue;
|
|
527
539
|
const display = displayRowsForHit(hit);
|
package/src/hash-store.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
1
2
|
import { existsSync } from "fs";
|
|
2
3
|
import { chmod, readFile, rename, mkdir, stat } from "fs/promises";
|
|
3
4
|
import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
|
|
@@ -226,7 +227,7 @@ function isHealthy(db: RawDb): boolean {
|
|
|
226
227
|
}
|
|
227
228
|
|
|
228
229
|
async function quarantineStore(storePath: string): Promise<void> {
|
|
229
|
-
const suffix = `.corrupt-${Date.now()}`;
|
|
230
|
+
const suffix = `.corrupt-${Date.now()}-${process.pid}-${randomUUID()}`;
|
|
230
231
|
for (const candidate of [storePath, `${storePath}-wal`, `${storePath}-shm`]) {
|
|
231
232
|
try {
|
|
232
233
|
await rename(candidate, `${candidate}${suffix}`);
|
|
@@ -442,7 +443,7 @@ export function getSnapshot(
|
|
|
442
443
|
snapshotCache.set(path, cached);
|
|
443
444
|
return cached.hashes.slice();
|
|
444
445
|
}
|
|
445
|
-
const row = store.stmts.get(path, checksum, lineCount);
|
|
446
|
+
const row = withBusyRetry(() => store.stmts.get(path, checksum, lineCount));
|
|
446
447
|
const parsed = parseStoredHashes(row, () => {
|
|
447
448
|
if (deleteCorrupt) store.stmts.deleteOne(path);
|
|
448
449
|
snapshotCache.delete(path);
|
|
@@ -484,7 +485,7 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
|
|
|
484
485
|
}
|
|
485
486
|
|
|
486
487
|
export function getUndoEntry(store: HashStore, path: string): UndoRecord | undefined {
|
|
487
|
-
const row = store.stmts.undoGet(path);
|
|
488
|
+
const row = withBusyRetry(() => store.stmts.undoGet(path));
|
|
488
489
|
if (!row) return undefined;
|
|
489
490
|
const parsed = parseStoredHashes(row, () => store.stmts.undoDelete(path));
|
|
490
491
|
if (!parsed) return undefined;
|
package/src/hashline/parse.ts
CHANGED
|
@@ -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/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";
|
|
@@ -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/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
|
@@ -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
|
+
}
|