pi-hashline-edit-pro 3.0.0 → 3.0.2
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/prompts/replace-guidelines.md +2 -1
- package/src/commit.ts +10 -6
- package/src/constants.ts +4 -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-response.ts +44 -3
- package/src/replace-undo.ts +3 -0
- package/src/replace.ts +22 -6
- 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.2",
|
|
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",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
- `replace`: use the same anchor for `remove_from` and `remove_to` to change one line.
|
|
2
2
|
- `replace`: `replacement_lines` takes bare lines without `│`; `[""]` is one blank line; pasted `anchor│` prefixes are stripped automatically.
|
|
3
3
|
- `replace`: keep the range tight — only lines that actually change — and copy leading spaces exactly.
|
|
4
|
-
- `replace`: post-edit diff `+anchor│`/` anchor│` rows are fresh anchors for the next edit — no new `read` needed. One edit per turn; check the diff before the next edit on that file.
|
|
4
|
+
- `replace`: post-edit diff `+anchor│`/` anchor│` rows are fresh anchors for the next edit — no new `read` needed. One edit per turn; check the diff before the next edit on that file.
|
|
5
|
+
- `replace`: if `replacement_lines` re-include the boundary line adjacent to the range, it is deduplicated automatically, shown as `dedup│content` rows in the diff (not editable, never use `dedup` as an anchor).
|
package/src/commit.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { PipelineResult } from "./replace";
|
|
2
|
-
import { abortIf
|
|
2
|
+
import { abortIf } from "./utils";
|
|
3
|
+
import { DEDUP_ANCHOR } from "./constants";
|
|
4
|
+
import { HASH_SEP } from "./hashline";
|
|
3
5
|
import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
|
|
4
6
|
import { saveUndo } from "./replace-undo";
|
|
5
7
|
import { safeSnapId } from "./file-reader";
|
|
@@ -22,10 +24,10 @@ export interface CommitMeta {
|
|
|
22
24
|
onNoopDedup?: () => void;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
function boundaryDedupWarning(
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
return `Boundary dedup: ${
|
|
27
|
+
function boundaryDedupWarning(count: number): string {
|
|
28
|
+
const noun = count === 1 ? "1 line" : `${count} lines`;
|
|
29
|
+
const row = count === 1 ? "row" : "rows";
|
|
30
|
+
return `Boundary dedup: ${noun} not added again (see ${DEDUP_ANCHOR}${HASH_SEP} ${row}).`;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
|
|
@@ -61,7 +63,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
61
63
|
);
|
|
62
64
|
}
|
|
63
65
|
if (pipe.boundaryRemovedLineTexts.length > 0) {
|
|
64
|
-
warnings.push(boundaryDedupWarning(pipe.boundaryRemovedLineTexts));
|
|
66
|
+
warnings.push(boundaryDedupWarning(pipe.boundaryRemovedLineTexts.length));
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
abortIf(signal);
|
|
@@ -109,6 +111,8 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
109
111
|
warnings,
|
|
110
112
|
snapshotId: updatedSnapshotId,
|
|
111
113
|
editMeta,
|
|
114
|
+
boundaryDedupAbove: pipe.boundaryDedupAbove,
|
|
115
|
+
boundaryDedupBelow: pipe.boundaryDedupBelow,
|
|
112
116
|
};
|
|
113
117
|
const changed = buildChanged(successInput, meta.verb);
|
|
114
118
|
if (changed.details.diff) {
|
package/src/constants.ts
CHANGED
|
@@ -6,7 +6,11 @@ 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 =
|
|
12
14
|
`[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
|
|
15
|
+
|
|
16
|
+
export const DEDUP_ANCHOR = "dedup";
|
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-response.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { formatSize, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import type { NEdit } from "./hashline";
|
|
3
|
+
import { HASH_SEP } from "./hashline";
|
|
2
4
|
import type { ReplaceDetails } from "./replace";
|
|
3
5
|
import { genDiff, genPatch } from "./replace-diff";
|
|
4
6
|
import { visLines, clipLine } from "./utils";
|
|
7
|
+
import { DEDUP_ANCHOR } from "./constants";
|
|
5
8
|
|
|
6
9
|
export type TResult = {
|
|
7
10
|
content: Array<{ type: "text"; text: string }>;
|
|
@@ -46,6 +49,8 @@ export interface SuccessInput {
|
|
|
46
49
|
warnings: string[] | undefined;
|
|
47
50
|
snapshotId?: string;
|
|
48
51
|
editMeta: RMeta;
|
|
52
|
+
boundaryDedupAbove?: string[];
|
|
53
|
+
boundaryDedupBelow?: string[];
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
|
|
@@ -124,11 +129,47 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
|
|
|
124
129
|
},
|
|
125
130
|
};
|
|
126
131
|
}
|
|
132
|
+
export function fmtDedupRow(line: string): string {
|
|
133
|
+
const row = `${DEDUP_ANCHOR}${HASH_SEP}${line}`;
|
|
134
|
+
if (Buffer.byteLength(row, "utf-8") <= DEFAULT_MAX_BYTES) return row;
|
|
135
|
+
const size = formatSize(Buffer.byteLength(row, "utf-8"));
|
|
136
|
+
const limit = formatSize(DEFAULT_MAX_BYTES);
|
|
137
|
+
return `${DEDUP_ANCHOR}${HASH_SEP}[Row is ${size}, exceeds ${limit}; content not shown. Use read to see the full line.]`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function isChangeRow(line: string): boolean {
|
|
141
|
+
return line.startsWith("+") || line.startsWith("-");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function withDedupRows(diff: string, lineNumbers: (number | undefined)[], above: string[] | undefined, below: string[] | undefined): { diff: string; lineNumbers: (number | undefined)[] } {
|
|
145
|
+
const top = (above ?? []).map(fmtDedupRow);
|
|
146
|
+
const bottom = (below ?? []).map(fmtDedupRow);
|
|
147
|
+
if (top.length === 0 && bottom.length === 0) return { diff, lineNumbers };
|
|
148
|
+
if (diff.length === 0) return { diff: [...top, ...bottom].join("\n"), lineNumbers: [...lineNumbers, ...[...top, ...bottom].map(() => undefined)] };
|
|
149
|
+
const lines = diff.split("\n");
|
|
150
|
+
let first = -1;
|
|
151
|
+
let last = -1;
|
|
152
|
+
for (let i = 0; i < lines.length; i++) {
|
|
153
|
+
if (isChangeRow(lines[i]!)) {
|
|
154
|
+
if (first < 0) first = i;
|
|
155
|
+
last = i;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (first < 0) return { diff: `${diff}\n${[...top, ...bottom].join("\n")}`, lineNumbers: [...lineNumbers, ...[...top, ...bottom].map(() => undefined)] };
|
|
159
|
+
const out = [...lines];
|
|
160
|
+
const nums = [...lineNumbers];
|
|
161
|
+
out.splice(last + 1, 0, ...bottom);
|
|
162
|
+
nums.splice(last + 1, 0, ...bottom.map(() => undefined));
|
|
163
|
+
out.splice(first, 0, ...top);
|
|
164
|
+
nums.splice(first, 0, ...top.map(() => undefined));
|
|
165
|
+
return { diff: out.join("\n"), lineNumbers: nums };
|
|
166
|
+
}
|
|
127
167
|
|
|
128
168
|
export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
|
|
129
|
-
const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes } = input;
|
|
169
|
+
const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes, boundaryDedupAbove, boundaryDedupBelow } = input;
|
|
130
170
|
const resultLines = visLines(result);
|
|
131
|
-
const
|
|
171
|
+
const baseDiff = genDiff(originalNormalized, result, 1, resultHashes, originalHashes);
|
|
172
|
+
const diffResult = withDedupRows(baseDiff.diff, baseDiff.lineNumbers, boundaryDedupAbove, boundaryDedupBelow);
|
|
132
173
|
const addedLines = editMeta.addedLines;
|
|
133
174
|
const removedLines = editMeta.removedLines;
|
|
134
175
|
const warningsBlock = warnBlock(warnings);
|
|
@@ -161,7 +202,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
|
|
|
161
202
|
patch: patchResult.patch,
|
|
162
203
|
...(patchResult.truncated ? { patchTruncated: true as const } : {}),
|
|
163
204
|
firstChangedLine:
|
|
164
|
-
editMeta.firstChangedLine ??
|
|
205
|
+
editMeta.firstChangedLine ?? baseDiff.firstChangedLine,
|
|
165
206
|
snapshotId,
|
|
166
207
|
metrics,
|
|
167
208
|
diffLineNumbers: diffResult.lineNumbers,
|
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,
|
|
@@ -23,7 +23,7 @@ import { applyEdit,
|
|
|
23
23
|
type NEdit,
|
|
24
24
|
} from "./hashline";
|
|
25
25
|
import { commitEdit } from "./commit";
|
|
26
|
-
import type
|
|
26
|
+
import { withDedupRows, type RMetrics } from "./replace-response";
|
|
27
27
|
import {
|
|
28
28
|
type RPreview,
|
|
29
29
|
type RRState,
|
|
@@ -64,6 +64,8 @@ export interface PipelineResult {
|
|
|
64
64
|
hadBoundaryDedup: boolean;
|
|
65
65
|
boundaryRemovedLines: number;
|
|
66
66
|
boundaryRemovedLineTexts: string[];
|
|
67
|
+
boundaryDedupAbove: string[];
|
|
68
|
+
boundaryDedupBelow: string[];
|
|
67
69
|
identity: FileIdentity;
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -160,11 +162,17 @@ export async function execPipeline(
|
|
|
160
162
|
const path = params.path;
|
|
161
163
|
|
|
162
164
|
const editWarnings: string[] = [];
|
|
165
|
+
let replacementLines = params.replacement_lines;
|
|
166
|
+
const expandedReplacement = decodeStringArray(replacementLines);
|
|
167
|
+
if (expandedReplacement) {
|
|
168
|
+
editWarnings.push('[E_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.');
|
|
169
|
+
replacementLines = expandedReplacement;
|
|
170
|
+
}
|
|
163
171
|
const edit = resEdit(
|
|
164
172
|
{
|
|
165
173
|
remove_from: params.remove_from,
|
|
166
174
|
remove_to: params.remove_to,
|
|
167
|
-
replacement_lines:
|
|
175
|
+
replacement_lines: replacementLines,
|
|
168
176
|
},
|
|
169
177
|
editWarnings,
|
|
170
178
|
);
|
|
@@ -210,6 +218,9 @@ export async function execPipeline(
|
|
|
210
218
|
edit, originalHashes, isNoop, anchorResult.autoFixes?.length ?? 0,
|
|
211
219
|
);
|
|
212
220
|
|
|
221
|
+
const sortedFixes = [...(anchorResult.autoFixes ?? [])].sort((a, b) => a.removedLineIndex - b.removedLineIndex);
|
|
222
|
+
const aboveFixes = sortedFixes.filter((fix) => fix.kind === "leading" || fix.kind === "last-new-before");
|
|
223
|
+
const belowFixes = sortedFixes.filter((fix) => fix.kind === "trailing" || fix.kind === "first-new-after");
|
|
213
224
|
return {
|
|
214
225
|
path,
|
|
215
226
|
originalNormalized,
|
|
@@ -227,7 +238,9 @@ export async function execPipeline(
|
|
|
227
238
|
totalRemovedLines,
|
|
228
239
|
hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
|
|
229
240
|
boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
|
|
230
|
-
boundaryRemovedLineTexts:
|
|
241
|
+
boundaryRemovedLineTexts: sortedFixes.map((fix) => fix.removedLine),
|
|
242
|
+
boundaryDedupAbove: aboveFixes.map((fix) => fix.removedLine),
|
|
243
|
+
boundaryDedupBelow: belowFixes.map((fix) => fix.removedLine),
|
|
231
244
|
identity,
|
|
232
245
|
};
|
|
233
246
|
}
|
|
@@ -238,7 +251,8 @@ export function previewFromPipe(pipe: PipelineResult): RPreview {
|
|
|
238
251
|
error: `No changes made to ${pipe.path}. The edit produced identical content.`,
|
|
239
252
|
};
|
|
240
253
|
}
|
|
241
|
-
|
|
254
|
+
const base = genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes);
|
|
255
|
+
return { diff: withDedupRows(base.diff, base.lineNumbers, pipe.boundaryDedupAbove, pipe.boundaryDedupBelow).diff };
|
|
242
256
|
}
|
|
243
257
|
export function previewError(error: unknown): RPreview {
|
|
244
258
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
@@ -246,6 +260,7 @@ export function previewError(error: unknown): RPreview {
|
|
|
246
260
|
export async function compPreview(
|
|
247
261
|
request: unknown,
|
|
248
262
|
cwd: string,
|
|
263
|
+
signal?: AbortSignal,
|
|
249
264
|
): Promise<RPreview> {
|
|
250
265
|
try {
|
|
251
266
|
const normalized = normReq(request);
|
|
@@ -253,10 +268,11 @@ export async function compPreview(
|
|
|
253
268
|
const pipe = await execPipeline(
|
|
254
269
|
normalized,
|
|
255
270
|
cwd,
|
|
256
|
-
{ accessMode: constants.R_OK, noPersist: true },
|
|
271
|
+
{ accessMode: constants.R_OK, noPersist: true, signal },
|
|
257
272
|
);
|
|
258
273
|
return previewFromPipe(pipe);
|
|
259
274
|
} catch (error: unknown) {
|
|
275
|
+
if (signal?.aborted) throw error;
|
|
260
276
|
return previewError(error);
|
|
261
277
|
}
|
|
262
278
|
}
|
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
|
+
}
|