pi-posthorse 0.10.2 → 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,291 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ import { isRecord } from "./contracts.ts";
4
+ import { sessionNoteTouches } from "./note-evidence.ts";
5
+ import { notesUnder, type NoteFile } from "./note-inventory.ts";
6
+ import { fileDigest, fileIdentity, readManifest, sameIdentity } from "./note-snapshots.ts";
7
+ import {
8
+ sameScope,
9
+ scopeDirectory,
10
+ type ForkBinding,
11
+ type NoteAddress,
12
+ type NoteIdentity,
13
+ type NoteScope,
14
+ type NoteSeed,
15
+ } from "./note-scopes.ts";
16
+ import type { SessionNotes } from "./note-store.ts";
17
+
18
+ export type DiscoveredNote = NoteFile & {
19
+ readonly address: NoteAddress;
20
+ readonly lineage: NoteAddress;
21
+ readonly seed: NoteSeed | undefined;
22
+ readonly copies: readonly NoteAddress[];
23
+ };
24
+ type ScopeInventory = { readonly scope: NoteScope; readonly manifest: ForkBinding | undefined };
25
+ type DiscoveryOptions = {
26
+ readonly path?: string | undefined;
27
+ readonly scope?: string | undefined;
28
+ readonly signal: AbortSignal | undefined;
29
+ };
30
+ type ComparisonOptions = DiscoveryOptions & { readonly notice: (message: string) => void };
31
+ type DigestCache = {
32
+ readonly identity: NoteIdentity;
33
+ readonly digest: string;
34
+ readonly revision: number;
35
+ };
36
+ async function projectScopes(
37
+ session: SessionNotes,
38
+ notice: (message: string) => void,
39
+ ): Promise<ScopeInventory[]> {
40
+ const scopes: ScopeInventory[] = [
41
+ { scope: { root: session.binding.root, scope: "" }, manifest: undefined },
42
+ ];
43
+ const directory = join(session.binding.root, ".forks");
44
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
45
+ if (isRecord(error) && error.code === "ENOENT") {
46
+ return [];
47
+ }
48
+ throw error;
49
+ });
50
+ for (const entry of entries) {
51
+ const path = join(directory, entry.name);
52
+ if (entry.name.startsWith(".staging-")) {
53
+ continue;
54
+ }
55
+ const manifest = entry.isDirectory() ? await readManifest(join(path, "notes")) : undefined;
56
+ if (manifest === undefined || manifest.root !== session.binding.root) {
57
+ notice(`Not indexed: unmanaged or incomplete notes data at ${path}.`);
58
+ } else {
59
+ scopes.push({ scope: manifest, manifest });
60
+ }
61
+ }
62
+ if (!scopes.some(({ scope }) => sameScope(scope, session.binding))) {
63
+ scopes.push({
64
+ scope: session.binding,
65
+ manifest: session.binding.kind === "fork" ? session.binding : undefined,
66
+ });
67
+ }
68
+ return scopes;
69
+ }
70
+ async function scopedFiles(
71
+ inventory: ScopeInventory,
72
+ options: DiscoveryOptions,
73
+ notice: (message: string) => void,
74
+ ): Promise<DiscoveredNote[]> {
75
+ const files = await notesUnder(inventory.scope, options.path, {
76
+ signal: options.signal,
77
+ notice,
78
+ });
79
+ const seeds = new Map(inventory.manifest?.seeds.map((seed) => [seed.path, seed]));
80
+ return files.map((file) => {
81
+ const path = relative(scopeDirectory(inventory.scope), file.path);
82
+ const address = { root: inventory.scope.root, scope: inventory.scope.scope, path };
83
+ const seed = seeds.get(path);
84
+ return Object.assign({}, file, {
85
+ address,
86
+ seed,
87
+ lineage: seed?.lineage ?? address,
88
+ copies: [],
89
+ });
90
+ });
91
+ }
92
+ function noteKey(address: NoteAddress): string {
93
+ return JSON.stringify([address.root, address.scope, address.path]);
94
+ }
95
+ export function noteReadTarget(note: DiscoveredNote): string {
96
+ return JSON.stringify({ scope: scopeDirectory(note.address), path: note.address.path });
97
+ }
98
+ export function noteLabel(note: DiscoveredNote, session: SessionNotes, labeled: boolean): string {
99
+ if (!labeled) {
100
+ return note.address.path;
101
+ }
102
+ const mode = sameScope(note.address, session.binding) ? "current" : "read-only";
103
+ const copies =
104
+ note.copies.length === 0
105
+ ? ""
106
+ : `; identical copies: ${note.copies.map((copy) => JSON.stringify({ scope: scopeDirectory(copy), path: copy.path })).join(", ")}`;
107
+ return `${note.address.path} [${mode}; read ${noteReadTarget(note)}${copies}]`;
108
+ }
109
+ async function readReference(
110
+ session: SessionNotes,
111
+ path: string,
112
+ signal: AbortSignal | undefined,
113
+ ): Promise<DiscoveredNote | undefined> {
114
+ const target = await session.read(path, { signal });
115
+ if (target.writable) {
116
+ return;
117
+ }
118
+ const info = await stat(target.path).catch((error: unknown) => {
119
+ if (isRecord(error) && error.code === "ENOENT") {
120
+ return;
121
+ }
122
+ throw error;
123
+ });
124
+ if (info?.isFile() !== true) {
125
+ return;
126
+ }
127
+ return {
128
+ ...fileIdentity(info),
129
+ path: target.path,
130
+ address: target.address,
131
+ lineage: target.address,
132
+ seed: undefined,
133
+ copies: [],
134
+ };
135
+ }
136
+ export class NoteDiscovery {
137
+ readonly #digests = new Map<string, DigestCache>();
138
+ #knownDigest(note: DiscoveredNote, revision: number): string | undefined {
139
+ const cached = this.#digests.get(note.path);
140
+ if (
141
+ cached !== undefined &&
142
+ cached.revision === revision &&
143
+ sameIdentity(cached.identity, note)
144
+ ) {
145
+ return cached.digest;
146
+ }
147
+ if (revision === 0 && note.seed !== undefined && sameIdentity(note.seed.identity, note)) {
148
+ return note.seed.digest;
149
+ }
150
+ return;
151
+ }
152
+ async #digest(
153
+ note: DiscoveredNote,
154
+ session: SessionNotes,
155
+ options: ComparisonOptions,
156
+ ): Promise<string | undefined> {
157
+ const { signal } = options;
158
+ const revision = session.revision(note.path);
159
+ const known = this.#knownDigest(note, revision);
160
+ if (known !== undefined) {
161
+ return known;
162
+ }
163
+ try {
164
+ const digest = await fileDigest(note.path, signal);
165
+ if (!sameIdentity(note, await stat(note.path))) {
166
+ return;
167
+ }
168
+ this.#digests.set(note.path, { identity: note, digest, revision });
169
+ return digest;
170
+ } catch (error) {
171
+ signal?.throwIfAborted();
172
+ options.notice(
173
+ `Duplicate comparison unavailable for ${noteReadTarget(note)}: ${error instanceof Error ? error.message : String(error)}. Copies remain separate.`,
174
+ );
175
+ return;
176
+ }
177
+ }
178
+ async #collapse(
179
+ files: readonly DiscoveredNote[],
180
+ session: SessionNotes,
181
+ options: ComparisonOptions,
182
+ ): Promise<DiscoveredNote[]> {
183
+ const groups = new Map<string, DiscoveredNote[]>();
184
+ for (const file of files) {
185
+ const key = `${noteKey(file.lineage)}\0${file.size}`;
186
+ const group = groups.get(key) ?? [];
187
+ group.push(file);
188
+ groups.set(key, group);
189
+ }
190
+ const selected: DiscoveredNote[] = [];
191
+ for (const group of groups.values()) {
192
+ if (group.length === 1) {
193
+ selected.push(...group);
194
+ continue;
195
+ }
196
+ const matches = new Map<string, DiscoveredNote>();
197
+ group.sort(
198
+ (a, b) =>
199
+ Number(sameScope(b.address, session.binding)) -
200
+ Number(sameScope(a.address, session.binding)),
201
+ );
202
+ for (const file of group) {
203
+ // Only same-lineage, same-size candidates need content comparison.
204
+ const digest = await this.#digest(file, session, options);
205
+ const previous = digest === undefined ? undefined : matches.get(digest);
206
+ if (previous === undefined || digest === undefined) {
207
+ matches.set(digest ?? noteKey(file.address), file);
208
+ } else {
209
+ matches.set(digest, {
210
+ ...previous,
211
+ copies: [...previous.copies, file.address],
212
+ });
213
+ }
214
+ }
215
+ selected.push(...matches.values());
216
+ }
217
+ return selected;
218
+ }
219
+ async #references(session: SessionNotes, options: DiscoveryOptions): Promise<DiscoveredNote[]> {
220
+ if (session.binding.kind !== "fork" || options.scope !== undefined) {
221
+ return [];
222
+ }
223
+ const recovery = session.recovery;
224
+ const { touches } = sessionNoteTouches({
225
+ branch: session.branch,
226
+ allEntries: recovery.entries,
227
+ projected: session.projection,
228
+ retained: new Set(),
229
+ directories: new Set(session.scopes.map(scopeDirectory)),
230
+ });
231
+ const paths = new Set(touches.map((touch) => touch.notePath));
232
+ const files: DiscoveredNote[] = [];
233
+ for (const path of paths) {
234
+ if (
235
+ session.owns(path) ||
236
+ (options.path !== undefined &&
237
+ path !== options.path &&
238
+ !path.startsWith(`${options.path}/`))
239
+ ) {
240
+ continue;
241
+ }
242
+ const file = await readReference(session, path, options.signal);
243
+ if (file !== undefined) {
244
+ files.push(file);
245
+ }
246
+ }
247
+ return files;
248
+ }
249
+ async list(
250
+ session: SessionNotes,
251
+ options: DiscoveryOptions,
252
+ ): Promise<{
253
+ readonly files: readonly DiscoveredNote[];
254
+ readonly notices: readonly string[];
255
+ readonly labeled: boolean;
256
+ }> {
257
+ const notices: string[] = [];
258
+ const notice = (text: string) => {
259
+ notices.push(text);
260
+ };
261
+ const scopes =
262
+ options.scope === undefined
263
+ ? await projectScopes(session, notice)
264
+ : [
265
+ {
266
+ scope: await session.selectScope(options.scope),
267
+ manifest: await readManifest(options.scope),
268
+ },
269
+ ];
270
+ const files: DiscoveredNote[] = [];
271
+ for (const scope of scopes) {
272
+ // Preserve native listing order while walking each scope independently.
273
+ files.push(...(await scopedFiles(scope, options, notice)));
274
+ }
275
+ for (const reference of await this.#references(session, options)) {
276
+ if (!files.some((file) => noteKey(file.address) === noteKey(reference.address))) {
277
+ files.push(reference);
278
+ }
279
+ }
280
+ const selected =
281
+ options.scope === undefined
282
+ ? await this.#collapse(files, session, { ...options, notice })
283
+ : files;
284
+ return {
285
+ files: selected.sort((a, b) => b.mtimeMs - a.mtimeMs),
286
+ notices,
287
+ labeled:
288
+ scopes.length > 1 || scopes.some(({ scope }) => !sameScope(scope, session.binding)),
289
+ };
290
+ }
291
+ }
@@ -0,0 +1,179 @@
1
+ import { join } from "node:path";
2
+ import { isRecord, optionalString, type EntryLike, type ProjectedEntry } from "./contracts.ts";
3
+ import { contextEdits } from "./history-entries.ts";
4
+ import { collectTouches, type NoteTouch, type ScannedNoteEntry } from "./recovery-note-touches.ts";
5
+ import {
6
+ addressOf,
7
+ bindingsOf,
8
+ notePath,
9
+ ownedPaths,
10
+ sameOwner,
11
+ sameScope,
12
+ scopeDirectory,
13
+ touchRecords,
14
+ type NoteBinding,
15
+ } from "./note-scopes.ts";
16
+
17
+ export type NotesRecovery = {
18
+ readonly binding: NoteBinding;
19
+ readonly entries: readonly EntryLike[];
20
+ };
21
+
22
+ export type NoteEvidence = {
23
+ readonly branch: readonly EntryLike[];
24
+ readonly allEntries: readonly EntryLike[];
25
+ readonly projected: readonly ProjectedEntry[];
26
+ readonly retained: ReadonlySet<string>;
27
+ readonly directories?: ReadonlySet<string>;
28
+ };
29
+ /** Compaction removes context, not provenance. Only active-branch edits suppress old receipts. */
30
+ function projectedNotes(evidence: NoteEvidence): ScannedNoteEntry[] {
31
+ const edits = contextEdits(evidence.branch);
32
+ const omitted = new Set(
33
+ evidence.projected
34
+ .filter(({ messages }) => messages.length === 0)
35
+ .map(({ sourceEntry }) => sourceEntry.id),
36
+ );
37
+ return evidence.branch.flatMap((entry) => {
38
+ const id = entry.id ?? "";
39
+ const edit = edits.get(id);
40
+ if (omitted.has(id) || edit?.replacement === null) {
41
+ return [];
42
+ }
43
+ const effective =
44
+ edit?.replacement !== undefined && entry.type === "message"
45
+ ? { ...entry, message: { ...entry.message, content: edit.replacement.content } }
46
+ : entry;
47
+ return [
48
+ { entry: effective, entryId: edit?.id ?? id, inRetained: evidence.retained.has(id) },
49
+ ];
50
+ });
51
+ }
52
+ function receiptKey(entry: EntryLike): string | undefined {
53
+ if (entry.type === "message" && entry.message?.role === "toolResult") {
54
+ return entry.message.toolCallId;
55
+ }
56
+ return entry.type === "custom_message" && isRecord(entry.details)
57
+ ? optionalString(entry.details.runId)
58
+ : undefined;
59
+ }
60
+ function effectReceipts(branch: readonly EntryLike[]): ReadonlyMap<string, EntryLike> {
61
+ const receipts = new Map<string, EntryLike>();
62
+ for (const entry of branch) {
63
+ const key = receiptKey(entry);
64
+ if (key !== undefined) {
65
+ // A delivered completion wins over the launch, including when an edit omits it.
66
+ receipts.set(key, entry);
67
+ }
68
+ }
69
+ return receipts;
70
+ }
71
+ function recordedTouches(
72
+ evidence: NoteEvidence,
73
+ visible: readonly ScannedNoteEntry[],
74
+ ): NoteTouch[] {
75
+ const receipts = effectReceipts(evidence.branch);
76
+ const shown = new Map(visible.map((item) => [item.entry.id, item]));
77
+ const seen = new Set<string>();
78
+ return touchRecords(evidence.allEntries).flatMap((record) => {
79
+ const receipt = receipts.get(record.rootToolCallId);
80
+ const item = shown.get(receipt?.id);
81
+ const key = `${record.owner.id}\0${record.owner.timestamp}\0${record.toolCallId}`;
82
+ if (item === undefined || seen.has(key)) {
83
+ return [];
84
+ }
85
+ seen.add(key);
86
+ return record.files.map((file): NoteTouch => {
87
+ let kind: NoteTouch["kind"] = "observed-write";
88
+ if (file.kind === "read") {
89
+ kind = "read";
90
+ } else if (file.kind === "delete" || file.kind === "clear") {
91
+ kind = "clear";
92
+ }
93
+ return {
94
+ notePath: file.path,
95
+ absPath: join(scopeDirectory(file), file.path),
96
+ kind,
97
+ entryId: item.entryId,
98
+ receiptId: item.entry.id ?? item.entryId,
99
+ inRetained: false,
100
+ owner: record.owner,
101
+ };
102
+ });
103
+ });
104
+ }
105
+ function touchKey(touch: NoteTouch): string {
106
+ return JSON.stringify([touch.receiptId ?? touch.entryId, touch.absPath ?? touch.notePath]);
107
+ }
108
+ export function sessionNoteTouches(evidence: NoteEvidence): {
109
+ readonly touches: readonly NoteTouch[];
110
+ readonly defaultDir: string | undefined;
111
+ readonly visibleIds: ReadonlySet<string>;
112
+ } {
113
+ const visible = projectedNotes(evidence);
114
+ const directories = new Set([
115
+ ...bindingsOf(evidence.allEntries).map(scopeDirectory),
116
+ ...(evidence.directories ?? []),
117
+ ]);
118
+ const legacy = collectTouches(visible, directories);
119
+ const recorded = recordedTouches(evidence, visible);
120
+ const recordedByReceipt = new Map(recorded.map((touch) => [touchKey(touch), touch]));
121
+ const covered = new Set<string>();
122
+ const identified = legacy.touches.map((touch) => {
123
+ const key = touchKey(touch);
124
+ covered.add(key);
125
+ return { ...touch, owner: recordedByReceipt.get(key)?.owner };
126
+ });
127
+ const touches = [...identified, ...recorded.filter((touch) => !covered.has(touchKey(touch)))];
128
+ const order = new Map(visible.map((entry, index) => [entry.entry.id ?? entry.entryId, index]));
129
+ touches.sort(
130
+ (a, b) =>
131
+ (order.get(a.receiptId ?? a.entryId) ?? 0) - (order.get(b.receiptId ?? b.entryId) ?? 0),
132
+ );
133
+ return {
134
+ touches,
135
+ defaultDir: legacy.defaultDir,
136
+ visibleIds: new Set(visible.map((item) => item.entry.id ?? "")),
137
+ };
138
+ }
139
+
140
+ /** Inherited history describes source bytes, not necessarily the bytes snapshotted at fork time. */
141
+ export function scopeRecoveryTouches(
142
+ touches: readonly NoteTouch[],
143
+ recovery: NotesRecovery | undefined,
144
+ ): readonly NoteTouch[] {
145
+ if (recovery === undefined || recovery.binding.kind === "project") {
146
+ return touches;
147
+ }
148
+ const owned = ownedPaths(recovery.binding, recovery.entries);
149
+ const scopes = [
150
+ recovery.binding,
151
+ ...bindingsOf(recovery.entries),
152
+ ...(recovery.binding.source === null ? [] : [recovery.binding.source]),
153
+ ];
154
+ return touches.map((touch): NoteTouch => {
155
+ const address = touch.absPath === undefined ? undefined : addressOf(touch.absPath, scopes);
156
+ if (address !== undefined && sameScope(address, recovery.binding)) {
157
+ return touch;
158
+ }
159
+ const currentOwner =
160
+ touch.owner !== undefined && sameOwner(touch.owner, recovery.binding.owner);
161
+ if (owned.has(touch.notePath) && !currentOwner) {
162
+ return {
163
+ ...touch,
164
+ absPath: notePath(recovery.binding, touch.notePath),
165
+ kind:
166
+ touch.kind === "read" || touch.kind === "clear" ? touch.kind : "observed-write",
167
+ inRetained: false,
168
+ };
169
+ }
170
+ return address === undefined
171
+ ? touch
172
+ : {
173
+ ...touch,
174
+ kind: "read",
175
+ referenceScope: scopeDirectory(address),
176
+ inRetained: false,
177
+ };
178
+ });
179
+ }
@@ -0,0 +1,203 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { withFileMutationQueue, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ addressOf,
7
+ canonicalPath,
8
+ inside,
9
+ notePath,
10
+ sameScope,
11
+ scopeDirectory,
12
+ type NoteAddress,
13
+ type NoteTouchRecord,
14
+ } from "./note-scopes.ts";
15
+ import { noteOperations, verifiedNoteOperations, type NoteOperation } from "./note-tool-effects.ts";
16
+ import type { NoteCall, NotesStore, SessionNotes } from "./note-store.ts";
17
+
18
+ /** Native core tools expand @/~ paths; pi-apply-edits deliberately treats them literally. */
19
+ function toolPath(tool: string, path: string, cwd: string): string {
20
+ if (tool !== "read" && tool !== "write" && tool !== "edit") {
21
+ if (process.platform === "win32") {
22
+ return resolve(cwd, path);
23
+ }
24
+ return isAbsolute(path) ? path : `${cwd}/${path}`;
25
+ }
26
+ let normalized = path
27
+ .replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/gu, " ")
28
+ .replace(/^@/u, "");
29
+ if (normalized === "~" || normalized.startsWith("~/")) {
30
+ normalized = join(homedir(), normalized.slice(1));
31
+ }
32
+ if (normalized.startsWith("file://")) {
33
+ normalized = fileURLToPath(normalized);
34
+ }
35
+ return resolve(cwd, normalized);
36
+ }
37
+ type CapturedCall = {
38
+ readonly session: SessionNotes;
39
+ readonly call: NoteCall;
40
+ readonly cwd: string;
41
+ readonly tool: string;
42
+ };
43
+ async function noteAddress(path: string, session: SessionNotes): Promise<NoteAddress | undefined> {
44
+ const known = addressOf(path, session.scopes);
45
+ if (known !== undefined) {
46
+ return known;
47
+ }
48
+ for (const { root } of session.scopes) {
49
+ const parts = relative(root, canonicalPath(path)).split(sep);
50
+ if (
51
+ parts[0] !== ".forks" ||
52
+ parts[1] === undefined ||
53
+ parts[2] !== "notes" ||
54
+ parts.length < 4
55
+ ) {
56
+ continue;
57
+ }
58
+ const scope = await session.selectScope(join(root, ".forks", parts[1], "notes"));
59
+ return addressOf(path, [scope]);
60
+ }
61
+ return;
62
+ }
63
+ function existingEdit(tool: string, operation: NoteOperation): boolean {
64
+ return (
65
+ operation.action === "edit" ||
66
+ operation.action === "delete" ||
67
+ (tool === "write_files" && (operation.action === "write" || operation.action === "clear"))
68
+ );
69
+ }
70
+ async function guardForeign(
71
+ address: NoteAddress,
72
+ operation: NoteOperation,
73
+ captured: CapturedCall,
74
+ ): Promise<string> {
75
+ const { session, tool, call } = captured;
76
+ if (operation.action === "read") {
77
+ return `This is another notes scope. Use notes read with ${JSON.stringify({ scope: scopeDirectory(address), path: address.path })} for a labeled read-only comparison, or omit scope to read this session’s copy.`;
78
+ }
79
+ return existingEdit(tool, operation) &&
80
+ session.scopes.some((scope) => sameScope(scope, address))
81
+ ? session.guardReference(address.path, call)
82
+ : `The foreign note was not changed. This session writes ${notePath(session.binding, address.path)}. Retry explicitly there; creation semantics were not changed.`;
83
+ }
84
+ async function guardOperation(
85
+ operation: NoteOperation,
86
+ captured: CapturedCall,
87
+ ): Promise<string | undefined> {
88
+ const { session, call, tool, cwd } = captured;
89
+ const path = toolPath(tool, operation.path, cwd);
90
+ const address = await noteAddress(path, session);
91
+ if (address === undefined) {
92
+ return operation.action !== "read" &&
93
+ session.scopes.some((scope) =>
94
+ inside(canonicalPath(join(scope.root, ".forks")), canonicalPath(path)),
95
+ )
96
+ ? `Managed notes metadata is not editable through file tools: ${path}. Use notes for this session’s checkpoint.`
97
+ : undefined;
98
+ }
99
+ if (!sameScope(address, session.binding)) {
100
+ return guardForeign(address, operation, captured);
101
+ }
102
+ if (existingEdit(tool, operation) && !session.owns(address.path)) {
103
+ const copied = await withFileMutationQueue(path, () => session.adopt(address.path, call));
104
+ if (copied) {
105
+ return `Private copy created at ${path}; no requested edit ran. Read this copy, then retry the edit.`;
106
+ }
107
+ }
108
+ if (operation.action !== "read") {
109
+ await session.prepareWrite();
110
+ }
111
+ return;
112
+ }
113
+ async function recordedFiles(
114
+ operations: readonly NoteOperation[],
115
+ captured: CapturedCall,
116
+ ): Promise<NoteTouchRecord["files"]> {
117
+ const files: NoteTouchRecord["files"][number][] = [];
118
+ for (const operation of operations) {
119
+ // Each result belongs to the scope captured before execution, even after /tree.
120
+ const address = await noteAddress(
121
+ toolPath(captured.tool, operation.path, captured.cwd),
122
+ captured.session,
123
+ );
124
+ if (address === undefined) {
125
+ continue;
126
+ }
127
+ let kind: NoteTouchRecord["files"][number]["kind"] = "write";
128
+ switch (operation.action) {
129
+ case "read":
130
+ case "delete":
131
+ kind = operation.action;
132
+ break;
133
+ case "clear":
134
+ case "create-empty":
135
+ kind = "clear";
136
+ break;
137
+ case "append":
138
+ case "create":
139
+ case "edit":
140
+ case "write":
141
+ break;
142
+ }
143
+ files.push({ ...address, kind });
144
+ }
145
+ return files;
146
+ }
147
+ export function registerNoteGuards(pi: ExtensionAPI, store: NotesStore): void {
148
+ const calls = new Map<string, CapturedCall>();
149
+ pi.on("tool_call", async (event, ctx) => {
150
+ const operations = noteOperations(event.toolName, event.input);
151
+ const session = await store.get(ctx).catch((error: unknown) => {
152
+ if (
153
+ event.toolName === "notes" ||
154
+ operations.some((operation) =>
155
+ store
156
+ .roots()
157
+ .some((root) =>
158
+ inside(
159
+ root,
160
+ canonicalPath(toolPath(event.toolName, operation.path, ctx.cwd)),
161
+ ),
162
+ ),
163
+ )
164
+ ) {
165
+ throw error;
166
+ }
167
+ return;
168
+ });
169
+ if (session === undefined) {
170
+ return;
171
+ }
172
+ const call = session.startCall(event.toolCallId, event.parentToolCallId);
173
+ const captured = { session, call, cwd: ctx.cwd, tool: event.toolName };
174
+ calls.set(event.toolCallId, captured);
175
+ if (event.toolName === "notes" || operations.length === 0) {
176
+ return;
177
+ }
178
+ for (const operation of operations) {
179
+ // Preflight never mutates arguments; a copied reference needs a fresh read before retry.
180
+ const reason = await guardOperation(operation, captured);
181
+ if (reason !== undefined) {
182
+ return { block: true, reason };
183
+ }
184
+ }
185
+ return;
186
+ });
187
+ pi.on("tool_result", async (event) => {
188
+ const captured = calls.get(event.toolCallId);
189
+ if (captured === undefined || event.toolName === "notes") {
190
+ return;
191
+ }
192
+ const details: unknown = event.details;
193
+ const operations = verifiedNoteOperations(event.toolName, event.input, {
194
+ details,
195
+ isError: event.isError,
196
+ });
197
+ captured.session.record(await recordedFiles(operations, captured), captured.call);
198
+ });
199
+ pi.on("tool_execution_end", (event) => {
200
+ calls.get(event.toolCallId)?.session.finishCall(event.toolCallId);
201
+ calls.delete(event.toolCallId);
202
+ });
203
+ }