pi-posthorse 0.10.3 → 0.10.4

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.
@@ -0,0 +1,278 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ withFileMutationQueue,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import type { EntryLike, ProjectedEntry } from "./contracts.ts";
9
+ import { notesDir } from "./files.ts";
10
+ import { adoptNote, ensureManifest, readManifest, saveAdoption } from "./note-snapshots.ts";
11
+ import {
12
+ loadNotesBinding,
13
+ resolveReference,
14
+ sourceEntries,
15
+ type NoteSource,
16
+ } from "./note-provenance.ts";
17
+ import {
18
+ NOTES_BINDING,
19
+ NOTES_TOUCH,
20
+ bindingsOf,
21
+ canonicalPath,
22
+ inside,
23
+ notePath,
24
+ noteRelative,
25
+ ownedPaths,
26
+ sameOwner,
27
+ sameScope,
28
+ scopeDirectory,
29
+ type NoteAddress,
30
+ type NoteBinding,
31
+ type NoteScope,
32
+ type NoteTouchRecord,
33
+ } from "./note-scopes.ts";
34
+ import type { NotesRecovery } from "./note-evidence.ts";
35
+
36
+ type NotesManager = ExtensionContext["sessionManager"];
37
+ export type NoteCall = { readonly id: string; readonly rootId: string };
38
+ export type NoteRead = {
39
+ readonly address: NoteAddress;
40
+ readonly path: string;
41
+ readonly writable: boolean;
42
+ };
43
+ export class SessionNotes {
44
+ readonly #pi: ExtensionAPI;
45
+ readonly binding: NoteBinding;
46
+ readonly #manager: NotesManager;
47
+ readonly #source: NoteSource;
48
+ readonly #owned: Set<string>;
49
+ readonly scopes: readonly NoteScope[];
50
+ readonly #calls = new Map<string, string>();
51
+ readonly #revisions = new Map<string, number>();
52
+ constructor(pi: ExtensionAPI, binding: NoteBinding, manager: NotesManager, source: NoteSource) {
53
+ this.#pi = pi;
54
+ this.binding = binding;
55
+ this.#manager = manager;
56
+ this.#source = source;
57
+ this.#owned = ownedPaths(binding, manager.getEntries());
58
+ this.scopes = [
59
+ binding,
60
+ { root: binding.root, scope: "" },
61
+ ...(binding.kind === "fork" && binding.source !== null ? [binding.source] : []),
62
+ ...bindingsOf([...manager.getEntries(), ...source.entries]),
63
+ ];
64
+ }
65
+ get directory(): string {
66
+ return scopeDirectory(this.binding);
67
+ }
68
+ get branch(): readonly EntryLike[] {
69
+ return this.#manager.getBranch();
70
+ }
71
+ get projection(): readonly ProjectedEntry[] {
72
+ return this.#manager.buildSessionProjection().entries;
73
+ }
74
+ revision(path: string): number {
75
+ return this.#revisions.get(canonicalPath(path)) ?? 0;
76
+ }
77
+ get recovery(): NotesRecovery {
78
+ return { binding: this.binding, entries: this.#manager.getEntries() };
79
+ }
80
+ startCall(id: string, parent: string | undefined): NoteCall {
81
+ const rootId = parent === undefined ? id : (this.#calls.get(parent) ?? parent);
82
+ this.#calls.set(id, rootId);
83
+ return { id, rootId };
84
+ }
85
+ finishCall(id: string): void {
86
+ this.#calls.delete(id);
87
+ }
88
+ call(id: string): NoteCall {
89
+ return { id, rootId: this.#calls.get(id) ?? id };
90
+ }
91
+ owns(path: string): boolean {
92
+ return (
93
+ this.binding.kind === "project" ||
94
+ this.#owned.has(path) ||
95
+ existsSync(notePath(this.binding, path))
96
+ );
97
+ }
98
+ record(files: NoteTouchRecord["files"], call: NoteCall): void {
99
+ if (files.length === 0) {
100
+ return;
101
+ }
102
+ for (const file of files) {
103
+ if (file.kind !== "read" && sameScope(file, this.binding)) {
104
+ this.#owned.add(file.path);
105
+ const path = canonicalPath(notePath(file, file.path));
106
+ this.#revisions.set(path, this.revision(path) + 1);
107
+ }
108
+ }
109
+ this.#pi.appendEntry(NOTES_TOUCH, {
110
+ version: 1,
111
+ owner: this.binding.owner,
112
+ scope: { root: this.binding.root, scope: this.binding.scope },
113
+ rootToolCallId: call.rootId,
114
+ toolCallId: call.id,
115
+ files,
116
+ } satisfies NoteTouchRecord);
117
+ }
118
+ async selectScope(selector: string): Promise<NoteScope> {
119
+ const directory = canonicalPath(selector);
120
+ const known = this.scopes.find(
121
+ (scope) => canonicalPath(scopeDirectory(scope)) === directory,
122
+ );
123
+ if (known !== undefined) {
124
+ return known;
125
+ }
126
+ const manifest = await readManifest(directory);
127
+ if (
128
+ manifest !== undefined &&
129
+ manifest.root === this.binding.root &&
130
+ inside(join(this.binding.root, ".forks"), directory)
131
+ ) {
132
+ return manifest;
133
+ }
134
+ throw new Error(
135
+ `Unknown notes scope ${selector}. Copy a scope selector from notes list/search.`,
136
+ );
137
+ }
138
+ async #reference(path: string, signal?: AbortSignal): Promise<NoteAddress | undefined> {
139
+ if (this.binding.kind !== "fork" || this.binding.source === null) {
140
+ return;
141
+ }
142
+ const entries = [
143
+ ...this.#manager.getEntries(),
144
+ ...this.#source.entries,
145
+ ...(await sourceEntries(this.#source.file, signal)),
146
+ ];
147
+ return resolveReference(this.binding.source, path, entries);
148
+ }
149
+ async read(
150
+ path: string,
151
+ options: {
152
+ readonly scope?: string | undefined;
153
+ readonly signal?: AbortSignal | undefined;
154
+ } = {},
155
+ ): Promise<NoteRead> {
156
+ const relative = noteRelative(path);
157
+ if (options.scope !== undefined) {
158
+ const scope = await this.selectScope(options.scope);
159
+ return {
160
+ address: { ...scope, path: relative },
161
+ path: notePath(scope, relative),
162
+ writable: sameScope(scope, this.binding),
163
+ };
164
+ }
165
+ const reference = this.owns(relative)
166
+ ? undefined
167
+ : await this.#reference(relative, options.signal);
168
+ const address = reference ?? {
169
+ root: this.binding.root,
170
+ scope: this.binding.scope,
171
+ path: relative,
172
+ };
173
+ return { address, path: notePath(address, relative), writable: reference === undefined };
174
+ }
175
+ async adopt(path: string, call: NoteCall): Promise<boolean> {
176
+ const relative = noteRelative(path);
177
+ if (this.owns(relative)) {
178
+ return false;
179
+ }
180
+ const source = await this.#reference(relative);
181
+ if (source === undefined) {
182
+ return false;
183
+ }
184
+ const seed = await adoptNote(this.binding, {
185
+ path: relative,
186
+ source,
187
+ lineage: source,
188
+ anchors: [],
189
+ });
190
+ this.record(
191
+ [
192
+ {
193
+ root: this.binding.root,
194
+ scope: this.binding.scope,
195
+ path: relative,
196
+ kind: "adopt",
197
+ seed,
198
+ },
199
+ ],
200
+ call,
201
+ );
202
+ try {
203
+ await saveAdoption(this.binding, seed);
204
+ } catch (error) {
205
+ throw new Error(
206
+ `Private copy created at ${notePath(this.binding, relative)}; source not edited, but its adoption manifest could not be saved. Read the private copy before retrying.`,
207
+ { cause: error },
208
+ );
209
+ }
210
+ return true;
211
+ }
212
+ async prepareWrite(): Promise<void> {
213
+ await ensureManifest(this.binding);
214
+ }
215
+ async guardReference(path: string, call: NoteCall): Promise<string> {
216
+ const destination = notePath(this.binding, path);
217
+ const copied = await withFileMutationQueue(destination, () => this.adopt(path, call));
218
+ return copied
219
+ ? `Private copy created at ${destination}; the source was not edited. Read the private copy, then retry the edit on that path.`
220
+ : `This session writes only ${this.directory}. Read ${destination}, then retry there; the foreign path was not edited.`;
221
+ }
222
+ }
223
+ function sessionKey(ctx: ExtensionContext): string {
224
+ const header = ctx.sessionManager.getHeader();
225
+ if (header === null) {
226
+ throw new Error("Pi session header is required for notes.");
227
+ }
228
+ return `${header.id}\0${header.timestamp}`;
229
+ }
230
+ export type NotesStore = {
231
+ readonly get: (ctx: ExtensionContext) => Promise<SessionNotes>;
232
+ readonly roots: () => readonly string[];
233
+ readonly recovery: (ctx: ExtensionContext) => NotesRecovery | undefined;
234
+ };
235
+ export function createNotesStore(pi: ExtensionAPI): NotesStore {
236
+ const sessions = new Map<string, Promise<SessionNotes>>();
237
+ const ready = new Map<string, SessionNotes>();
238
+ const roots = new Set<string>();
239
+ const get = (ctx: ExtensionContext): Promise<SessionNotes> => {
240
+ const key = sessionKey(ctx);
241
+ const existing = sessions.get(key);
242
+ if (existing !== undefined) {
243
+ return existing;
244
+ }
245
+ const bindings = bindingsOf(ctx.sessionManager.getEntries());
246
+ const header = ctx.sessionManager.getHeader();
247
+ const root =
248
+ bindings.findLast((binding) => header !== null && sameOwner(binding.owner, header))
249
+ ?.root ?? canonicalPath(notesDir(ctx.cwd));
250
+ roots.add(root);
251
+ for (const binding of bindings) {
252
+ roots.add(binding.root);
253
+ }
254
+ const initialized = loadNotesBinding(ctx.sessionManager, root)
255
+ .then(({ binding, source, created }) => {
256
+ if (created) {
257
+ pi.appendEntry(NOTES_BINDING, binding);
258
+ }
259
+ const session = new SessionNotes(pi, binding, ctx.sessionManager, source);
260
+ for (const scope of session.scopes) {
261
+ roots.add(scope.root);
262
+ }
263
+ ready.set(key, session);
264
+ return session;
265
+ })
266
+ .catch((error: unknown) => {
267
+ sessions.delete(key);
268
+ throw error;
269
+ });
270
+ sessions.set(key, initialized);
271
+ return initialized;
272
+ };
273
+ return {
274
+ get,
275
+ roots: () => [...roots],
276
+ recovery: (ctx: ExtensionContext) => ready.get(sessionKey(ctx))?.recovery,
277
+ };
278
+ }
@@ -0,0 +1,190 @@
1
+ import { isRecord, optionalString, unknownArray } from "./contracts.ts";
2
+
3
+ export type NoteAction =
4
+ | "read"
5
+ | "write"
6
+ | "clear"
7
+ | "append"
8
+ | "edit"
9
+ | "create"
10
+ | "create-empty"
11
+ | "delete";
12
+ export type NoteOperation = {
13
+ readonly path: string;
14
+ readonly action: NoteAction;
15
+ readonly scope?: string | undefined;
16
+ };
17
+ export function bareToolName(name: string | undefined): string {
18
+ const cut = Math.max(name?.lastIndexOf(":") ?? -1, name?.lastIndexOf("/") ?? -1);
19
+ return name?.slice(cut + 1) ?? "";
20
+ }
21
+ function patchOperations(input: unknown): NoteOperation[] {
22
+ if (typeof input !== "string") {
23
+ return [];
24
+ }
25
+ const operations: NoteOperation[] = [];
26
+ for (const match of input.matchAll(
27
+ /^\*\*\* (Update File|Add File|Delete File|Move to): ([^\r\n]+)$/gmu,
28
+ )) {
29
+ const path = match[2];
30
+ if (path === undefined) {
31
+ continue;
32
+ }
33
+ if (match[1] === "Move to") {
34
+ const previous = operations.pop();
35
+ if (previous !== undefined) {
36
+ operations.push({ ...previous, action: "delete" });
37
+ }
38
+ }
39
+ let action: NoteAction = "create";
40
+ if (match[1] === "Update File") {
41
+ action = "edit";
42
+ } else if (match[1] === "Delete File") {
43
+ action = "delete";
44
+ }
45
+ operations.push({ path, action });
46
+ }
47
+ return operations;
48
+ }
49
+ function batchOperations(tool: string, files: unknown): NoteOperation[] {
50
+ return (unknownArray(files) ?? []).flatMap<NoteOperation>((file) => {
51
+ if (!isRecord(file) || typeof file.path !== "string") {
52
+ return [];
53
+ }
54
+ if (tool === "replace_text") {
55
+ return [{ path: file.path, action: "edit" }];
56
+ }
57
+ if (file.mode === "create") {
58
+ return [{ path: file.path, action: file.content === "" ? "create-empty" : "create" }];
59
+ }
60
+ return [{ path: file.path, action: file.content === "" ? "clear" : "write" }];
61
+ });
62
+ }
63
+ function notesAction(args: Readonly<Record<string, unknown>>): NoteAction | undefined {
64
+ switch (args.op) {
65
+ case "write":
66
+ return args.content === "" ? "clear" : "write";
67
+ case "append":
68
+ case "read":
69
+ return args.op;
70
+ default:
71
+ return;
72
+ }
73
+ }
74
+ function singleOperation(tool: string, args: Readonly<Record<string, unknown>>): NoteOperation[] {
75
+ const path = optionalString(args.path);
76
+ if (path === undefined) {
77
+ return [];
78
+ }
79
+ switch (tool) {
80
+ case "notes": {
81
+ const action = notesAction(args);
82
+ return action === undefined
83
+ ? []
84
+ : [{ path, action, scope: optionalString(args.scope) }];
85
+ }
86
+ case "read":
87
+ case "edit":
88
+ return [{ path, action: tool }];
89
+ case "write":
90
+ return [{ path, action: args.content === "" ? "clear" : "write" }];
91
+ default:
92
+ return [];
93
+ }
94
+ }
95
+ export function noteOperations(tool: string, input: unknown): NoteOperation[] {
96
+ const args = isRecord(input) ? input : {};
97
+ if (args.preview === true || tool === "preview_patch") {
98
+ return [];
99
+ }
100
+ switch (tool) {
101
+ case "apply_patch":
102
+ return patchOperations(typeof input === "string" ? input : args.input);
103
+ case "write_files":
104
+ case "replace_text":
105
+ return batchOperations(tool, args.files);
106
+ default:
107
+ return singleOperation(tool, args);
108
+ }
109
+ }
110
+ export function noteOperationKind(
111
+ action: NoteAction,
112
+ ): "read" | "full-write" | "partial-write" | "clear" {
113
+ switch (action) {
114
+ case "read":
115
+ return "read";
116
+ case "write":
117
+ case "create":
118
+ return "full-write";
119
+ case "edit":
120
+ case "append":
121
+ return "partial-write";
122
+ case "clear":
123
+ case "create-empty":
124
+ case "delete":
125
+ return "clear";
126
+ }
127
+ }
128
+ function committedAction(
129
+ file: unknown,
130
+ operation: NoteOperation | undefined,
131
+ path: string,
132
+ ): NoteAction {
133
+ if (!isRecord(file)) {
134
+ return operation?.action ?? "edit";
135
+ }
136
+ // modifiedFiles is path-qualified; a partly committed move can still have status "failed".
137
+ if (file.operation === "delete" || (file.operation === "move" && file.path === path)) {
138
+ return "delete";
139
+ }
140
+ if (file.bytesAfter === 0) {
141
+ return "clear";
142
+ }
143
+ return operation?.action ?? "edit";
144
+ }
145
+ function committedOperations(
146
+ details: Readonly<Record<string, unknown>>,
147
+ operations: readonly NoteOperation[],
148
+ ): NoteOperation[] | undefined {
149
+ const modified = unknownArray(details.modifiedFiles);
150
+ if (modified === undefined) {
151
+ return;
152
+ }
153
+ const files = unknownArray(details.files) ?? [];
154
+ return modified.flatMap((path) => {
155
+ if (typeof path !== "string") {
156
+ return [];
157
+ }
158
+ const file = files.find(
159
+ (value) => isRecord(value) && (value.path === path || value.moveTo === path),
160
+ );
161
+ const action = committedAction(
162
+ file,
163
+ operations.find((operation) => operation.path === path),
164
+ path,
165
+ );
166
+ return [{ path, action }];
167
+ });
168
+ }
169
+ /** pi-apply-edits reports committed paths even when a later publication failed. */
170
+ export function verifiedNoteOperations(
171
+ tool: string,
172
+ input: unknown,
173
+ result: {
174
+ readonly isError: boolean;
175
+ readonly details: unknown;
176
+ },
177
+ ): NoteOperation[] {
178
+ const operations = noteOperations(tool, input);
179
+ const details = isRecord(result.details) ? result.details : undefined;
180
+ if (details?.preview === true) {
181
+ return [];
182
+ }
183
+ if (details !== undefined && ["replace_text", "write_files", "apply_patch"].includes(tool)) {
184
+ const committed = committedOperations(details, operations);
185
+ if (committed !== undefined) {
186
+ return committed;
187
+ }
188
+ }
189
+ return result.isError ? [] : operations;
190
+ }