dsh-hashline-edit 0.2.1 → 0.2.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/README.md +60 -56
- package/cordis.patch.yml +3 -3
- package/lib/index.js +7224 -6454
- package/lib/types/dsh/contracts.d.ts +3 -12
- package/lib/types/dsh/fs.d.ts +0 -3
- package/lib/types/dsh/guidance.d.ts +5 -5
- package/lib/types/dsh/host-compat.d.ts +78 -7
- package/lib/types/dsh/index.d.ts +9 -21
- package/lib/types/dsh/metrics/events.d.ts +1 -1
- package/lib/types/dsh/metrics/observer.d.ts +1 -1
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +1 -1
- package/lib/types/dsh/tools/edit.d.ts +10 -0
- package/lib/types/dsh/tools/read.d.ts +3 -3
- package/lib/types/src/domain-errors.d.ts +2 -16
- package/lib/types/src/edit-tool.d.ts +41 -0
- package/lib/types/src/edit-undo.d.ts +2 -11
- package/lib/types/src/file-content/index.d.ts +1 -1
- package/lib/types/src/file-content/preview.d.ts +1 -1
- package/lib/types/src/hash-store.d.ts +2 -2
- package/lib/types/src/hashline/alphabet.d.ts +16 -1
- package/lib/types/src/hashline/hash-identity.d.ts +10 -4
- package/lib/types/src/mutation-engine/engine.d.ts +31 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +1 -8
- package/lib/types/src/mutation-engine/types.d.ts +50 -40
- package/lib/types/src/payload-contract.d.ts +135 -0
- package/lib/types/src/served-session/serve-render.d.ts +0 -6
- package/lib/types/src/undo-store.d.ts +1 -17
- package/package.json +21 -18
- package/prompts/edit-guidelines.md +10 -0
- package/prompts/edit-snippet.md +1 -0
- package/prompts/edit.md +1 -0
- package/prompts/read-guidelines.md +4 -4
- package/prompts/read.md +1 -1
- package/prompts/undo-last-edit-guidelines.md +2 -2
- package/prompts/undo-last-edit-snippet.md +1 -1
- package/prompts/undo-last-edit.md +1 -1
- package/src/bash-classifier.ts +1626 -0
- package/src/bash-conversion/decide.ts +812 -0
- package/src/bash-display-transform.ts +50 -0
- package/src/bash-multifile-config.ts +239 -0
- package/src/bash-view-config.ts +158 -0
- package/src/cut-repair.ts +11 -77
- package/src/domain-errors.ts +3 -35
- package/src/edit-normalize.ts +8 -0
- package/src/edit-render.ts +161 -0
- package/src/edit-tool.ts +118 -0
- package/src/edit-undo.ts +25 -379
- package/src/edit.ts +136 -0
- package/src/file-content/index.ts +1 -1
- package/src/file-content/preview.ts +1 -1
- package/src/file-content/served-header.ts +1 -1
- package/src/hash-store.ts +1 -18
- package/src/hashline/alphabet.ts +42 -1
- package/src/hashline/hash-identity.ts +12 -3
- package/src/integrations/pi-lens/command.ts +712 -0
- package/src/integrations/pi-lens/config.ts +148 -0
- package/src/integrations/pi-lens/io-bridge-adapter.ts +234 -0
- package/src/integrations/pi-lens/io-bridge.ts +112 -0
- package/src/integrations/pi-lens/settings-overlay.ts +272 -0
- package/src/lifecycle-hooks/index.ts +1992 -0
- package/src/lifecycle-hooks/types.ts +64 -0
- package/src/mutated-files.ts +75 -0
- package/src/mutation-engine/engine.ts +239 -0
- package/src/mutation-engine/index.ts +17 -0
- package/src/mutation-engine/pipeline.ts +27 -198
- package/src/mutation-engine/types.ts +57 -39
- package/src/mutation-queue.ts +1 -1
- package/src/payload-contract.ts +685 -0
- package/src/preview-controller.ts +84 -0
- package/src/prompts.ts +43 -10
- package/src/read.ts +865 -0
- package/src/search-hook.ts +41 -0
- package/src/served-session/serve-render.ts +2 -2
- package/src/store-config.ts +73 -0
- package/src/store-janitor-warning.ts +169 -0
- package/src/store-janitor.ts +539 -0
- package/src/store-kernel/settings.ts +233 -0
- package/src/store.ts +154 -0
- package/src/tui-presenter.ts +134 -0
- package/src/undo-store.ts +5 -74
- package/src/write-hook.ts +130 -0
- package/lib/types/dsh/hide.d.ts +0 -79
- package/lib/types/dsh/tools/apply-patch.d.ts +0 -38
- package/lib/types/src/apply-patch/commit.d.ts +0 -49
- package/lib/types/src/apply-patch/grammar.d.ts +0 -29
- package/lib/types/src/apply-patch/lifecycle.d.ts +0 -32
- package/lib/types/src/apply-patch/plan.d.ts +0 -50
- package/lib/types/src/apply-patch/render.d.ts +0 -51
- package/lib/types/src/apply-patch/resolve.d.ts +0 -129
- package/lib/types/src/apply-patch/tool.d.ts +0 -54
- package/lib/types/src/apply-patch/types.d.ts +0 -362
- package/lib/types/src/fs-lifecycle.d.ts +0 -44
- package/lib/types/src/host-utils.d.ts +0 -63
- package/lib/types/src/mutation-engine/edit-request.d.ts +0 -34
- package/prompts/apply-patch-guidelines.md +0 -10
- package/prompts/apply-patch.md +0 -1
- package/src/apply-patch/commit.ts +0 -190
- package/src/apply-patch/grammar.ts +0 -620
- package/src/apply-patch/index.ts +0 -17
- package/src/apply-patch/lifecycle.ts +0 -195
- package/src/apply-patch/plan.ts +0 -800
- package/src/apply-patch/render.ts +0 -137
- package/src/apply-patch/resolve.ts +0 -470
- package/src/apply-patch/tool.ts +0 -257
- package/src/apply-patch/types.ts +0 -387
- package/src/fs-lifecycle.ts +0 -201
- package/src/host-utils.ts +0 -224
- package/src/mutation-engine/edit-request.ts +0 -39
package/src/edit-undo.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { withFileMutationQueue as piFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
2
4
|
import {
|
|
3
5
|
readUndo,
|
|
4
6
|
writeUndo,
|
|
@@ -19,14 +21,13 @@ import {
|
|
|
19
21
|
import { loadHashStore } from "./hash-store.js";
|
|
20
22
|
import { sessionKeyFor } from "./served-session/session.js";
|
|
21
23
|
import { readBytes, resolveTarget, writeAtomic } from "./fs-write.js";
|
|
22
|
-
import { pruneEmptyDirs, removeFile, renameFile } from "./fs-lifecycle.js";
|
|
23
24
|
import { toCwd } from "./paths.js";
|
|
24
25
|
import { DEFERRED_STORE_SYNC_WARNING } from "./constants.js";
|
|
25
26
|
import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./edit-diff.js";
|
|
26
27
|
import { visLines, splitLines, errCode } from "./utils.js";
|
|
28
|
+
import { loadP, loadGuide } from "./prompts.js";
|
|
27
29
|
import { buildMetrics, type EditDetails } from "./edit-response.js";
|
|
28
30
|
import { DomainError, withPayloadSubject } from "./domain-errors.js";
|
|
29
|
-
import type { UndoLifecycle } from "./undo-store.js";
|
|
30
31
|
import { ANCHOR_GENERATION, lineHashes } from "./hashline/hash-identity.js";
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -67,16 +68,6 @@ export interface UndoEntry {
|
|
|
67
68
|
rawPre?: string | null;
|
|
68
69
|
/** The anchor generation the stored hashes were derived under (0 = pre-generation). */
|
|
69
70
|
anchorGeneration?: number | null;
|
|
70
|
-
/** The lifecycle action this entry reverses; absent/null is an ordinary edit. */
|
|
71
|
-
lifecycle?: UndoLifecycle | null;
|
|
72
|
-
/** Move: the absolute pre-transaction path. */
|
|
73
|
-
fromPath?: string | null;
|
|
74
|
-
/** Move: the absolute committed path. */
|
|
75
|
-
toPath?: string | null;
|
|
76
|
-
/** The raw post-transaction text when the commit wrote non-canonical bytes (pure move). */
|
|
77
|
-
rawPost?: string | null;
|
|
78
|
-
/** Add: directories the add created, outermost first. */
|
|
79
|
-
createdDirs?: readonly string[] | null;
|
|
80
71
|
}
|
|
81
72
|
|
|
82
73
|
export async function saveUndo(
|
|
@@ -98,11 +89,6 @@ export async function saveUndo(
|
|
|
98
89
|
snapshotHash: entry.snapshotHash ?? snapshotHashFor(entry.content),
|
|
99
90
|
transactionId: entry.transactionId ?? null,
|
|
100
91
|
rawPre: entry.rawPre ?? null,
|
|
101
|
-
lifecycle: entry.lifecycle ?? null,
|
|
102
|
-
fromPath: entry.fromPath ?? null,
|
|
103
|
-
toPath: entry.toPath ?? null,
|
|
104
|
-
rawPost: entry.rawPost ?? null,
|
|
105
|
-
createdDirs: entry.createdDirs ?? null,
|
|
106
92
|
// WHY: ADR-0031 §4 — forward the payload generation so the store stamp (upsertUndo)
|
|
107
93
|
// WHY: can preserve it on replay; absent (ordinary edit) defaults to ANCHOR_GENERATION there.
|
|
108
94
|
anchorGeneration: entry.anchorGeneration ?? ANCHOR_GENERATION,
|
|
@@ -135,7 +121,7 @@ export async function getUndo(path: string): Promise<UndoEntry | undefined> {
|
|
|
135
121
|
await removeUndo(path);
|
|
136
122
|
return undefined;
|
|
137
123
|
}
|
|
138
|
-
|
|
124
|
+
return {
|
|
139
125
|
content: record.content,
|
|
140
126
|
bom: record.bom,
|
|
141
127
|
originalEnding,
|
|
@@ -145,15 +131,6 @@ export async function getUndo(path: string): Promise<UndoEntry | undefined> {
|
|
|
145
131
|
transactionId: record.transactionId ?? null,
|
|
146
132
|
anchorGeneration: record.anchorGeneration ?? 0,
|
|
147
133
|
};
|
|
148
|
-
if (record.lifecycle !== null && record.lifecycle !== undefined)
|
|
149
|
-
entry.lifecycle = record.lifecycle;
|
|
150
|
-
if (record.fromPath !== null && record.fromPath !== undefined) entry.fromPath = record.fromPath;
|
|
151
|
-
if (record.toPath !== null && record.toPath !== undefined) entry.toPath = record.toPath;
|
|
152
|
-
if (record.rawPost !== null && record.rawPost !== undefined) entry.rawPost = record.rawPost;
|
|
153
|
-
if (record.createdDirs !== null && record.createdDirs !== undefined) {
|
|
154
|
-
entry.createdDirs = record.createdDirs;
|
|
155
|
-
}
|
|
156
|
-
return entry;
|
|
157
134
|
} catch (error) {
|
|
158
135
|
// SAFETY: best-effort undo load — failures return undefined (no history) and caller reports "No undo history"; stale or corrupt store is recoverable on next edit, not silent undefined without log.
|
|
159
136
|
reportDiagnostic("edit-undo", "Failed to load undo entry:", error);
|
|
@@ -185,16 +162,6 @@ interface UndoMember {
|
|
|
185
162
|
rawPre: string | null;
|
|
186
163
|
/** The anchor generation the stored hashes were derived under (0 = pre-generation). */
|
|
187
164
|
anchorGeneration: number;
|
|
188
|
-
/** Lifecycle action this row reverses; `edit` is the back-compatible default. */
|
|
189
|
-
lifecycle: UndoLifecycle;
|
|
190
|
-
/** Move: absolute pre-transaction path. */
|
|
191
|
-
fromPath: string | null;
|
|
192
|
-
/** Move: absolute committed path. */
|
|
193
|
-
toPath: string | null;
|
|
194
|
-
/** The raw post bytes when the commit wrote a non-canonical image (pure move). */
|
|
195
|
-
rawPost: string | null;
|
|
196
|
-
/** Add: the directories the add created, outermost first. */
|
|
197
|
-
createdDirs: readonly string[];
|
|
198
165
|
}
|
|
199
166
|
|
|
200
167
|
function toMember(row: UndoRecord & { path: string }, displayPath: string): UndoMember | undefined {
|
|
@@ -211,11 +178,6 @@ function toMember(row: UndoRecord & { path: string }, displayPath: string): Undo
|
|
|
211
178
|
snapshotHash: row.snapshotHash ?? null,
|
|
212
179
|
rawPre: row.rawPre ?? null,
|
|
213
180
|
anchorGeneration: row.anchorGeneration ?? 0,
|
|
214
|
-
lifecycle: (row.lifecycle as UndoLifecycle | null) ?? "edit",
|
|
215
|
-
fromPath: row.fromPath ?? null,
|
|
216
|
-
toPath: row.toPath ?? null,
|
|
217
|
-
rawPost: row.rawPost ?? null,
|
|
218
|
-
createdDirs: row.createdDirs ?? [],
|
|
219
181
|
};
|
|
220
182
|
}
|
|
221
183
|
|
|
@@ -274,18 +236,6 @@ async function undoCorrelatedTransaction(
|
|
|
274
236
|
};
|
|
275
237
|
}
|
|
276
238
|
|
|
277
|
-
// WHY: a row whose lifecycle is anything but `edit` reverses a namespace action (add/delete/
|
|
278
|
-
// WHY: move). Those need path-level restore steps, not `writeAtomic(pre)`; one lifecycle-aware
|
|
279
|
-
// WHY: executor handles every member of the batch (including the edit members beside them).
|
|
280
|
-
if (members.some((m) => m.lifecycle !== "edit")) {
|
|
281
|
-
return await undoLifecycleTransaction(
|
|
282
|
-
txnId,
|
|
283
|
-
requestedAbsolutePath,
|
|
284
|
-
requestedDisplayPath,
|
|
285
|
-
members,
|
|
286
|
-
ctx,
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
239
|
const sessionKeyForUndo = sessionKeyFor(
|
|
290
240
|
// SAFETY: ctx is untyped at the pi boundary — cast validated by pi's runtime shape
|
|
291
241
|
ctx as unknown as { sessionManager?: { getSessionId(): string } },
|
|
@@ -613,330 +563,6 @@ async function undoCorrelatedTransaction(
|
|
|
613
563
|
);
|
|
614
564
|
}
|
|
615
565
|
|
|
616
|
-
/**
|
|
617
|
-
* Undo ONE correlated transaction that contains lifecycle members (apply_patch's Add/Delete/Move
|
|
618
|
-
* batch, possibly beside ordinary text edits). Same durable recipe as `undoCorrelatedTransaction`:
|
|
619
|
-
* validate every member by BYTES first, land the revert intent before the first write, complete a
|
|
620
|
-
* partially failed revert inline, fail closed with `E_UNDO_REVERT_FAILED` only when completion is
|
|
621
|
-
* itself defeated. The namespace steps are the only addition: an add undo removes the created
|
|
622
|
-
* file (and the empty directories it made), a delete undo re-creates it from the raw pre-image,
|
|
623
|
-
* and a move undo renames back before restoring the pre bytes.
|
|
624
|
-
*/
|
|
625
|
-
async function undoLifecycleTransaction(
|
|
626
|
-
txnId: string,
|
|
627
|
-
requestedAbsolutePath: string,
|
|
628
|
-
requestedDisplayPath: string,
|
|
629
|
-
members: UndoMember[],
|
|
630
|
-
ctx: unknown,
|
|
631
|
-
): Promise<UndoLastEditResult> {
|
|
632
|
-
const sessionKeyForUndo = sessionKeyFor(
|
|
633
|
-
// SAFETY: ctx is untyped at the pi boundary — cast validated by pi's runtime shape
|
|
634
|
-
ctx as unknown as { sessionManager?: { getSessionId(): string } },
|
|
635
|
-
);
|
|
636
|
-
const names = members.map((member) => member.displayPath).join(", ");
|
|
637
|
-
const fileExists = async (path: string): Promise<boolean> => {
|
|
638
|
-
try {
|
|
639
|
-
await readBytes(path);
|
|
640
|
-
return true;
|
|
641
|
-
} catch (error) {
|
|
642
|
-
if (errCode(error) === "ENOENT") return false;
|
|
643
|
-
throw error;
|
|
644
|
-
}
|
|
645
|
-
};
|
|
646
|
-
const stale = (member: UndoMember, reason: "deleted" | "modified"): UndoLastEditResult => ({
|
|
647
|
-
content: [
|
|
648
|
-
{
|
|
649
|
-
type: "text" as const,
|
|
650
|
-
text: new DomainError("E_UNDO_STALE", { path: member.displayPath, reason }).message,
|
|
651
|
-
},
|
|
652
|
-
],
|
|
653
|
-
isError: true,
|
|
654
|
-
details: {},
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
return withSortedMutationQueues(
|
|
658
|
-
members.map((member) => member.absolutePath),
|
|
659
|
-
async () => {
|
|
660
|
-
// WHY: STEP 1 — validate EVERY member before reverting ANY. A delete row's post state
|
|
661
|
-
// WHY: is "absent"; every other row's post state is its bytes. A move's pre path must not exist.
|
|
662
|
-
for (const member of members) {
|
|
663
|
-
const committedPath =
|
|
664
|
-
member.lifecycle === "move"
|
|
665
|
-
? (member.toPath ?? member.absolutePath)
|
|
666
|
-
: member.absolutePath;
|
|
667
|
-
if (member.lifecycle === "delete") {
|
|
668
|
-
if (await fileExists(committedPath)) return stale(member, "modified");
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
671
|
-
let bytes: Buffer;
|
|
672
|
-
try {
|
|
673
|
-
bytes = await readBytes(committedPath);
|
|
674
|
-
} catch (error) {
|
|
675
|
-
if (errCode(error) === "ENOENT") return stale(member, "deleted");
|
|
676
|
-
throw error;
|
|
677
|
-
}
|
|
678
|
-
const expectedPost =
|
|
679
|
-
member.rawPost !== null
|
|
680
|
-
? Buffer.from(member.rawPost, "utf-8")
|
|
681
|
-
: Buffer.from(
|
|
682
|
-
member.bom + restoreEndings(member.resultContent, member.ending),
|
|
683
|
-
"utf-8",
|
|
684
|
-
);
|
|
685
|
-
if (!bytes.equals(expectedPost)) return stale(member, "modified");
|
|
686
|
-
if (member.lifecycle === "move" && member.fromPath !== null) {
|
|
687
|
-
if (await fileExists(member.fromPath)) return stale(member, "modified");
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// WHY: STEP 2 — prepare every restore IN MEMORY (no write side touched yet).
|
|
692
|
-
interface RestorePlan {
|
|
693
|
-
member: UndoMember;
|
|
694
|
-
restorePath: string;
|
|
695
|
-
restoreBytes: Buffer | null;
|
|
696
|
-
removeOnly: boolean;
|
|
697
|
-
moveFrom: string | null;
|
|
698
|
-
moveTo: string | null;
|
|
699
|
-
dirs: readonly string[];
|
|
700
|
-
content: string;
|
|
701
|
-
currentNormalized: string;
|
|
702
|
-
diff: string;
|
|
703
|
-
servedRows: ServedRow[];
|
|
704
|
-
restoredContentHash: string;
|
|
705
|
-
restoredLineCount: number;
|
|
706
|
-
restoredHashes: string[];
|
|
707
|
-
displacedAnchors: string[];
|
|
708
|
-
}
|
|
709
|
-
const plans: RestorePlan[] = [];
|
|
710
|
-
for (const member of members) {
|
|
711
|
-
const isDelete = member.lifecycle === "delete";
|
|
712
|
-
const isAdd = member.lifecycle === "add";
|
|
713
|
-
const isMove = member.lifecycle === "move";
|
|
714
|
-
const committedPath = isMove ? (member.toPath ?? member.absolutePath) : member.absolutePath;
|
|
715
|
-
const restorePath = isMove ? (member.fromPath ?? member.absolutePath) : member.absolutePath;
|
|
716
|
-
const currentNormalized = isDelete ? "" : member.resultContent;
|
|
717
|
-
const content = isDelete || isMove || !isAdd ? member.content : "";
|
|
718
|
-
const storedIsCurrentGeneration = member.anchorGeneration === ANCHOR_GENERATION;
|
|
719
|
-
const restoredContentHash =
|
|
720
|
-
storedIsCurrentGeneration && member.lifecycle !== "add"
|
|
721
|
-
? (member.snapshotHash ?? snapshotHashFor(content))
|
|
722
|
-
: snapshotHashFor(content);
|
|
723
|
-
const restoredLineCount = visLines(content).length;
|
|
724
|
-
let restoredHashes: string[] = [];
|
|
725
|
-
if (!isAdd) {
|
|
726
|
-
try {
|
|
727
|
-
restoredHashes = await restoreHashes(content, restorePath);
|
|
728
|
-
} catch (error) {
|
|
729
|
-
reportDiagnostic("edit-undo", "Failed to derive lifecycle restore anchors:", error);
|
|
730
|
-
restoredHashes = [];
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
const currentHashes = isAdd
|
|
734
|
-
? await restoreHashes(member.resultContent, committedPath)
|
|
735
|
-
: isDelete
|
|
736
|
-
? []
|
|
737
|
-
: await restoreHashes(member.resultContent, restorePath);
|
|
738
|
-
const undoDenseRows = denseServeRows(restoredHashes);
|
|
739
|
-
const curSet = new Set(currentHashes);
|
|
740
|
-
const restoredSet = new Set(restoredHashes);
|
|
741
|
-
const displacedAnchors = [...curSet].filter((hash) => !restoredSet.has(hash));
|
|
742
|
-
const undoDiff = isAdd
|
|
743
|
-
? genDiff(currentNormalized, "", 1, [], currentHashes).diff
|
|
744
|
-
: isDelete
|
|
745
|
-
? genDiff("", content, 1, restoredHashes, []).diff
|
|
746
|
-
: genDiff(currentNormalized, content, 1, restoredHashes, currentHashes).diff;
|
|
747
|
-
const restoreBytes = isAdd
|
|
748
|
-
? null
|
|
749
|
-
: member.rawPre !== null
|
|
750
|
-
? Buffer.from(member.rawPre, "utf-8")
|
|
751
|
-
: Buffer.from(member.bom + restoreEndings(content, member.ending), "utf-8");
|
|
752
|
-
plans.push({
|
|
753
|
-
member,
|
|
754
|
-
restorePath,
|
|
755
|
-
restoreBytes,
|
|
756
|
-
removeOnly: isAdd,
|
|
757
|
-
moveFrom: isMove ? restorePath : null,
|
|
758
|
-
moveTo: isMove ? committedPath : null,
|
|
759
|
-
dirs: member.createdDirs,
|
|
760
|
-
content,
|
|
761
|
-
currentNormalized,
|
|
762
|
-
diff: undoDiff,
|
|
763
|
-
servedRows: undoDenseRows,
|
|
764
|
-
restoredContentHash,
|
|
765
|
-
restoredLineCount,
|
|
766
|
-
restoredHashes,
|
|
767
|
-
displacedAnchors,
|
|
768
|
-
});
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
// WHY: STEP 3 — durable revert intent BEFORE the first write.
|
|
772
|
-
await saveCutIntent(txnId, requestedAbsolutePath, "revert");
|
|
773
|
-
const undoCtx = ctx as { onBeforeUndoWrites?: () => void | Promise<void> };
|
|
774
|
-
if (undoCtx?.onBeforeUndoWrites) await undoCtx.onBeforeUndoWrites();
|
|
775
|
-
|
|
776
|
-
// WHY: STEP 4 — apply the restore steps; a failed step first COMPLETES the remaining members.
|
|
777
|
-
const applyRestore = async (plan: RestorePlan): Promise<void> => {
|
|
778
|
-
if (plan.removeOnly) {
|
|
779
|
-
if (await fileExists(plan.restorePath)) await removeFile(plan.restorePath);
|
|
780
|
-
await pruneEmptyDirs(plan.dirs);
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
if (plan.moveFrom !== null && plan.moveTo !== null) {
|
|
784
|
-
if (await fileExists(plan.moveTo)) await renameFile(plan.moveTo, plan.moveFrom);
|
|
785
|
-
}
|
|
786
|
-
if (plan.restoreBytes !== null) await writeAtomic(plan.restorePath, plan.restoreBytes);
|
|
787
|
-
};
|
|
788
|
-
const reverted = new Set<string>();
|
|
789
|
-
let defeated: UndoMember | undefined;
|
|
790
|
-
for (const plan of plans) {
|
|
791
|
-
try {
|
|
792
|
-
await applyRestore(plan);
|
|
793
|
-
reverted.add(plan.member.absolutePath);
|
|
794
|
-
} catch (error) {
|
|
795
|
-
reportDiagnostic(
|
|
796
|
-
"edit-undo",
|
|
797
|
-
"Lifecycle undo write failed; completing the revert:",
|
|
798
|
-
error,
|
|
799
|
-
);
|
|
800
|
-
for (const rest of plans) {
|
|
801
|
-
if (reverted.has(rest.member.absolutePath)) continue;
|
|
802
|
-
try {
|
|
803
|
-
await applyRestore(rest);
|
|
804
|
-
reverted.add(rest.member.absolutePath);
|
|
805
|
-
} catch (completionError) {
|
|
806
|
-
reportDiagnostic(
|
|
807
|
-
"edit-undo",
|
|
808
|
-
"Failed to complete the interrupted lifecycle revert:",
|
|
809
|
-
completionError,
|
|
810
|
-
);
|
|
811
|
-
defeated ??= rest.member;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
break;
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
if (defeated !== undefined) {
|
|
818
|
-
return {
|
|
819
|
-
content: [
|
|
820
|
-
{
|
|
821
|
-
type: "text" as const,
|
|
822
|
-
text: new DomainError("E_UNDO_REVERT_FAILED", { path: defeated.displayPath }).message,
|
|
823
|
-
},
|
|
824
|
-
],
|
|
825
|
-
isError: true,
|
|
826
|
-
details: {},
|
|
827
|
-
};
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// WHY: STEP 5 — store commit for every restored file (a removed add owns no snapshot).
|
|
831
|
-
const deferredSyncWarnings: string[] = [];
|
|
832
|
-
const servedRowsAll: ServedRow[] = [];
|
|
833
|
-
const sections: { plan: RestorePlan }[] = [];
|
|
834
|
-
for (const plan of plans) {
|
|
835
|
-
if (plan.removeOnly) {
|
|
836
|
-
sections.push({ plan });
|
|
837
|
-
continue;
|
|
838
|
-
}
|
|
839
|
-
try {
|
|
840
|
-
await adoptPinnedSnapshotFor(
|
|
841
|
-
{
|
|
842
|
-
path: plan.restorePath,
|
|
843
|
-
snapshotHash: plan.restoredContentHash,
|
|
844
|
-
lineCount: plan.restoredLineCount,
|
|
845
|
-
hashes: plan.restoredHashes,
|
|
846
|
-
content: plan.content,
|
|
847
|
-
},
|
|
848
|
-
{
|
|
849
|
-
retireLeases: true,
|
|
850
|
-
leases: { sessionKey: sessionKeyForUndo, rows: plan.servedRows },
|
|
851
|
-
servedMirror: {
|
|
852
|
-
sessionKey: sessionKeyForUndo,
|
|
853
|
-
rows: plan.servedRows,
|
|
854
|
-
shape: { lineCount: plan.restoredLineCount, clearFrom: 0 },
|
|
855
|
-
retireAnchors: plan.displacedAnchors,
|
|
856
|
-
},
|
|
857
|
-
},
|
|
858
|
-
);
|
|
859
|
-
} catch (error) {
|
|
860
|
-
reportDiagnostic("edit-undo", "Failed to commit the lifecycle undo restore:", error);
|
|
861
|
-
deferredSyncWarnings.push(DEFERRED_STORE_SYNC_WARNING);
|
|
862
|
-
}
|
|
863
|
-
servedRowsAll.push(...plan.servedRows);
|
|
864
|
-
sections.push({ plan });
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
await clearUndoTransaction(txnId);
|
|
868
|
-
try {
|
|
869
|
-
await dropCutIntent(txnId);
|
|
870
|
-
} catch (error) {
|
|
871
|
-
reportDiagnostic("edit-undo", "Failed to clear the revert intent after commit:", error);
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
const parts: string[] = ["Undone last edit on " + names + "."];
|
|
875
|
-
for (const { plan } of sections) {
|
|
876
|
-
switch (plan.member.lifecycle) {
|
|
877
|
-
case "add":
|
|
878
|
-
parts.push("Removed the created file " + plan.member.displayPath + ".");
|
|
879
|
-
break;
|
|
880
|
-
case "delete":
|
|
881
|
-
parts.push("Restored the deleted file " + plan.member.displayPath + ".");
|
|
882
|
-
break;
|
|
883
|
-
case "move":
|
|
884
|
-
parts.push(
|
|
885
|
-
"Moved " +
|
|
886
|
-
(plan.member.toPath ?? plan.member.absolutePath) +
|
|
887
|
-
" back to " +
|
|
888
|
-
plan.restorePath +
|
|
889
|
-
".",
|
|
890
|
-
);
|
|
891
|
-
break;
|
|
892
|
-
default:
|
|
893
|
-
break;
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
parts.push(
|
|
897
|
-
deferredSyncWarnings.length > 0
|
|
898
|
-
? "Files reverted; store synchronization is deferred (see the warning below)."
|
|
899
|
-
: "Files reverted; diff rows carry fresh anchors for follow-up edits.",
|
|
900
|
-
);
|
|
901
|
-
parts.push(...deferredSyncWarnings);
|
|
902
|
-
|
|
903
|
-
const totalAdded = plans.reduce(
|
|
904
|
-
(total, plan) =>
|
|
905
|
-
total +
|
|
906
|
-
Math.max(0, visLines(plan.content).length - visLines(plan.currentNormalized).length),
|
|
907
|
-
0,
|
|
908
|
-
);
|
|
909
|
-
const totalRemoved = plans.reduce(
|
|
910
|
-
(total, plan) =>
|
|
911
|
-
total +
|
|
912
|
-
Math.max(0, visLines(plan.currentNormalized).length - visLines(plan.content).length),
|
|
913
|
-
0,
|
|
914
|
-
);
|
|
915
|
-
const details: EditDetails = {
|
|
916
|
-
diff: sections
|
|
917
|
-
.map(({ plan }) => "--- " + plan.restorePath + " ---\n" + plan.diff)
|
|
918
|
-
.join("\n"),
|
|
919
|
-
resultLineCount: visLines(plans[0]!.content).length,
|
|
920
|
-
servedRows: servedRowsAll,
|
|
921
|
-
contentHash: plans[0]!.restoredContentHash,
|
|
922
|
-
...(deferredSyncWarnings.length > 0 ? { warnings: deferredSyncWarnings } : {}),
|
|
923
|
-
metrics: buildMetrics({
|
|
924
|
-
classification: "applied",
|
|
925
|
-
editsAttempted: plans.length,
|
|
926
|
-
noopEditsCount: 0,
|
|
927
|
-
warningsCount: deferredSyncWarnings.length,
|
|
928
|
-
addedLines: totalAdded,
|
|
929
|
-
removedLines: totalRemoved,
|
|
930
|
-
}),
|
|
931
|
-
};
|
|
932
|
-
return {
|
|
933
|
-
content: [{ type: "text" as const, text: parts.join("\n") }],
|
|
934
|
-
details,
|
|
935
|
-
};
|
|
936
|
-
},
|
|
937
|
-
);
|
|
938
|
-
}
|
|
939
|
-
|
|
940
566
|
/**
|
|
941
567
|
* Execution facts one host supplies for the undo use case: the directory relative `path` resolves
|
|
942
568
|
* against, and the session whose served anchors the restore re-leases.
|
|
@@ -1258,3 +884,23 @@ export async function executeUndoLastEdit(
|
|
|
1258
884
|
throw withPayloadSubject(error, "undo_last_edit");
|
|
1259
885
|
}
|
|
1260
886
|
}
|
|
887
|
+
|
|
888
|
+
export function regEditUndo(pi: ExtensionAPI): void {
|
|
889
|
+
pi.registerTool({
|
|
890
|
+
name: "undo_last_edit",
|
|
891
|
+
label: "Undo Last Edit",
|
|
892
|
+
description: loadP("../prompts/undo-last-edit.md"),
|
|
893
|
+
promptSnippet: loadP("../prompts/undo-last-edit-snippet.md"),
|
|
894
|
+
promptGuidelines: loadGuide("../prompts/undo-last-edit-guidelines.md"),
|
|
895
|
+
parameters: Type.Object({
|
|
896
|
+
path: Type.String({
|
|
897
|
+
description: "Path to the file to undo",
|
|
898
|
+
}),
|
|
899
|
+
}),
|
|
900
|
+
|
|
901
|
+
// WHY: the registration owns only the pi wire shape; the use case lives in the host-agnostic seam.
|
|
902
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
903
|
+
return executeUndoLastEdit(params, ctx as unknown as UndoLastEditContext);
|
|
904
|
+
},
|
|
905
|
+
});
|
|
906
|
+
}
|
package/src/edit.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** SAFETY: Edit tool — thin adapter over deep EditTool + TuiPresenter.
|
|
2
|
+
*
|
|
3
|
+
* Clean Architecture: this module is the Interface Adapter seam.
|
|
4
|
+
* It owns the pi ToolDefinition wiring only; Use Case lives in EditTool,
|
|
5
|
+
* Framework (pi TUI) lives in TuiPresenter.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { TSchema } from "typebox";
|
|
10
|
+
import {
|
|
11
|
+
prepareEditArguments,
|
|
12
|
+
EDIT_DESCRIPTION,
|
|
13
|
+
type NormalizedEditRequest,
|
|
14
|
+
type EditItem,
|
|
15
|
+
editToolSchema,
|
|
16
|
+
editItemSchema,
|
|
17
|
+
anchorFromSchema,
|
|
18
|
+
anchorToSchema,
|
|
19
|
+
editFileSchema,
|
|
20
|
+
editModeSchema,
|
|
21
|
+
assertReq,
|
|
22
|
+
} from "./payload-contract.js";
|
|
23
|
+
import { withPayloadSubject } from "./domain-errors.js";
|
|
24
|
+
import { attributePayloadSubject } from "./error-envelope.js";
|
|
25
|
+
import { createEditTool, type PreviewContext } from "./edit-tool.js";
|
|
26
|
+
import { createTuiPresenter } from "./tui-presenter.js";
|
|
27
|
+
import { loadP, loadGuide } from "./prompts.js";
|
|
28
|
+
import type { EditDetails } from "./edit-response.js";
|
|
29
|
+
import type { RPreview, RRState } from "./edit-render.js";
|
|
30
|
+
|
|
31
|
+
void EDIT_DESCRIPTION;
|
|
32
|
+
/** SAFETY: @deprecated Import from "./payload-contract.js" — single source per ADR-0007 */
|
|
33
|
+
export { assertReq };
|
|
34
|
+
/** SAFETY: @deprecated Import from "./payload-contract.js" — single source per ADR-0007. Re-exports retained for compatibility until next MAJOR. `replaceWithSchema` is REMOVED: the ticket-04 wire has no `replace_with` fold. */
|
|
35
|
+
export {
|
|
36
|
+
editToolSchema,
|
|
37
|
+
editItemSchema,
|
|
38
|
+
anchorFromSchema,
|
|
39
|
+
anchorToSchema,
|
|
40
|
+
editFileSchema,
|
|
41
|
+
editModeSchema,
|
|
42
|
+
};
|
|
43
|
+
export { reuseText, reuseMarkdown } from "./tui-presenter.js";
|
|
44
|
+
|
|
45
|
+
/** The wire item (ticket-04): exactly one payload — `text` or `text_ref` — plus optional `at`. */
|
|
46
|
+
export type EditParams = EditItem;
|
|
47
|
+
|
|
48
|
+
export type EditRequest = NormalizedEditRequest;
|
|
49
|
+
|
|
50
|
+
export async function compPreview(
|
|
51
|
+
request: unknown,
|
|
52
|
+
cwd: string,
|
|
53
|
+
ctx: PreviewContext,
|
|
54
|
+
): Promise<RPreview> {
|
|
55
|
+
const tool = createEditTool();
|
|
56
|
+
return tool.preview(request, cwd, ctx);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type ToolDef = ToolDefinition<TSchema, EditDetails, RRState> & {
|
|
60
|
+
renderShell?: "default" | "self";
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function buildToolDef(
|
|
64
|
+
getSessionManager?: () => { getSessionId(): string } | undefined,
|
|
65
|
+
): ToolDef {
|
|
66
|
+
const E_DESC = loadP("../prompts/edit.md");
|
|
67
|
+
const E_SNIPPET = loadP("../prompts/edit-snippet.md");
|
|
68
|
+
const E_GUIDE = loadGuide("../prompts/edit-guidelines.md");
|
|
69
|
+
const parameters = editToolSchema;
|
|
70
|
+
const tool = createEditTool();
|
|
71
|
+
const presenter = createTuiPresenter(
|
|
72
|
+
// SAFETY: fire-time read is paired with the arm-time getSessionId below; DebouncedPreview
|
|
73
|
+
// SAFETY: drops the compute if the serving session changed between arming and firing.
|
|
74
|
+
(req, cwd) => {
|
|
75
|
+
// SAFETY: the fire-time read stays inside the call so the ctx names the session serving anchors now.
|
|
76
|
+
const sessionManager = getSessionManager?.();
|
|
77
|
+
return tool.preview(req, cwd, sessionManager === undefined ? {} : { sessionManager });
|
|
78
|
+
},
|
|
79
|
+
() => getSessionManager?.()?.getSessionId(),
|
|
80
|
+
);
|
|
81
|
+
return {
|
|
82
|
+
name: "edit",
|
|
83
|
+
label: "Edit",
|
|
84
|
+
description: E_DESC,
|
|
85
|
+
parameters,
|
|
86
|
+
promptSnippet: E_SNIPPET,
|
|
87
|
+
promptGuidelines: E_GUIDE,
|
|
88
|
+
// WHY: an admission refusal raised here carries no subject, so it must name the edit tool.
|
|
89
|
+
// WHY: Without the stamp the model reads the neutral wording — the analyzer names no caller.
|
|
90
|
+
prepareArguments: (args: unknown) => {
|
|
91
|
+
try {
|
|
92
|
+
return prepareEditArguments(args);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw withPayloadSubject(error, "edit");
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
renderShell: "default",
|
|
98
|
+
// SAFETY: presenter owns TUI casts — asToolDef returns ToolDefinition-typed renders, edit.ts has zero direct TUI casts
|
|
99
|
+
...presenter.asToolDef(),
|
|
100
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
101
|
+
// SAFETY: pi execute boundary is untyped — ctx narrowed via sessionKeyFor, signal is AbortSignal validated by engine
|
|
102
|
+
try {
|
|
103
|
+
const res = await tool.execute(
|
|
104
|
+
params,
|
|
105
|
+
signal as AbortSignal | undefined,
|
|
106
|
+
// SAFETY: ctx is untyped at the pi boundary — cast validated by pi's runtime shape
|
|
107
|
+
ctx as unknown as {
|
|
108
|
+
cwd: string;
|
|
109
|
+
sessionManager?: { getSessionId(): string };
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
// SAFETY: res is validated tool result after tool.execute — cast to pi ToolDef return type for registration
|
|
113
|
+
return res as unknown as ReturnType<ToolDef["execute"]> extends Promise<infer R>
|
|
114
|
+
? R
|
|
115
|
+
: never;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw attributePayloadSubject(error, "edit");
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function regEdit(pi: ExtensionAPI): void {
|
|
124
|
+
// WHY: (#165) pi's ToolRenderContext carries no session, so the preview pane's session is
|
|
125
|
+
// WHY: captured from the session_start ctx — the same session reads serve anchors to.
|
|
126
|
+
// WHY: session_start fires for startup/reload/new/resume/fork, so the capture tracks switches.
|
|
127
|
+
// WHY: (#168-class) tracking a switch is only safe if the *pending* compute re-checks it:
|
|
128
|
+
// WHY: buildToolDef below exposes getSessionId so DebouncedPreview binds the serving session
|
|
129
|
+
// WHY: at arm time and drops (cancels) a compute whose session changed before the timer fires,
|
|
130
|
+
// WHY: instead of verifying anchors against a session that never served them.
|
|
131
|
+
let sessionManager: { getSessionId(): string } | undefined;
|
|
132
|
+
pi.on("session_start", (_event, ctx) => {
|
|
133
|
+
sessionManager = ctx.sessionManager;
|
|
134
|
+
});
|
|
135
|
+
pi.registerTool(buildToolDef(() => sessionManager));
|
|
136
|
+
}
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
} from "./preview.js";
|
|
33
33
|
import { findStatSnapshotFor } from "../snapshot-store";
|
|
34
34
|
import type { ServedRow } from "../served-session/serve-render.js";
|
|
35
|
-
import type { TruncationResult } from "
|
|
35
|
+
import type { TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
36
36
|
|
|
37
37
|
export type { FileStats, LFile, LoadFileOptions } from "./detection.js";
|
|
38
38
|
export { decodeUtf8Strict, loadFileKindAndText } from "./detection.js";
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
DEFAULT_MAX_LINES,
|
|
5
5
|
DEFAULT_MAX_BYTES,
|
|
6
6
|
type TruncationResult,
|
|
7
|
-
} from "
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { MAX_READ_WINDOWS, MAX_READ_WINDOWS_MESSAGE } from "../constants.js";
|
|
9
9
|
import { DomainError } from "../domain-errors.js";
|
|
10
10
|
import {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* WHY: one result. Both builders live here, so a header's shape has ONE definition site and the footer
|
|
6
6
|
* WHY: they replaced cannot come back from a call site.
|
|
7
7
|
*/
|
|
8
|
-
import { formatSize } from "
|
|
8
|
+
import { formatSize } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
|
|
10
10
|
/** The lines a page rendered, 1-based and inclusive. */
|
|
11
11
|
interface RenderedRange {
|
package/src/hash-store.ts
CHANGED
|
@@ -156,16 +156,7 @@ function openDb(storePath: string, budget?: BusyBudget): OpenedDb {
|
|
|
156
156
|
const MIGRATION_COLUMNS = {
|
|
157
157
|
cut_intent: { direction: "TEXT" },
|
|
158
158
|
file_snapshots: { stat_key: "TEXT" },
|
|
159
|
-
file_undo: {
|
|
160
|
-
snapshot_hash: "TEXT",
|
|
161
|
-
transaction_id: "TEXT",
|
|
162
|
-
raw_pre: "TEXT",
|
|
163
|
-
lifecycle: "TEXT",
|
|
164
|
-
from_path: "TEXT",
|
|
165
|
-
to_path: "TEXT",
|
|
166
|
-
raw_post: "TEXT",
|
|
167
|
-
created_dirs: "TEXT",
|
|
168
|
-
},
|
|
159
|
+
file_undo: { snapshot_hash: "TEXT", transaction_id: "TEXT", raw_pre: "TEXT" },
|
|
169
160
|
served: { retired: "TEXT", canons: "TEXT", snapshotId: "TEXT" },
|
|
170
161
|
undo: { result_content: "TEXT NOT NULL DEFAULT ''" },
|
|
171
162
|
} as const;
|
|
@@ -634,14 +625,6 @@ export function ensureFileUndoSchema(db: DatabaseSync): void {
|
|
|
634
625
|
addColumnIfMissing(db, "file_undo", "snapshot_hash");
|
|
635
626
|
addColumnIfMissing(db, "file_undo", "transaction_id");
|
|
636
627
|
addColumnIfMissing(db, "file_undo", "raw_pre");
|
|
637
|
-
// WHY: apply_patch lifecycle rows are still edit-shaped rows: one file_undo row per member,
|
|
638
|
-
// WHY: one shared transaction id. These columns only widen what a row can describe — an
|
|
639
|
-
// WHY: existing row (all NULL) stays an ordinary edit, so the addition is back-compatible.
|
|
640
|
-
addColumnIfMissing(db, "file_undo", "lifecycle");
|
|
641
|
-
addColumnIfMissing(db, "file_undo", "from_path");
|
|
642
|
-
addColumnIfMissing(db, "file_undo", "to_path");
|
|
643
|
-
addColumnIfMissing(db, "file_undo", "raw_post");
|
|
644
|
-
addColumnIfMissing(db, "file_undo", "created_dirs");
|
|
645
628
|
// WHY: versions the persisted anchor set — existing rows keep 0 (never current),
|
|
646
629
|
// WHY: so a pre-bump store is refused, not migrated, on first open after upgrade.
|
|
647
630
|
// WHY: `canon_version` (pre-rename builds) is renamed in place, preserving stamped values.
|