pi-hashline-edit-pro 2.7.2 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/index.ts +9 -6
- package/package.json +2 -2
- package/prompts/grep-guidelines.md +3 -7
- package/prompts/grep-snippet.md +1 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +3 -7
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -9
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -4
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/file-kind.ts +8 -6
- package/src/file-reader.ts +25 -11
- package/src/fs-write.ts +28 -2
- package/src/grep.ts +18 -28
- package/src/hash-store.ts +52 -16
- package/src/hashline/hash.ts +11 -9
- package/src/hashline/parse.ts +15 -1
- package/src/hashline/resolve.ts +3 -2
- package/src/insert.ts +9 -14
- package/src/payload-contract.ts +102 -0
- package/src/read.ts +15 -1
- package/src/replace-diff.ts +26 -21
- package/src/replace-render.ts +18 -40
- package/src/replace-response.ts +1 -0
- package/src/replace-undo.ts +13 -10
- package/src/replace.ts +43 -106
- package/src/served.ts +26 -5
- package/src/utils.ts +58 -0
- package/src/write-hook.ts +59 -0
- package/src/replace-normalize.ts +0 -13
package/src/hash-store.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { initHasher, contentChecksum } from "./hashline/hasher";
|
|
|
6
6
|
import { HASH_RE } from "./hashline/alphabet";
|
|
7
7
|
import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
|
|
8
8
|
|
|
9
|
+
export const STORE_NOT_OPEN_MESSAGE = "Hash store is not open; transactional update aborted";
|
|
10
|
+
|
|
9
11
|
type SqlParams = (string | number)[];
|
|
10
12
|
|
|
11
13
|
interface RawStatement {
|
|
@@ -104,21 +106,22 @@ export function isValidHashList(value: unknown): value is string[] {
|
|
|
104
106
|
return true;
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
export function parseHashList(raw: string, onInvalid: () => void): string[] | undefined {
|
|
109
|
+
export function parseHashList(raw: string, onInvalid: () => void, context?: string): string[] | undefined {
|
|
108
110
|
let parsed: unknown;
|
|
109
111
|
try {
|
|
110
112
|
parsed = JSON.parse(raw);
|
|
111
|
-
} catch {
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error(`[parseHashList]${context ? ` ${context}:` : ""} failed to parse stored hashes JSON:`, error);
|
|
112
115
|
onInvalid();
|
|
113
116
|
return undefined;
|
|
114
117
|
}
|
|
115
118
|
if (!isValidHashList(parsed)) {
|
|
119
|
+
console.error(`[parseHashList]${context ? ` ${context}:` : ""} stored hashes did not pass validation:`, Array.isArray(parsed) ? `length=${parsed.length} sample=${JSON.stringify(parsed.slice(0, 3))}` : (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
|
|
116
120
|
onInvalid();
|
|
117
121
|
return undefined;
|
|
118
122
|
}
|
|
119
123
|
return parsed;
|
|
120
124
|
}
|
|
121
|
-
|
|
122
125
|
export function parseStoredHashes(
|
|
123
126
|
row: Record<string, unknown> | undefined,
|
|
124
127
|
onInvalid: () => void,
|
|
@@ -157,13 +160,14 @@ function isBusyError(error: unknown): boolean {
|
|
|
157
160
|
return error instanceof Error && /busy|locked/i.test(error.message);
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
const sleepSab = new Int32Array(new SharedArrayBuffer(4));
|
|
164
|
+
|
|
160
165
|
function sleepSync(ms: number): void {
|
|
161
|
-
|
|
162
|
-
Atomics.wait(sab, 0, 0, ms);
|
|
166
|
+
Atomics.wait(sleepSab, 0, 0, ms);
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
const BUSY_RETRIES = 3;
|
|
166
|
-
const BUSY_RETRY_DELAY_MS =
|
|
170
|
+
const BUSY_RETRY_DELAY_MS = 50;
|
|
167
171
|
|
|
168
172
|
function withBusyRetry<T>(fn: () => T): T {
|
|
169
173
|
let lastError: unknown;
|
|
@@ -173,14 +177,28 @@ function withBusyRetry<T>(fn: () => T): T {
|
|
|
173
177
|
} catch (error) {
|
|
174
178
|
lastError = error;
|
|
175
179
|
if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
|
|
176
|
-
sleepSync(BUSY_RETRY_DELAY_MS);
|
|
180
|
+
sleepSync(BUSY_RETRY_DELAY_MS * (1 << attempt));
|
|
177
181
|
}
|
|
178
182
|
}
|
|
179
183
|
throw lastError;
|
|
180
184
|
}
|
|
181
185
|
|
|
182
|
-
function
|
|
183
|
-
|
|
186
|
+
async function withBusyRetryAsync<T>(fn: () => T): Promise<T> {
|
|
187
|
+
let lastError: unknown;
|
|
188
|
+
for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
|
|
189
|
+
try {
|
|
190
|
+
return fn();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
lastError = error;
|
|
193
|
+
if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
|
|
194
|
+
await new Promise<void>((r) => setTimeout(r, BUSY_RETRY_DELAY_MS * (1 << attempt)));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
throw lastError;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function openDbWithBusyRetryAsync(storePath: string): Promise<{ db: RawDb; stmts: Prepared }> {
|
|
201
|
+
return withBusyRetryAsync(() => openDb(storePath));
|
|
184
202
|
}
|
|
185
203
|
|
|
186
204
|
function retriedWrite(
|
|
@@ -341,19 +359,19 @@ async function openStore(storePath: string): Promise<HashStore> {
|
|
|
341
359
|
let existed = existsSync(storePath);
|
|
342
360
|
let opened: { db: RawDb; stmts: Prepared };
|
|
343
361
|
try {
|
|
344
|
-
opened =
|
|
362
|
+
opened = await openDbWithBusyRetryAsync(storePath);
|
|
345
363
|
} catch (error) {
|
|
346
364
|
if (!isCorruptionError(error)) throw error;
|
|
347
365
|
console.error("Hash store failed to open, rebuilding:", error);
|
|
348
366
|
await quarantineStore(storePath);
|
|
349
367
|
existed = false;
|
|
350
|
-
opened =
|
|
368
|
+
opened = await openDbWithBusyRetryAsync(storePath);
|
|
351
369
|
}
|
|
352
370
|
if (!isHealthy(opened.db)) {
|
|
353
371
|
shutdownDb(opened.db);
|
|
354
372
|
await quarantineStore(storePath);
|
|
355
373
|
existed = false;
|
|
356
|
-
opened =
|
|
374
|
+
opened = await openDbWithBusyRetryAsync(storePath);
|
|
357
375
|
}
|
|
358
376
|
const { db, stmts } = opened;
|
|
359
377
|
|
|
@@ -403,9 +421,9 @@ export function shutdownHashStore(): void {
|
|
|
403
421
|
snapshotCache.clear();
|
|
404
422
|
}
|
|
405
423
|
|
|
406
|
-
function withStore(fn: () => void): void {
|
|
424
|
+
export function withStore(fn: () => void): void {
|
|
407
425
|
if (!cachedDb) {
|
|
408
|
-
throw new Error(
|
|
426
|
+
throw new Error(STORE_NOT_OPEN_MESSAGE);
|
|
409
427
|
}
|
|
410
428
|
withBusyRetry(() => {
|
|
411
429
|
cachedDb!.db.exec("BEGIN IMMEDIATE");
|
|
@@ -528,6 +546,14 @@ export function upsertSnapshot(
|
|
|
528
546
|
store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), Date.now());
|
|
529
547
|
cacheSnapshot(path, checksum, lineCount, hashes);
|
|
530
548
|
}
|
|
549
|
+
export function persistSnapshot(
|
|
550
|
+
store: HashStore,
|
|
551
|
+
path: string,
|
|
552
|
+
content: string,
|
|
553
|
+
hashes: string[],
|
|
554
|
+
): void {
|
|
555
|
+
upsertSnapshot(store, path, contentChecksum(content), splitLines(content).length, hashes);
|
|
556
|
+
}
|
|
531
557
|
|
|
532
558
|
export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): void {
|
|
533
559
|
store.stmts.undoUpsert(
|
|
@@ -593,22 +619,32 @@ export async function pruneMissing(store: HashStore): Promise<void> {
|
|
|
593
619
|
withStore(() => {
|
|
594
620
|
for (const path of missing) {
|
|
595
621
|
store.stmts.deleteOne(path);
|
|
596
|
-
snapshotCache.delete(path);
|
|
597
622
|
store.stmts.servedDelete(path);
|
|
598
623
|
}
|
|
599
624
|
});
|
|
625
|
+
for (const path of missing) snapshotCache.delete(path);
|
|
600
626
|
}
|
|
601
627
|
|
|
602
628
|
function matchPathsByHashes(
|
|
603
629
|
rows: { path: string; hashes: string }[],
|
|
604
630
|
hashes: string[],
|
|
605
631
|
): string[] {
|
|
632
|
+
const needed = new Set(hashes);
|
|
633
|
+
if (needed.size === 0) return [];
|
|
606
634
|
const matches: string[] = [];
|
|
607
635
|
for (const row of rows) {
|
|
608
636
|
try {
|
|
609
637
|
const parsed = JSON.parse(row.hashes) as unknown;
|
|
610
638
|
if (!isValidHashList(parsed)) continue;
|
|
611
|
-
|
|
639
|
+
const parsedSet = new Set(parsed);
|
|
640
|
+
let ok = true;
|
|
641
|
+
for (const h of needed) {
|
|
642
|
+
if (!parsedSet.has(h)) {
|
|
643
|
+
ok = false;
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (ok) matches.push(row.path);
|
|
612
648
|
} catch {
|
|
613
649
|
continue;
|
|
614
650
|
}
|
package/src/hashline/hash.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { splitLines, truncateToBytes } from "../utils";
|
|
1
|
+
import { splitLines, truncateToBytes, getCached } from "../utils";
|
|
2
2
|
import { MAX_HASH_SOURCE_BYTES } from "../constants";
|
|
3
3
|
import {
|
|
4
4
|
loadHashStore,
|
|
5
5
|
type HashStore,
|
|
6
6
|
getSnapshot,
|
|
7
|
-
|
|
7
|
+
persistSnapshot,
|
|
8
8
|
} from "../hash-store";
|
|
9
|
-
import { xxh32,
|
|
9
|
+
import { xxh32, initHasher } from "./hasher";
|
|
10
10
|
import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS, HASH_RUN } from "./alphabet";
|
|
11
11
|
export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS, HASH_RUN };
|
|
12
12
|
|
|
@@ -120,9 +120,10 @@ export function _lineHashesPure(content: string): string[] {
|
|
|
120
120
|
const hashes = new Array<string>(lines.length);
|
|
121
121
|
const used = new Uint32Array(BITSET_WORDS);
|
|
122
122
|
const hint = { value: 0 };
|
|
123
|
+
const hashSourceCache = new Map<string, string>();
|
|
123
124
|
|
|
124
125
|
for (let i = 0; i < lines.length; i++) {
|
|
125
|
-
const c =
|
|
126
|
+
const c = getCached(hashSourceCache, lines[i]!, hashSource);
|
|
126
127
|
const baseIdx = (xxh32(c) >>> 14) % HASH_SPACE;
|
|
127
128
|
hashes[i] = assignHash(used, baseIdx, hint);
|
|
128
129
|
}
|
|
@@ -151,7 +152,7 @@ export async function lineHashes(
|
|
|
151
152
|
);
|
|
152
153
|
if (persist !== false) {
|
|
153
154
|
try {
|
|
154
|
-
|
|
155
|
+
persistSnapshot(hashStore, path, content, newHashes);
|
|
155
156
|
} catch (error) {
|
|
156
157
|
console.error("Failed to persist hash snapshot:", error);
|
|
157
158
|
}
|
|
@@ -172,7 +173,7 @@ export async function lineHashes(
|
|
|
172
173
|
const newHashes = _lineHashesPure(content);
|
|
173
174
|
if (persist !== false) {
|
|
174
175
|
try {
|
|
175
|
-
|
|
176
|
+
persistSnapshot(hashStore, path, content, newHashes);
|
|
176
177
|
} catch (error) {
|
|
177
178
|
console.error("Failed to persist hash snapshot:", error);
|
|
178
179
|
}
|
|
@@ -224,6 +225,7 @@ function mapStableHashes(
|
|
|
224
225
|
const newHashes = new Array<string>(newLines.length);
|
|
225
226
|
const used = new Uint32Array(BITSET_WORDS);
|
|
226
227
|
const hint = { value: 0 };
|
|
228
|
+
const hashSourceCache = new Map<string, string>();
|
|
227
229
|
const removed = removedHashes ?? new Set<string>();
|
|
228
230
|
|
|
229
231
|
const oldHashIndex = new Map<string, number>();
|
|
@@ -260,7 +262,7 @@ function mapStableHashes(
|
|
|
260
262
|
|
|
261
263
|
const newByContent = new Map<string, number[]>();
|
|
262
264
|
for (let i = 0; i < newLines.length; i++) {
|
|
263
|
-
const key =
|
|
265
|
+
const key = getCached(hashSourceCache, newLines[i]!, hashSource);
|
|
264
266
|
const list = newByContent.get(key);
|
|
265
267
|
if (list) list.push(i);
|
|
266
268
|
else newByContent.set(key, [i]);
|
|
@@ -275,7 +277,7 @@ function mapStableHashes(
|
|
|
275
277
|
};
|
|
276
278
|
|
|
277
279
|
for (const entry of survivors) {
|
|
278
|
-
const candidates = newByContent.get(
|
|
280
|
+
const candidates = newByContent.get(getCached(hashSourceCache, oldLines[entry.index]!, hashSource));
|
|
279
281
|
if (!candidates || candidates.length === 0) continue;
|
|
280
282
|
const target = entry.index > spanEnd ? entry.index + shiftAfterSpan : entry.index;
|
|
281
283
|
const pos = nearestNew(candidates, target);
|
|
@@ -306,7 +308,7 @@ function mapStableHashes(
|
|
|
306
308
|
|
|
307
309
|
for (let i = 0; i < newLines.length; i++) {
|
|
308
310
|
if (newHashes[i]) continue;
|
|
309
|
-
const c =
|
|
311
|
+
const c = getCached(hashSourceCache, newLines[i]!, hashSource);
|
|
310
312
|
const baseIdx = (xxh32(c) >>> 14) % HASH_SPACE;
|
|
311
313
|
newHashes[i] = assignHash(used, baseIdx, hint);
|
|
312
314
|
}
|
package/src/hashline/parse.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ANCHOR_LEN,
|
|
3
3
|
ALPH_RE,
|
|
4
|
+
HASH_CLASS,
|
|
4
5
|
} from "./hash";
|
|
5
6
|
import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
|
|
6
7
|
|
|
8
|
+
const HASH_EXTRACT_RE = new RegExp(HASH_CLASS);
|
|
9
|
+
|
|
7
10
|
export type Anchor = { hash: string };
|
|
8
11
|
|
|
9
12
|
function diagRef(ref: string): string {
|
|
@@ -16,7 +19,18 @@ function diagRef(ref: string): string {
|
|
|
16
19
|
if (/^\d+/.test(trimmed)) {
|
|
17
20
|
return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "aB3"): no line numbers or trailing content.`;
|
|
18
21
|
}
|
|
19
|
-
|
|
22
|
+
if (trimmed.includes("│") && trimmed.includes("\n")) {
|
|
23
|
+
const lines = trimmed.split(/\r?\n/);
|
|
24
|
+
const first = lines[0] ?? "";
|
|
25
|
+
const last = lines[lines.length - 1] ?? "";
|
|
26
|
+
const hashRe = HASH_EXTRACT_RE;
|
|
27
|
+
const firstMatch = first.match(hashRe);
|
|
28
|
+
const lastMatch = last.match(hashRe);
|
|
29
|
+
const firstHash = firstMatch?.[0] ?? "aB3";
|
|
30
|
+
const lastHash = lastMatch?.[0] ?? "aB3";
|
|
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 3-char hash (e.g. "aB3"), 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
|
+
}
|
|
20
34
|
if (trimmed.includes("│")) {
|
|
21
35
|
return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char anchor, drop everything from "│" onward.`;
|
|
22
36
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
|
|
1
|
+
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached } from "../utils";
|
|
2
2
|
import { HASH_SEP, HASH_RUN, stripRowPrefix, canon } from "./hash";
|
|
3
3
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
4
4
|
import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
@@ -462,7 +462,8 @@ export function valEdit(
|
|
|
462
462
|
}
|
|
463
463
|
const endLine = endResolved.line;
|
|
464
464
|
const rangeLines = fileLines.slice(startResolved.line - 1, endLine);
|
|
465
|
-
const
|
|
465
|
+
const canonCache = new Map<string, string>();
|
|
466
|
+
const canonLines = fileLines.map((line) => getCached(canonCache, line, canon));
|
|
466
467
|
boundaryDups.push(
|
|
467
468
|
...trailingDups(edit.content_lines, fileLines, endLine),
|
|
468
469
|
...leadingDups(edit.content_lines, fileLines, startResolved.line),
|
package/src/insert.ts
CHANGED
|
@@ -2,16 +2,14 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
|
|
|
2
2
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { constants } from "fs";
|
|
5
|
-
import { execPipeline, type ReqParams, type ReplaceDetails } from "./replace";
|
|
5
|
+
import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
|
|
6
6
|
import { commitEdit } from "./commit";
|
|
7
7
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
|
-
import {
|
|
8
|
+
import { resolveInCwd } from "./fs-write";
|
|
9
9
|
import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
|
|
10
10
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
11
|
-
import { toCwd } from "./paths";
|
|
12
11
|
import { loadP, loadGuide } from "./prompts";
|
|
13
|
-
import { normReq } from "./
|
|
14
|
-
import { genDiff } from "./replace-diff";
|
|
12
|
+
import { normReq } from "./payload-contract";
|
|
15
13
|
import { makeRenderCall, renderEditResult, type RPreview, type RRState } from "./replace-render";
|
|
16
14
|
import { abortIf, isRec, makePrepareArguments, rejectUnknownFields, splitLines } from "./utils";
|
|
17
15
|
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
@@ -121,12 +119,9 @@ export async function insertPreview(request: unknown, cwd: string): Promise<RPre
|
|
|
121
119
|
preloadedNorm: preload,
|
|
122
120
|
skipBoundaryDedup: true,
|
|
123
121
|
});
|
|
124
|
-
|
|
125
|
-
return { error: `No changes made to ${normalized.path}. The edit produced identical content.` };
|
|
126
|
-
}
|
|
127
|
-
return { diff: genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes).diff };
|
|
122
|
+
return previewFromPipe(pipe);
|
|
128
123
|
} catch (error: unknown) {
|
|
129
|
-
return
|
|
124
|
+
return previewError(error);
|
|
130
125
|
}
|
|
131
126
|
}
|
|
132
127
|
|
|
@@ -164,16 +159,17 @@ export function buildInsertToolDef(): InsertToolDef {
|
|
|
164
159
|
promptSnippet: loadP("../prompts/insert-snippet.md"),
|
|
165
160
|
promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
|
|
166
161
|
prepareArguments: makePrepareArguments(),
|
|
162
|
+
executionMode: "sequential",
|
|
167
163
|
parameters: insertToolSchema,
|
|
168
164
|
renderShell: "default",
|
|
169
165
|
renderCall: makeRenderCall(insertPreview, { getInput: getInsertInput, toolName: "insert" }),
|
|
170
|
-
renderResult(result, { isPartial }, theme, context) {
|
|
166
|
+
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
171
167
|
return renderEditResult(
|
|
172
168
|
result as {
|
|
173
169
|
content?: Array<{ type: string; text?: string }>;
|
|
174
170
|
details?: ReplaceDetails;
|
|
175
171
|
},
|
|
176
|
-
isPartial,
|
|
172
|
+
{ isPartial, expanded },
|
|
177
173
|
theme,
|
|
178
174
|
context,
|
|
179
175
|
);
|
|
@@ -185,8 +181,7 @@ export function buildInsertToolDef(): InsertToolDef {
|
|
|
185
181
|
const req = canonical;
|
|
186
182
|
const path = req.path;
|
|
187
183
|
const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
|
|
188
|
-
const absolutePath =
|
|
189
|
-
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
184
|
+
const { absolute: absolutePath, resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
|
|
190
185
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
191
186
|
abortIf(signal);
|
|
192
187
|
const preload = await readNormFile(path, ctx.cwd, {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { isRec, normalizeFilePath, rejectUnknownFields } from "./utils";
|
|
3
|
+
|
|
4
|
+
const replacementLinesSchema = Type.Array(
|
|
5
|
+
Type.String({
|
|
6
|
+
description:
|
|
7
|
+
"One replacement line. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
|
|
8
|
+
}),
|
|
9
|
+
{
|
|
10
|
+
description:
|
|
11
|
+
"Replacement lines as an array of strings, one element per line. Use [] to delete the range.",
|
|
12
|
+
},
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const removeFromSchema = Type.String({
|
|
16
|
+
description:
|
|
17
|
+
"Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const removeToSchema = Type.String({
|
|
21
|
+
description:
|
|
22
|
+
"Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the LAST line to remove (inclusive)",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const editToolSchema = Type.Object(
|
|
26
|
+
{
|
|
27
|
+
path: Type.Optional(
|
|
28
|
+
Type.String({
|
|
29
|
+
description:
|
|
30
|
+
"Path to edit. Required: always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake.",
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
remove_from: removeFromSchema,
|
|
34
|
+
remove_to: removeToSchema,
|
|
35
|
+
replacement_lines: replacementLinesSchema,
|
|
36
|
+
},
|
|
37
|
+
{ additionalProperties: false },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export type ReqParams = {
|
|
41
|
+
path: string;
|
|
42
|
+
remove_from: string;
|
|
43
|
+
remove_to: string;
|
|
44
|
+
replacement_lines: string[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
|
|
48
|
+
|
|
49
|
+
export function assertReq(request: unknown): asserts request is ReqParams {
|
|
50
|
+
if (!isRec(request)) {
|
|
51
|
+
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
52
|
+
}
|
|
53
|
+
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
54
|
+
if (typeof request.path !== "string" || request.path.length === 0) {
|
|
55
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
typeof request.remove_from !== "string" ||
|
|
59
|
+
typeof request.remove_to !== "string" ||
|
|
60
|
+
!Array.isArray(request.replacement_lines) ||
|
|
61
|
+
request.replacement_lines.some((line) => typeof line !== "string")
|
|
62
|
+
) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
'[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" (array of strings, one per line; use [] to delete).',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function normReq(input: unknown): unknown {
|
|
70
|
+
if (!isRec(input)) {
|
|
71
|
+
return input;
|
|
72
|
+
}
|
|
73
|
+
const record: Record<string, unknown> = { ...input };
|
|
74
|
+
normalizeFilePath(record);
|
|
75
|
+
return record;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getPreviewInput(args: unknown): ReqParams | null {
|
|
79
|
+
let normalized: unknown;
|
|
80
|
+
try {
|
|
81
|
+
normalized = normReq(args);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!isRec(normalized) || typeof normalized.path !== "string") {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
typeof normalized.remove_from !== "string" ||
|
|
90
|
+
typeof normalized.remove_to !== "string" ||
|
|
91
|
+
!Array.isArray(normalized.replacement_lines) ||
|
|
92
|
+
normalized.replacement_lines.some((line) => typeof line !== "string")
|
|
93
|
+
) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
path: normalized.path,
|
|
98
|
+
remove_from: normalized.remove_from,
|
|
99
|
+
remove_to: normalized.remove_to,
|
|
100
|
+
replacement_lines: normalized.replacement_lines,
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/read.ts
CHANGED
|
@@ -13,10 +13,11 @@ import { MAX_OVERSIZED_WARNING_LINES } from "./constants";
|
|
|
13
13
|
import { readNormFile, safeSnapId } from "./file-reader";
|
|
14
14
|
import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
15
15
|
import { toCwd } from "./paths";
|
|
16
|
-
import { abortIf, makePrepareArguments, visLines } from "./utils";
|
|
16
|
+
import { abortIf, makePrepareArguments, numberedRead, visLines } from "./utils";
|
|
17
17
|
import { recordServedSafe } from "./served";
|
|
18
18
|
import { loadP, loadGuide } from "./prompts";
|
|
19
19
|
import { valAccess } from "./validation";
|
|
20
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
20
21
|
|
|
21
22
|
const R_DESC = loadP("../prompts/read.md");
|
|
22
23
|
|
|
@@ -190,6 +191,18 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
190
191
|
}),
|
|
191
192
|
),
|
|
192
193
|
}),
|
|
194
|
+
executionMode: "sequential",
|
|
195
|
+
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
196
|
+
if (isPartial) return new Text((theme as unknown as { fg: (a:string,b:string)=>string }).fg("warning", "Reading..."), 0, 0);
|
|
197
|
+
const raw = (result.content?.[0] as { text?: string } | undefined)?.text;
|
|
198
|
+
if (typeof raw !== "string") return new Text("", 0, 0);
|
|
199
|
+
if ((context as unknown as { isError?: boolean }).isError) return new Text((theme as unknown as { fg: (a:string,b:string)=>string }).fg("error", raw), 0, 0);
|
|
200
|
+
const isExpanded = expanded === true || (context as unknown as { expanded?: boolean }).expanded === true;
|
|
201
|
+
if (!isExpanded) return new Text("", 0, 0);
|
|
202
|
+
const details = (result as unknown as { details?: { offset?: number } }).details;
|
|
203
|
+
const off = details?.offset ?? (context as unknown as { args?: { offset?: number } }).args?.offset ?? 1;
|
|
204
|
+
return new Text(numberedRead(raw, off), 0, 0);
|
|
205
|
+
},
|
|
193
206
|
|
|
194
207
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
195
208
|
const rawPath = params.path;
|
|
@@ -235,6 +248,7 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
235
248
|
details: {
|
|
236
249
|
truncation: preview.truncation,
|
|
237
250
|
snapshotId,
|
|
251
|
+
offset: params.offset ?? 1,
|
|
238
252
|
...(preview.nextOffset !== undefined
|
|
239
253
|
? { nextOffset: preview.nextOffset }
|
|
240
254
|
: {}),
|
package/src/replace-diff.ts
CHANGED
|
@@ -9,13 +9,13 @@ import {
|
|
|
9
9
|
export type LineEnding = "\r\n" | "\n" | "\r";
|
|
10
10
|
|
|
11
11
|
export function detectEnding(content: string): LineEnding {
|
|
12
|
+
const crIdx = content.indexOf("\r");
|
|
12
13
|
const lfIdx = content.indexOf("\n");
|
|
13
|
-
if (lfIdx === -1)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
14
|
+
if (crIdx === -1 && lfIdx === -1) return "\n";
|
|
15
|
+
if (crIdx === -1) return "\n";
|
|
16
|
+
if (lfIdx === -1) return "\r";
|
|
17
|
+
if (crIdx < lfIdx) return content[crIdx + 1] === "\n" ? "\r\n" : "\r";
|
|
18
|
+
return "\n";
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function toLF(text: string): string {
|
|
@@ -69,7 +69,7 @@ export function genDiff(
|
|
|
69
69
|
newContentHashes?: string[],
|
|
70
70
|
oldContentHashes?: string[],
|
|
71
71
|
limits?: DiffLimits,
|
|
72
|
-
): { diff: string; firstChangedLine: number | undefined } {
|
|
72
|
+
): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
|
|
73
73
|
const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
|
|
74
74
|
const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
|
|
75
75
|
const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
@@ -83,8 +83,9 @@ export function genDiff(
|
|
|
83
83
|
let outBytes = 0;
|
|
84
84
|
let stopped = false;
|
|
85
85
|
let diffTruncated = false;
|
|
86
|
+
const lineNumbers: (number|undefined)[] = [];
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
const emitPlain = (line: string, num?: number): void => {
|
|
88
89
|
if (stopped) return;
|
|
89
90
|
const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
|
|
90
91
|
if (outBytes + lineBytes > maxBytes) {
|
|
@@ -94,15 +95,16 @@ export function genDiff(
|
|
|
94
95
|
}
|
|
95
96
|
outBytes += lineBytes;
|
|
96
97
|
output.push(line);
|
|
98
|
+
lineNumbers.push(num);
|
|
97
99
|
};
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void => {
|
|
100
102
|
if (stopped) return;
|
|
101
103
|
const full = fmtDiffLine(prefix, line, hash);
|
|
102
104
|
const rowBytes = Buffer.byteLength(full, "utf-8");
|
|
103
105
|
if (rowBytes > maxLineBytes) {
|
|
104
106
|
const marker = `[Row is ${formatSize(rowBytes)}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
|
|
105
|
-
emitPlain(fmtDiffLine(prefix, marker, hash));
|
|
107
|
+
emitPlain(fmtDiffLine(prefix, marker, hash), num);
|
|
106
108
|
return;
|
|
107
109
|
}
|
|
108
110
|
if (outBytes + rowBytes + 1 > maxBytes) {
|
|
@@ -112,6 +114,7 @@ export function genDiff(
|
|
|
112
114
|
}
|
|
113
115
|
outBytes += rowBytes + 1;
|
|
114
116
|
output.push(full);
|
|
117
|
+
lineNumbers.push(num);
|
|
115
118
|
};
|
|
116
119
|
|
|
117
120
|
for (let i = 0; i < parts.length; i++) {
|
|
@@ -127,11 +130,11 @@ export function genDiff(
|
|
|
127
130
|
if (stopped) break;
|
|
128
131
|
if (part.added) {
|
|
129
132
|
const hash = effectiveNewHashes[newLineNum - 1];
|
|
130
|
-
emitRow("+", displayLines[k]!, hash);
|
|
133
|
+
emitRow("+", displayLines[k]!, hash, newLineNum);
|
|
131
134
|
newLineNum++;
|
|
132
135
|
} else {
|
|
133
136
|
const hash = oldContentHashes?.[oldLineNum - 1];
|
|
134
|
-
emitRow("-", displayLines[k]!, hash);
|
|
137
|
+
emitRow("-", displayLines[k]!, hash, oldLineNum);
|
|
135
138
|
oldLineNum++;
|
|
136
139
|
}
|
|
137
140
|
}
|
|
@@ -198,25 +201,25 @@ export function genDiff(
|
|
|
198
201
|
}
|
|
199
202
|
|
|
200
203
|
if (skipStart > 0) {
|
|
201
|
-
emitPlain(" ...");
|
|
204
|
+
emitPlain(" ...", undefined);
|
|
202
205
|
newLineNum += skipStart;
|
|
203
206
|
oldLineNum += skipStart;
|
|
204
207
|
}
|
|
205
208
|
for (const line of linesToShow) {
|
|
206
209
|
if (stopped) break;
|
|
207
210
|
if (isEllipsisMarker(line)) {
|
|
208
|
-
emitPlain(" ...");
|
|
211
|
+
emitPlain(" ...", undefined);
|
|
209
212
|
newLineNum += skipMiddle;
|
|
210
213
|
oldLineNum += skipMiddle;
|
|
211
214
|
continue;
|
|
212
215
|
}
|
|
213
216
|
const hash = effectiveNewHashes[newLineNum - 1];
|
|
214
|
-
emitRow(" ", line, hash);
|
|
217
|
+
emitRow(" ", line, hash, newLineNum);
|
|
215
218
|
newLineNum++;
|
|
216
219
|
oldLineNum++;
|
|
217
220
|
}
|
|
218
221
|
if (skipTail > 0) {
|
|
219
|
-
emitPlain(" ...");
|
|
222
|
+
emitPlain(" ...", undefined);
|
|
220
223
|
}
|
|
221
224
|
} else {
|
|
222
225
|
newLineNum += displayLines.length;
|
|
@@ -227,10 +230,12 @@ export function genDiff(
|
|
|
227
230
|
|
|
228
231
|
if (diffTruncated) {
|
|
229
232
|
output.push(" ...");
|
|
233
|
+
lineNumbers.push(undefined);
|
|
230
234
|
output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);
|
|
235
|
+
lineNumbers.push(undefined);
|
|
231
236
|
}
|
|
232
237
|
|
|
233
|
-
return { diff: output.join("\n"), firstChangedLine };
|
|
238
|
+
return { diff: output.join("\n"), firstChangedLine, lineNumbers };
|
|
234
239
|
}
|
|
235
240
|
|
|
236
241
|
export function genPatch(
|
|
@@ -239,10 +244,10 @@ export function genPatch(
|
|
|
239
244
|
newContent: string,
|
|
240
245
|
limits?: DiffLimits,
|
|
241
246
|
): { patch: string; truncated: boolean } {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
247
|
+
const patchOpts: Record<string, unknown> = { context: 4 };
|
|
248
|
+
const ho = (Diff as unknown as Record<string, unknown>).FILE_HEADERS_ONLY;
|
|
249
|
+
if (ho !== undefined) patchOpts.headerOptions = ho;
|
|
250
|
+
const full = (Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, patchOpts as never) as unknown as string) ?? "";
|
|
246
251
|
const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
|
|
247
252
|
const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
248
253
|
const out: string[] = [];
|