pi-hashline-edit-pro 4.4.0 → 4.4.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/index.ts +2 -3
- package/package.json +1 -1
- package/prompts/insert.md +3 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +6 -4
- package/prompts/replace.md +5 -1
- package/src/anchor-registry.ts +38 -11
- package/src/auto-read-all.ts +12 -40
- package/src/batch.ts +21 -3
- package/src/commit.ts +2 -0
- package/src/edit-common.ts +19 -6
- package/src/hash-store.ts +7 -1
- package/src/hashline/hash.ts +1 -1
- package/src/hashline/parse.ts +1 -3
- package/src/insert.ts +4 -0
- package/src/replace-diff.ts +2 -4
- package/src/utils.ts +0 -24
package/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { initHasher
|
|
3
|
+
import { initHasher } from "./src/hashline";
|
|
4
4
|
import { regReplace } from "./src/replace";
|
|
5
5
|
import { regInsert } from "./src/insert";
|
|
6
6
|
import { regGrep } from "./src/grep";
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
adjustDiffContextLines,
|
|
24
24
|
setAutoReadAllIgnoreFromText,
|
|
25
25
|
} from "./src/config";
|
|
26
|
-
import { loadHashStore,
|
|
26
|
+
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
27
27
|
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
|
|
28
28
|
import { serveRows } from "./src/served";
|
|
29
29
|
import { finalizeTurn, planAssistantMessage } from "./src/batch";
|
|
@@ -216,7 +216,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
216
216
|
DEFAULT_MAX_LINES,
|
|
217
217
|
);
|
|
218
218
|
const fileLines = splitLines(normalized);
|
|
219
|
-
persistSnapshot(await loadHashStore(), absolutePath, normalized, fileHashes, fileLines.map(lineChecksum));
|
|
220
219
|
serveRows(absolutePath, fileHashes, fileLines, preview.servedHashes);
|
|
221
220
|
return {
|
|
222
221
|
content: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.4.
|
|
3
|
+
"version": "4.4.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",
|
package/prompts/insert.md
CHANGED
|
@@ -1 +1,3 @@
|
|
|
1
|
-
Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes, no embedded newlines. Inserted lines are written exactly as given; nothing else in the file changes.
|
|
1
|
+
Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes, no embedded newlines. Inserted lines are written exactly as given; nothing else in the file changes.
|
|
2
|
+
|
|
3
|
+
Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
package/prompts/read.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only), the `│` separator, then the line content. Target lines in replace and insert by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue with the hinted `offset/limit`. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
|
|
1
|
+
Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only, case-sensitive), the `│` separator, then the line content. Target lines in replace and insert by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue with the hinted `offset/limit`. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
- `replace`: edit
|
|
2
|
-
- `replace`:
|
|
3
|
-
- `replace`:
|
|
4
|
-
- `replace`:
|
|
1
|
+
- `replace`: `-anchor│` rows in a post-edit diff are dead anchors; only `+anchor│` and ` anchor│` rows are live. Check the post-edit diff before the next turn's edits on that file.
|
|
2
|
+
- `replace`: same-file same-message calls batch: disjoint ranges, one undo. A call whose anchors resolve nowhere fails alone; its file's batch still commits.
|
|
3
|
+
- `replace`: `[E_STALE_ANCHOR]` means the anchor is not owned in this session: call `read` on the file for fresh anchors. `[E_RANGE_STALE]` means a line in the range changed since it was shown, or was never shown: retry with the fresh anchors the error returns.
|
|
4
|
+
- `replace`: `[E_BATCH_OVERLAP]` means two batch calls share lines: retry with disjoint ranges. `[E_OP_ABORTED]` means another batch call failed or the file changed: fix that call, then retry, or call `read` for fresh anchors.
|
|
5
|
+
- `replace`: use `replace`/`insert`, not `sed -i` or heredocs — anchor edits are verified against what was shown and undoable.
|
|
6
|
+
- `replace`: `replacement_lines`: one string per line, `[""]` is one blank line, `[]` deletes. Pasted `anchor│` prefixes are stripped. Single line: same anchor for `remove_from` and `remove_to`.
|
package/prompts/replace.md
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes and no embedded newlines; `[]` deletes the range.
|
|
1
|
+
Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes and no embedded newlines; `[]` deletes the range.
|
|
2
|
+
|
|
3
|
+
Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
4
|
+
|
|
5
|
+
Example: read served `Hasu│old` and `arvm│old2`. Call { "remove_from": "Hasu", "remove_to": "arvm", "replacement_lines": ["new"] }. The post-edit diff shows `-Hasu│old`, `-arvm│old2`, `+Qwer│new`: the `-` rows are dead anchors now; the `+` and ` ` rows are live anchors for the next edit.
|
package/src/anchor-registry.ts
CHANGED
|
@@ -568,6 +568,24 @@ export function ownerOf(anchor: string): OwnedAnchor | undefined {
|
|
|
568
568
|
return current()?.owned.get(anchor);
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
+
export interface CaseFoldedOwner {
|
|
572
|
+
anchor: string;
|
|
573
|
+
path: string;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function ownersDifferingOnlyByCase(anchor: string, paths?: ReadonlySet<string>): CaseFoldedOwner[] {
|
|
577
|
+
const state = current();
|
|
578
|
+
if (!state) return [];
|
|
579
|
+
const lower = anchor.toLowerCase();
|
|
580
|
+
const matches: CaseFoldedOwner[] = [];
|
|
581
|
+
for (const [owned, entry] of state.owned) {
|
|
582
|
+
if (owned === anchor || owned.toLowerCase() !== lower) continue;
|
|
583
|
+
if (paths && paths.size > 0 && !paths.has(entry.path)) continue;
|
|
584
|
+
matches.push({ anchor: owned, path: entry.path });
|
|
585
|
+
}
|
|
586
|
+
return matches;
|
|
587
|
+
}
|
|
588
|
+
|
|
571
589
|
export function ownersForPath(path: string): Map<string, string> {
|
|
572
590
|
const claims = new Map<string, string>();
|
|
573
591
|
const state = current();
|
|
@@ -653,7 +671,7 @@ export function alignOwnershipWithSpans(
|
|
|
653
671
|
prevAnchors: string[],
|
|
654
672
|
prevChecksums: string[],
|
|
655
673
|
newChecksums: string[],
|
|
656
|
-
spans: { start: number; end: number; replacementCount: number }[],
|
|
674
|
+
spans: { start: number; end: number; replacementCount: number; carry?: number }[],
|
|
657
675
|
options?: { shadow?: boolean },
|
|
658
676
|
): Aligned {
|
|
659
677
|
const state = options?.shadow ? shadowStateFrom(current()!) : current()!;
|
|
@@ -681,15 +699,24 @@ export function alignOwnershipWithSpans(
|
|
|
681
699
|
}
|
|
682
700
|
}
|
|
683
701
|
const replacement: (string | undefined)[] = new Array(span.replacementCount);
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
702
|
+
const carryIndex =
|
|
703
|
+
span.carry !== undefined && span.carry >= 0 && span.carry < span.replacementCount ? span.carry : undefined;
|
|
704
|
+
const carried = carryIndex === undefined ? undefined : prevAnchors[prevStart];
|
|
705
|
+
if (carryIndex !== undefined && carried !== undefined && prevChecksums[prevStart] === newChecksums[start + carryIndex]) {
|
|
706
|
+
replacement[carryIndex] = carried;
|
|
707
|
+
reused.add(carried);
|
|
708
|
+
} else {
|
|
709
|
+
for (let k = 0; k < span.replacementCount; k++) {
|
|
710
|
+
if (k === carryIndex) continue;
|
|
711
|
+
const positional = k < spanLength ? prevAnchors[prevStart + k] : undefined;
|
|
712
|
+
if (
|
|
713
|
+
positional !== undefined &&
|
|
714
|
+
prevChecksums[prevStart + k] === newChecksums[start + k] &&
|
|
715
|
+
(!state.owned.has(positional) || state.owned.get(positional)!.path === path)
|
|
716
|
+
) {
|
|
717
|
+
replacement[k] = positional;
|
|
718
|
+
reused.add(positional);
|
|
719
|
+
}
|
|
693
720
|
}
|
|
694
721
|
}
|
|
695
722
|
for (let k = 0; k < span.replacementCount; k++) {
|
|
@@ -801,7 +828,7 @@ export async function allocateFileAnchors(
|
|
|
801
828
|
options?: {
|
|
802
829
|
persist?: boolean;
|
|
803
830
|
shadow?: boolean;
|
|
804
|
-
previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number }[] };
|
|
831
|
+
previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number; carry?: number }[] };
|
|
805
832
|
},
|
|
806
833
|
): Promise<string[]> {
|
|
807
834
|
ensureRegistry();
|
package/src/auto-read-all.ts
CHANGED
|
@@ -24,10 +24,9 @@ const EXEC_MAX_BYTES = 64 * 1024 * 1024;
|
|
|
24
24
|
const SCAN_CONCURRENCY = 32;
|
|
25
25
|
const SCAN_LIMIT_MULTIPLIER = 4;
|
|
26
26
|
const MAX_REPORTED_OMISSIONS = 50;
|
|
27
|
-
export const AUTO_READ_ALL_CHUNK_BYTES = 48 * 1024;
|
|
28
27
|
|
|
29
28
|
const HEADER =
|
|
30
|
-
"[hashline auto-read-all] Every non-ignored project file is attached below with live
|
|
29
|
+
"[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for files in [files complete: ...].\nFiles listed as omitted or not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
|
|
31
30
|
|
|
32
31
|
const IMAGE_EXTENSIONS = new Set([
|
|
33
32
|
".avif",
|
|
@@ -87,24 +86,17 @@ function isExcludedBySegment(path: string): boolean {
|
|
|
87
86
|
}
|
|
88
87
|
return false;
|
|
89
88
|
}
|
|
89
|
+
const EXCLUDED_PATTERN_SUFFIXES = [
|
|
90
|
+
".min.js", ".min.css", ".min.mjs", "-min.js", "-min.css", ".umd.js", ".map", ".lock",
|
|
91
|
+
"_pb2.py", ".pb.go", ".g.dart", ".freezed.dart", ".designer.cs", ".g.cs", ".snap",
|
|
92
|
+
];
|
|
93
|
+
const EXCLUDED_PATTERN_INFIXES = [".bundle.", ".chunk.", ".generated.", ".gen."];
|
|
94
|
+
|
|
90
95
|
function isExcludedByPattern(baseLower: string): boolean {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (baseLower.endsWith(".map")) return true;
|
|
96
|
-
if (baseLower.endsWith(".lock")) return true;
|
|
97
|
-
if (baseLower.includes(".generated.") || baseLower.includes(".gen.")) return true;
|
|
98
|
-
if (baseLower.endsWith("_pb2.py")) return true;
|
|
99
|
-
if (baseLower.endsWith(".pb.go")) return true;
|
|
100
|
-
if (baseLower.endsWith(".g.dart")) return true;
|
|
101
|
-
if (baseLower.endsWith(".freezed.dart")) return true;
|
|
102
|
-
if (baseLower.endsWith(".designer.cs")) return true;
|
|
103
|
-
if (baseLower.endsWith(".g.cs")) return true;
|
|
104
|
-
if (baseLower.endsWith(".snap")) return true;
|
|
105
|
-
if (baseLower.startsWith("coreui-icons.")) return true;
|
|
106
|
-
if (baseLower === "coreui.css") return true;
|
|
107
|
-
return false;
|
|
96
|
+
return EXCLUDED_PATTERN_SUFFIXES.some((suffix) => baseLower.endsWith(suffix))
|
|
97
|
+
|| EXCLUDED_PATTERN_INFIXES.some((infix) => baseLower.includes(infix))
|
|
98
|
+
|| baseLower.startsWith("coreui-icons.")
|
|
99
|
+
|| baseLower === "coreui.css";
|
|
108
100
|
}
|
|
109
101
|
export function normalizeAutoReadAllIgnoreList(entries: readonly string[] | undefined): string[] {
|
|
110
102
|
const seen = new Set<string>();
|
|
@@ -203,7 +195,6 @@ export interface AutoReadAllInjection {
|
|
|
203
195
|
files: number;
|
|
204
196
|
bytes: number;
|
|
205
197
|
omitted: string[];
|
|
206
|
-
chunks: string[];
|
|
207
198
|
completeFiles: number;
|
|
208
199
|
}
|
|
209
200
|
|
|
@@ -371,24 +362,6 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
|
|
|
371
362
|
return { files, source, discovered: unique.length, skippedBinary, skippedLarge, skippedOther, skippedByName };
|
|
372
363
|
}
|
|
373
364
|
|
|
374
|
-
export function chunkAutoReadAllSections(sections: string[], maxBytes: number = AUTO_READ_ALL_CHUNK_BYTES): string[] {
|
|
375
|
-
const chunks: string[] = [];
|
|
376
|
-
let current: string[] = [];
|
|
377
|
-
let currentBytes = 0;
|
|
378
|
-
for (const section of sections) {
|
|
379
|
-
const sectionBytes = Buffer.byteLength(section, "utf-8") + 2;
|
|
380
|
-
if (current.length > 0 && currentBytes + sectionBytes > maxBytes) {
|
|
381
|
-
chunks.push(current.join("\n\n"));
|
|
382
|
-
current = [];
|
|
383
|
-
currentBytes = 0;
|
|
384
|
-
}
|
|
385
|
-
current.push(section);
|
|
386
|
-
currentBytes += sectionBytes;
|
|
387
|
-
}
|
|
388
|
-
if (current.length > 0) chunks.push(current.join("\n\n"));
|
|
389
|
-
return chunks;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
365
|
async function candidateFileBytes(cwd: string, file: string): Promise<number | undefined> {
|
|
393
366
|
try {
|
|
394
367
|
const stats = await lstat(resolve(cwd, file));
|
|
@@ -462,12 +435,11 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
|
|
|
462
435
|
}
|
|
463
436
|
if (sections.length === 0) return undefined;
|
|
464
437
|
const sectionTexts = sections.map((section) => section.text);
|
|
465
|
-
const chunks = chunkAutoReadAllSections(sectionTexts);
|
|
466
438
|
const coverage = `[coverage: ${completeFiles} complete]`;
|
|
467
439
|
const completeNames = sections.map((section) => section.file);
|
|
468
440
|
const machineList = `[files complete: ${JSON.stringify(completeNames)} omitted: ${JSON.stringify(omitted)}]`;
|
|
469
441
|
const text = `${HEADER}\n\n${coverage}\n${machineList}\n\n${sectionTexts.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}`;
|
|
470
|
-
return { text, files: sections.length, bytes, omitted,
|
|
442
|
+
return { text, files: sections.length, bytes, omitted, completeFiles };
|
|
471
443
|
}
|
|
472
444
|
|
|
473
445
|
export function autoReadAllBudget(model: { contextWindow?: number } | undefined): number {
|
package/src/batch.ts
CHANGED
|
@@ -63,6 +63,7 @@ export interface BatchPiece {
|
|
|
63
63
|
warnings: string[];
|
|
64
64
|
noop: boolean;
|
|
65
65
|
foldedLines: number;
|
|
66
|
+
carryIndex?: number;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
export interface BatchMemberInput {
|
|
@@ -495,10 +496,18 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
495
496
|
const baseLines = base.baseLines;
|
|
496
497
|
const originalSlice = baseLines.slice(start - 1, end);
|
|
497
498
|
const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
|
|
499
|
+
const foldedLines = input.foldedLines ?? 0;
|
|
500
|
+
const carryIndex =
|
|
501
|
+
input.kind === "insert" && foldedLines > 0
|
|
502
|
+
? input.direction === "after"
|
|
503
|
+
? 0
|
|
504
|
+
: newLines.length - 1
|
|
505
|
+
: undefined;
|
|
498
506
|
const piece: BatchPiece = {
|
|
499
507
|
order: input.member.order,
|
|
500
508
|
kind: input.kind,
|
|
501
509
|
...(input.direction !== undefined ? { direction: input.direction } : {}),
|
|
510
|
+
...(carryIndex !== undefined ? { carryIndex } : {}),
|
|
502
511
|
start,
|
|
503
512
|
end,
|
|
504
513
|
fromHash: input.hedit.hash_bounds[0].hash,
|
|
@@ -506,7 +515,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
506
515
|
newLines: [...newLines],
|
|
507
516
|
warnings: [...input.extraWarnings, ...planned.warnings],
|
|
508
517
|
noop,
|
|
509
|
-
foldedLines
|
|
518
|
+
foldedLines,
|
|
510
519
|
};
|
|
511
520
|
runtime.pieces.push(piece);
|
|
512
521
|
if (input.kind === "replace") runtime.replaceCount += 1;
|
|
@@ -571,6 +580,15 @@ function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
|
|
|
571
580
|
return merged;
|
|
572
581
|
}
|
|
573
582
|
|
|
583
|
+
function pieceMappingSpans(pieces: BatchPiece[]): { start: number; end: number; replacementCount: number; carry?: number }[] {
|
|
584
|
+
return pieces.map((piece) => ({
|
|
585
|
+
start: piece.start - 1,
|
|
586
|
+
end: piece.end - 1,
|
|
587
|
+
replacementCount: piece.newLines.length,
|
|
588
|
+
...(piece.carryIndex !== undefined ? { carry: piece.carryIndex } : {}),
|
|
589
|
+
}));
|
|
590
|
+
}
|
|
591
|
+
|
|
574
592
|
async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise<TResult> {
|
|
575
593
|
const runtime = batches.get(member.batchKey);
|
|
576
594
|
if (!runtime || !runtime.base || !runtime.paths) throw new Error(`[E_STALE_ANCHOR] Batch ${member.display} is no longer tracked. Call read for fresh anchors.`);
|
|
@@ -637,7 +655,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
637
655
|
discardBatchState(runtime);
|
|
638
656
|
throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file changed after the batch started. Call read for fresh anchors and retry.`);
|
|
639
657
|
}
|
|
640
|
-
const preflightSpans = effectivePieces
|
|
658
|
+
const preflightSpans = pieceMappingSpans(effectivePieces);
|
|
641
659
|
try {
|
|
642
660
|
await lineHashes(composed, runtime.target, {
|
|
643
661
|
content: base.content,
|
|
@@ -670,7 +688,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
670
688
|
throw error;
|
|
671
689
|
}
|
|
672
690
|
const updatedSnapshotId = await safeSnapId(paths.absolutePath, "post-edit");
|
|
673
|
-
const spans = effectivePieces
|
|
691
|
+
const spans = pieceMappingSpans(effectivePieces);
|
|
674
692
|
let resultHashes: string[];
|
|
675
693
|
try {
|
|
676
694
|
resultHashes = await lineHashes(composed, runtime.target, {
|
package/src/commit.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { spanForEdit } from "./replace";
|
|
|
12
12
|
import { restoreEndings, stripBOM, toLF } from "./normalize";
|
|
13
13
|
export interface CommitMeta {
|
|
14
14
|
editAnchors?: [string, string];
|
|
15
|
+
anchorCarry?: number;
|
|
15
16
|
path: string;
|
|
16
17
|
absolutePath: string;
|
|
17
18
|
mutationTargetPath: string;
|
|
@@ -107,6 +108,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
107
108
|
};
|
|
108
109
|
|
|
109
110
|
const span = meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined;
|
|
111
|
+
if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
|
|
110
112
|
let resultHashes: string[];
|
|
111
113
|
try {
|
|
112
114
|
resultHashes = await lineHashes(pipe.result, mutationTargetPath, {
|
package/src/edit-common.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { resolveInCwd } from "./fs-write";
|
|
3
3
|
import { abortIf, makePrepareArguments } from "./utils";
|
|
4
|
-
import { ownerOf } from "./anchor-registry";
|
|
4
|
+
import { ownerOf, ownersDifferingOnlyByCase, type OwnedAnchor } from "./anchor-registry";
|
|
5
5
|
import { parseHashRef, stripAnchorRow } from "./hashline";
|
|
6
6
|
import { readConfig } from "./config";
|
|
7
7
|
import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
|
|
@@ -37,7 +37,7 @@ export function withReplacePrompts(base: { description: string; snippet: string;
|
|
|
37
37
|
const snippetParts = [base.snippet];
|
|
38
38
|
let guidelines = [...base.guidelines];
|
|
39
39
|
if (!flags.autoRead) {
|
|
40
|
-
description = description.replace(
|
|
40
|
+
description = description.replace(/\n\nExample:[\s\S]*$/, "");
|
|
41
41
|
guidelines = guidelines.filter((guideline) => !guideline.includes("post-edit diff"));
|
|
42
42
|
description = description.replace("and the last call shows the combined diff,", "and the last call shows the combined result,");
|
|
43
43
|
}
|
|
@@ -95,14 +95,27 @@ export function withUndoPrompts(base: { description: string; snippet: string; gu
|
|
|
95
95
|
const guidelines = base.guidelines.map((guideline) => guideline.includes("bad diff") ? "`undo_last_change`: only the last `replace`/`insert` per file is undoable; a `write` clears it, so undo right after a bad edit — review what you're restoring." : guideline);
|
|
96
96
|
return { description: base.description, snippet: base.snippet, guidelines };
|
|
97
97
|
}
|
|
98
|
+
|
|
99
|
+
function staleAnchorMessage(ref: string, hash: string, owners: Array<OwnedAnchor | undefined>): string {
|
|
100
|
+
const knownPaths = new Set<string>();
|
|
101
|
+
for (const owner of owners) {
|
|
102
|
+
if (owner) knownPaths.add(owner.path);
|
|
103
|
+
}
|
|
104
|
+
const folded = ownersDifferingOnlyByCase(hash, knownPaths);
|
|
105
|
+
const hint =
|
|
106
|
+
folded.length > 0
|
|
107
|
+
? ` Anchors are case-sensitive; ${folded.map((match) => `"${match.anchor}"`).join(", ")} differs only in case.`
|
|
108
|
+
: "";
|
|
109
|
+
return `[E_STALE_ANCHOR] "${ref}" is not owned in this session.${hint} Call read() on the target file first.`;
|
|
110
|
+
}
|
|
111
|
+
|
|
98
112
|
export function resolveEditTarget(removeFrom: string, removeTo?: string): string {
|
|
99
113
|
const refs = [removeFrom, removeTo].filter((value): value is string => typeof value === "string");
|
|
100
|
-
const
|
|
114
|
+
const hashes = refs.map((ref) => parseHashRef(stripAnchorRow(ref.trim(), "anchor entry")).hash);
|
|
115
|
+
const owners = hashes.map((hash) => ownerOf(hash));
|
|
101
116
|
const missing = owners.findIndex((owner) => !owner);
|
|
102
117
|
if (missing >= 0) {
|
|
103
|
-
throw new Error(
|
|
104
|
-
`[E_STALE_ANCHOR] "${refs[missing]!}" is not owned in this session. Call read() on the target file first.`,
|
|
105
|
-
);
|
|
118
|
+
throw new Error(staleAnchorMessage(refs[missing]!, hashes[missing]!, owners));
|
|
106
119
|
}
|
|
107
120
|
const paths = new Set(owners.map((owner) => owner!.path));
|
|
108
121
|
if (paths.size > 1) {
|
package/src/hash-store.ts
CHANGED
|
@@ -529,6 +529,7 @@ export function getAllocatedState(store: HashStore, path: string, deleteCorrupt
|
|
|
529
529
|
checksums = undefined;
|
|
530
530
|
}
|
|
531
531
|
}
|
|
532
|
+
if (checksums !== undefined && checksums.length !== parsedAnchors.length) return undefined;
|
|
532
533
|
return { anchors: parsedAnchors, checksums, contentChecksum: row.checksum };
|
|
533
534
|
}
|
|
534
535
|
|
|
@@ -550,8 +551,13 @@ export function getUndoEntry(store: HashStore, path: string): UndoRecord | undef
|
|
|
550
551
|
if (!row) return undefined;
|
|
551
552
|
const parsed = parseStoredHashes(row, () => store.stmts.undoDelete(path));
|
|
552
553
|
if (!parsed) return undefined;
|
|
554
|
+
const content = row.content as string;
|
|
555
|
+
if (splitLines(content).length !== parsed.length) {
|
|
556
|
+
store.stmts.undoDelete(path);
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
553
559
|
return {
|
|
554
|
-
content
|
|
560
|
+
content,
|
|
555
561
|
bom: row.bom as string,
|
|
556
562
|
ending: row.ending as string,
|
|
557
563
|
hashes: parsed,
|
package/src/hashline/hash.ts
CHANGED
|
@@ -101,7 +101,7 @@ export function _lineHashesPure(content: string): string[] {
|
|
|
101
101
|
export async function lineHashes(
|
|
102
102
|
content: string,
|
|
103
103
|
path?: string,
|
|
104
|
-
previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number }[] },
|
|
104
|
+
previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number; carry?: number }[] },
|
|
105
105
|
store?: HashStore,
|
|
106
106
|
persist?: boolean,
|
|
107
107
|
shadow?: boolean,
|
package/src/hashline/parse.ts
CHANGED
|
@@ -38,7 +38,7 @@ function diagRef(ref: string): string {
|
|
|
38
38
|
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 4-character anchor (letters only, e.g. "Hasu").`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function
|
|
41
|
+
export function parseHashRef(ref: string): Anchor {
|
|
42
42
|
const trimmed = ref.trim();
|
|
43
43
|
|
|
44
44
|
if (
|
|
@@ -51,8 +51,6 @@ function parseRef(ref: string): Anchor {
|
|
|
51
51
|
throw new Error(diagRef(ref));
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export const parseHashRef = parseRef;
|
|
55
|
-
|
|
56
54
|
const JSON_ENVELOPE_RE = /^\s*\["(.*)"\]\.\s*$/;
|
|
57
55
|
|
|
58
56
|
function unwrapJsonEnvelope(line: string): string {
|
package/src/insert.ts
CHANGED
|
@@ -241,6 +241,10 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
241
241
|
path: pipe.path,
|
|
242
242
|
absolutePath,
|
|
243
243
|
mutationTargetPath,
|
|
244
|
+
editAnchors: [editParams.remove_from, editParams.remove_to],
|
|
245
|
+
...(anchorLine === undefined
|
|
246
|
+
? {}
|
|
247
|
+
: { anchorCarry: req.direction === "after" ? 0 : editParams.replacement_lines.length - 1 }),
|
|
244
248
|
signal,
|
|
245
249
|
verb: "inserted",
|
|
246
250
|
noopNoun: "Insertion",
|
package/src/replace-diff.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface DiffSpan {
|
|
|
23
23
|
start: number;
|
|
24
24
|
end: number;
|
|
25
25
|
replacementCount: number;
|
|
26
|
+
carry?: number;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
interface PlacedSpan {
|
|
@@ -37,10 +38,7 @@ function fmtDiffLine(
|
|
|
37
38
|
line: string,
|
|
38
39
|
hash: string | undefined,
|
|
39
40
|
): string {
|
|
40
|
-
|
|
41
|
-
return `${prefix}${" ".repeat(ANCHOR_LEN)}${HASH_SEP}${line}`;
|
|
42
|
-
}
|
|
43
|
-
return `${prefix}${hash}${HASH_SEP}${line}`;
|
|
41
|
+
return `${prefix}${hash ?? " ".repeat(ANCHOR_LEN)}${HASH_SEP}${line}`;
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
function isBlankLine(line: string): boolean {
|
package/src/utils.ts
CHANGED
|
@@ -99,30 +99,6 @@ export function errCode(error: unknown): string | undefined {
|
|
|
99
99
|
return undefined;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
export function lastNonEmptyIndex(lines: string[]): number {
|
|
103
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
104
|
-
if (lines[i]!.length > 0) return i;
|
|
105
|
-
}
|
|
106
|
-
return -1;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function firstNonEmptyIndex(lines: string[]): number {
|
|
110
|
-
for (let i = 0; i < lines.length; i++) {
|
|
111
|
-
if (lines[i]!.length > 0) return i;
|
|
112
|
-
}
|
|
113
|
-
return -1;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function lastNonEmpty(lines: string[]): string | undefined {
|
|
117
|
-
const idx = lastNonEmptyIndex(lines);
|
|
118
|
-
return idx >= 0 ? lines[idx] : undefined;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function firstNonEmpty(lines: string[]): string | undefined {
|
|
122
|
-
const idx = firstNonEmptyIndex(lines);
|
|
123
|
-
return idx >= 0 ? lines[idx] : undefined;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
102
|
export function truncateToBytes(s: string, maxBytes: number): string {
|
|
127
103
|
if (Buffer.byteLength(s, "utf-8") <= maxBytes) return s;
|
|
128
104
|
let out = "";
|