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.
- package/README.md +7 -2
- package/index.ts +13 -3
- package/package.json +6 -6
- package/src/contracts.ts +3 -0
- package/src/note-discovery.ts +291 -0
- package/src/note-evidence.ts +179 -0
- package/src/note-guards.ts +203 -0
- package/src/note-inventory.ts +111 -0
- package/src/note-provenance.ts +299 -0
- package/src/note-scopes.ts +293 -0
- package/src/note-snapshots.ts +185 -0
- package/src/note-store.ts +278 -0
- package/src/note-tool-effects.ts +190 -0
- package/src/notes.ts +117 -133
- package/src/recovery-note-touches.ts +59 -106
- package/src/recovery-notes.ts +50 -69
- package/src/recovery.ts +5 -0
- package/src/retention.ts +3 -0
- package/src/rollover.ts +22 -5
- package/src/session-files.ts +7 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isRecord } from "./contracts.ts";
|
|
4
|
+
import {
|
|
5
|
+
canonicalPath,
|
|
6
|
+
inside,
|
|
7
|
+
notePath,
|
|
8
|
+
scopeDirectory,
|
|
9
|
+
type NoteIdentity,
|
|
10
|
+
type NoteScope,
|
|
11
|
+
} from "./note-scopes.ts";
|
|
12
|
+
import { fileIdentity } from "./note-snapshots.ts";
|
|
13
|
+
|
|
14
|
+
export type NoteFile = NoteIdentity & { readonly path: string };
|
|
15
|
+
/** Maximum overlapped stats; 16, 64 and 256 were equivalent on the measured 212,751-note tree. */
|
|
16
|
+
const WALK_BATCH = 64;
|
|
17
|
+
async function statIfPresent(path: string) {
|
|
18
|
+
try {
|
|
19
|
+
return await stat(path);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Preserve depth-first listing order and recheck later entries after visiting each folder. */
|
|
28
|
+
async function* folderEntries(directory: string, signal: AbortSignal | undefined) {
|
|
29
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
30
|
+
let start = 0;
|
|
31
|
+
while (start < entries.length) {
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
const batch = entries.slice(start, start + WALK_BATCH);
|
|
34
|
+
const firstFolder = batch.findIndex(
|
|
35
|
+
(entry) => entry.isDirectory() || entry.isSymbolicLink(),
|
|
36
|
+
);
|
|
37
|
+
const paths = batch
|
|
38
|
+
.slice(0, firstFolder === -1 ? batch.length : firstFolder + 1)
|
|
39
|
+
.map((entry) => join(directory, entry.name));
|
|
40
|
+
const infos = await Promise.all(paths.map(statIfPresent));
|
|
41
|
+
for (const [index, path] of paths.entries()) {
|
|
42
|
+
start += 1;
|
|
43
|
+
const info = infos[index];
|
|
44
|
+
if (info !== undefined) {
|
|
45
|
+
yield { path, info, alias: batch[index]?.isSymbolicLink() === true };
|
|
46
|
+
}
|
|
47
|
+
if (info?.isDirectory() === true) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
type WalkState = {
|
|
54
|
+
readonly ancestors: readonly string[];
|
|
55
|
+
readonly scope: NoteScope;
|
|
56
|
+
readonly notice: (text: string) => void;
|
|
57
|
+
};
|
|
58
|
+
function allowedTarget(path: string, state: WalkState): boolean {
|
|
59
|
+
const scopeRoot = canonicalPath(scopeDirectory(state.scope));
|
|
60
|
+
if (state.scope.scope === "") {
|
|
61
|
+
return !inside(canonicalPath(join(state.scope.root, ".forks")), path);
|
|
62
|
+
}
|
|
63
|
+
if (inside(scopeRoot, path)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
state.notice(`Not indexed: alias outside private notes scope (${path}).`);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
async function walk(
|
|
70
|
+
directory: string,
|
|
71
|
+
signal: AbortSignal | undefined,
|
|
72
|
+
state: WalkState,
|
|
73
|
+
): Promise<NoteFile[]> {
|
|
74
|
+
signal?.throwIfAborted();
|
|
75
|
+
const root = await realpath(directory);
|
|
76
|
+
if (state.ancestors.includes(root) || !allowedTarget(root, state)) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const next: WalkState = { ...state, ancestors: [...state.ancestors, root] };
|
|
80
|
+
const files: NoteFile[] = [];
|
|
81
|
+
for await (const { path, info, alias } of folderEntries(directory, signal)) {
|
|
82
|
+
if (info.isDirectory()) {
|
|
83
|
+
for (const file of await walk(path, signal, next)) {
|
|
84
|
+
files.push(file);
|
|
85
|
+
}
|
|
86
|
+
} else if (!alias || allowedTarget(canonicalPath(path), state)) {
|
|
87
|
+
files.push({ path, ...fileIdentity(info) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
export async function notesUnder(
|
|
93
|
+
scope: NoteScope,
|
|
94
|
+
folder: string | undefined,
|
|
95
|
+
options: { readonly signal: AbortSignal | undefined; readonly notice: (text: string) => void },
|
|
96
|
+
): Promise<NoteFile[]> {
|
|
97
|
+
const target =
|
|
98
|
+
folder === undefined || folder === "" || folder === "."
|
|
99
|
+
? scopeDirectory(scope)
|
|
100
|
+
: notePath(scope, folder);
|
|
101
|
+
const info = await statIfPresent(target);
|
|
102
|
+
options.signal?.throwIfAborted();
|
|
103
|
+
if (info === undefined) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const files = info.isDirectory()
|
|
107
|
+
? await walk(target, options.signal, { scope, ancestors: [], notice: options.notice })
|
|
108
|
+
: [{ path: target, ...fileIdentity(info) }];
|
|
109
|
+
options.signal?.throwIfAborted();
|
|
110
|
+
return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
111
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { isRecord, type EntryLike } from "./contracts.ts";
|
|
5
|
+
import { sessionLines } from "./session-lines.ts";
|
|
6
|
+
import { parseSessionLine } from "./session-files.ts";
|
|
7
|
+
import { sessionNoteTouches } from "./note-evidence.ts";
|
|
8
|
+
import { readManifest, snapshot, type SnapshotNote } from "./note-snapshots.ts";
|
|
9
|
+
import {
|
|
10
|
+
addressOf,
|
|
11
|
+
bindingsOf,
|
|
12
|
+
canonicalPath,
|
|
13
|
+
forkScope,
|
|
14
|
+
notePath,
|
|
15
|
+
sameOwner,
|
|
16
|
+
sameScope,
|
|
17
|
+
scopeDirectory,
|
|
18
|
+
ownedPaths,
|
|
19
|
+
seedsOf,
|
|
20
|
+
type ForkBinding,
|
|
21
|
+
type NoteAddress,
|
|
22
|
+
type NoteBinding,
|
|
23
|
+
type NoteScope,
|
|
24
|
+
} from "./note-scopes.ts";
|
|
25
|
+
|
|
26
|
+
type NotesManager = ExtensionContext["sessionManager"];
|
|
27
|
+
export type NoteSource = {
|
|
28
|
+
readonly entries: readonly EntryLike[];
|
|
29
|
+
readonly file: string | undefined;
|
|
30
|
+
};
|
|
31
|
+
type Candidate = SnapshotNote & { readonly cleared: boolean };
|
|
32
|
+
function includedJournalType(type: string | undefined, legacy: boolean): boolean {
|
|
33
|
+
return legacy || type === undefined || type === "session" || type === "custom";
|
|
34
|
+
}
|
|
35
|
+
async function journalMetadata(
|
|
36
|
+
file: string,
|
|
37
|
+
legacy: boolean,
|
|
38
|
+
signal?: AbortSignal,
|
|
39
|
+
): Promise<readonly EntryLike[]> {
|
|
40
|
+
const entries: EntryLike[] = [];
|
|
41
|
+
try {
|
|
42
|
+
for await (const line of sessionLines(file, signal)) {
|
|
43
|
+
const type = /^\s*\{\s*"type"\s*:\s*"([^"]+)"/u.exec(line.head)?.[1];
|
|
44
|
+
if (!includedJournalType(type, legacy)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const entry = parseSessionLine(await line.text());
|
|
48
|
+
if (entry !== undefined && includedJournalType(entry.type, legacy)) {
|
|
49
|
+
entries.push(entry);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (!isRecord(error) || error.code !== "ENOENT") {
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return entries;
|
|
58
|
+
}
|
|
59
|
+
/** Read only; opening a SessionManager here could migrate or create an archived source. */
|
|
60
|
+
export async function sourceEntries(
|
|
61
|
+
file: string | undefined,
|
|
62
|
+
signal?: AbortSignal,
|
|
63
|
+
): Promise<readonly EntryLike[]> {
|
|
64
|
+
let current = file;
|
|
65
|
+
const seen = new Set<string>();
|
|
66
|
+
const entries: EntryLike[] = [];
|
|
67
|
+
while (current !== undefined && !seen.has(current)) {
|
|
68
|
+
seen.add(current);
|
|
69
|
+
// Each ancestor's current ownership prevents falling through a deleted owned file.
|
|
70
|
+
const metadata = await journalMetadata(current, false, signal);
|
|
71
|
+
entries.push(...metadata);
|
|
72
|
+
current = metadata.find((entry) => entry.type === "session")?.parentSession;
|
|
73
|
+
}
|
|
74
|
+
return entries;
|
|
75
|
+
}
|
|
76
|
+
function legacySource(entries: readonly EntryLike[]): NoteScope | undefined {
|
|
77
|
+
const { touches, defaultDir } = sessionNoteTouches({
|
|
78
|
+
branch: entries,
|
|
79
|
+
allEntries: entries,
|
|
80
|
+
projected: [],
|
|
81
|
+
retained: new Set(),
|
|
82
|
+
});
|
|
83
|
+
const roots = new Set(defaultDir === undefined ? [] : [defaultDir]);
|
|
84
|
+
for (const touch of touches) {
|
|
85
|
+
if (touch.absPath !== undefined && touch.absPath.endsWith(`/${touch.notePath}`)) {
|
|
86
|
+
roots.add(touch.absPath.slice(0, -touch.notePath.length - 1));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if ([...roots].some((root) => !isAbsolute(root))) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const canonical = new Set([...roots].map(canonicalPath));
|
|
93
|
+
const [root] = canonical;
|
|
94
|
+
return canonical.size === 1 && root !== undefined ? { root, scope: "" } : undefined;
|
|
95
|
+
}
|
|
96
|
+
async function sourceScope(
|
|
97
|
+
manager: NotesManager,
|
|
98
|
+
source: NoteSource,
|
|
99
|
+
): Promise<NoteScope | undefined> {
|
|
100
|
+
const header = source.entries.find((entry) => entry.type === "session");
|
|
101
|
+
const bindings = bindingsOf([...manager.getEntries(), ...source.entries]);
|
|
102
|
+
const binding =
|
|
103
|
+
header?.id === undefined || header.timestamp === undefined
|
|
104
|
+
? bindings.at(-1)
|
|
105
|
+
: bindings.findLast(
|
|
106
|
+
(candidate) =>
|
|
107
|
+
candidate.owner.id === header.id &&
|
|
108
|
+
candidate.owner.timestamp === header.timestamp,
|
|
109
|
+
);
|
|
110
|
+
if (binding !== undefined) {
|
|
111
|
+
return binding;
|
|
112
|
+
}
|
|
113
|
+
const legacy =
|
|
114
|
+
source.file === undefined ? manager.getEntries() : await journalMetadata(source.file, true);
|
|
115
|
+
return legacySource(legacy.length > 0 ? legacy : manager.getEntries());
|
|
116
|
+
}
|
|
117
|
+
export async function resolveReference(
|
|
118
|
+
scope: NoteScope,
|
|
119
|
+
path: string,
|
|
120
|
+
entries: readonly EntryLike[],
|
|
121
|
+
): Promise<NoteAddress | undefined> {
|
|
122
|
+
let source: NoteScope | null = scope;
|
|
123
|
+
const bindings = bindingsOf(entries);
|
|
124
|
+
const visited = new Set<string>();
|
|
125
|
+
while (source !== null) {
|
|
126
|
+
const current: NoteScope = source;
|
|
127
|
+
const directory = scopeDirectory(current);
|
|
128
|
+
if (visited.has(directory)) {
|
|
129
|
+
throw new Error(`Cyclic note provenance at ${directory}.`);
|
|
130
|
+
}
|
|
131
|
+
visited.add(directory);
|
|
132
|
+
if (existsSync(notePath(current, path))) {
|
|
133
|
+
return { ...current, path };
|
|
134
|
+
}
|
|
135
|
+
// A manifest includes later adoptions; the journal includes later writes and deletions.
|
|
136
|
+
const binding: NoteBinding | undefined =
|
|
137
|
+
(await readManifest(directory)) ??
|
|
138
|
+
bindings.findLast((candidate) => sameScope(candidate, current));
|
|
139
|
+
if (
|
|
140
|
+
binding === undefined ||
|
|
141
|
+
binding.kind === "project" ||
|
|
142
|
+
ownedPaths(binding, entries).has(path)
|
|
143
|
+
) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
source = binding.source;
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
function touchedCandidates(
|
|
151
|
+
evidence: ReturnType<typeof sessionNoteTouches>,
|
|
152
|
+
scopes: readonly NoteScope[],
|
|
153
|
+
allEntries: readonly EntryLike[],
|
|
154
|
+
): Map<string, Candidate> {
|
|
155
|
+
const bindings = bindingsOf(allEntries);
|
|
156
|
+
const selected = new Map<string, Candidate>();
|
|
157
|
+
for (const touch of evidence.touches) {
|
|
158
|
+
const absolute =
|
|
159
|
+
touch.absPath ??
|
|
160
|
+
(evidence.defaultDir === undefined
|
|
161
|
+
? undefined
|
|
162
|
+
: join(evidence.defaultDir, touch.notePath));
|
|
163
|
+
const address = absolute === undefined ? undefined : addressOf(absolute, scopes);
|
|
164
|
+
if (address === undefined) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const owner = bindings.findLast((binding) => sameScope(binding, address));
|
|
168
|
+
const lineage =
|
|
169
|
+
owner === undefined
|
|
170
|
+
? address
|
|
171
|
+
: (seedsOf(owner, allEntries).find((seed) => seed.path === address.path)?.lineage ??
|
|
172
|
+
address);
|
|
173
|
+
selected.set(address.path, {
|
|
174
|
+
path: address.path,
|
|
175
|
+
source: address,
|
|
176
|
+
lineage,
|
|
177
|
+
anchors: [touch.receiptId ?? touch.entryId],
|
|
178
|
+
cleared: touch.kind === "clear",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return selected;
|
|
182
|
+
}
|
|
183
|
+
function seedCandidates(
|
|
184
|
+
manager: NotesManager,
|
|
185
|
+
source: NoteSource,
|
|
186
|
+
scope: NoteScope | undefined,
|
|
187
|
+
): Candidate[] {
|
|
188
|
+
const allEntries = [...manager.getEntries(), ...source.entries];
|
|
189
|
+
const bindings = bindingsOf(allEntries);
|
|
190
|
+
const scopes = scope === undefined ? bindings : [...bindings, scope];
|
|
191
|
+
const evidence = sessionNoteTouches({
|
|
192
|
+
branch: manager.getBranch(),
|
|
193
|
+
allEntries,
|
|
194
|
+
projected: manager.buildSessionProjection().entries,
|
|
195
|
+
retained: new Set(),
|
|
196
|
+
directories: new Set(scopes.map(scopeDirectory)),
|
|
197
|
+
});
|
|
198
|
+
if (scope === undefined && evidence.touches.length > 0) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
"Incomplete notes snapshot: the source notes root is missing or ambiguous.",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const selected = touchedCandidates(evidence, scopes, allEntries);
|
|
204
|
+
for (const binding of bindings) {
|
|
205
|
+
for (const seed of seedsOf(binding, allEntries)) {
|
|
206
|
+
if (
|
|
207
|
+
!selected.has(seed.path) &&
|
|
208
|
+
seed.anchors.some((id) => evidence.visibleIds.has(id))
|
|
209
|
+
) {
|
|
210
|
+
selected.set(seed.path, {
|
|
211
|
+
...seed,
|
|
212
|
+
source: { root: binding.root, scope: binding.scope, path: seed.path },
|
|
213
|
+
cleared: false,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return [...selected.values()];
|
|
219
|
+
}
|
|
220
|
+
async function forkBinding(
|
|
221
|
+
manager: NotesManager,
|
|
222
|
+
binding: ForkBinding,
|
|
223
|
+
source: NoteSource,
|
|
224
|
+
): Promise<ForkBinding> {
|
|
225
|
+
const scope = await sourceScope(manager, source);
|
|
226
|
+
const candidates = seedCandidates(manager, source, scope);
|
|
227
|
+
const entries = [...manager.getEntries(), ...source.entries];
|
|
228
|
+
const files: SnapshotNote[] = [];
|
|
229
|
+
const absent: string[] = [];
|
|
230
|
+
for (const candidate of candidates) {
|
|
231
|
+
// Resolve against the immediate parent's files, not a historical ancestor's version.
|
|
232
|
+
const address = await resolveReference(scope ?? candidate.source, candidate.path, entries);
|
|
233
|
+
if (address !== undefined) {
|
|
234
|
+
files.push({
|
|
235
|
+
path: candidate.path,
|
|
236
|
+
source: address,
|
|
237
|
+
lineage: candidate.lineage,
|
|
238
|
+
anchors: candidate.anchors,
|
|
239
|
+
});
|
|
240
|
+
} else if (candidate.cleared) {
|
|
241
|
+
absent.push(candidate.path);
|
|
242
|
+
} else {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Incomplete notes snapshot: source note ${candidate.path} is unavailable.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return snapshot(
|
|
249
|
+
{
|
|
250
|
+
...binding,
|
|
251
|
+
source: scope === undefined ? null : { root: scope.root, scope: scope.scope },
|
|
252
|
+
absent,
|
|
253
|
+
},
|
|
254
|
+
files,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
export async function loadNotesBinding(
|
|
258
|
+
manager: NotesManager,
|
|
259
|
+
root: string,
|
|
260
|
+
): Promise<{
|
|
261
|
+
readonly binding: NoteBinding;
|
|
262
|
+
readonly source: NoteSource;
|
|
263
|
+
readonly created: boolean;
|
|
264
|
+
}> {
|
|
265
|
+
const header = manager.getHeader();
|
|
266
|
+
if (header === null) {
|
|
267
|
+
throw new Error("Pi did not supply the session header for notes ownership.");
|
|
268
|
+
}
|
|
269
|
+
const owner = { id: header.id, timestamp: header.timestamp };
|
|
270
|
+
const own = bindingsOf(manager.getEntries()).findLast((binding) =>
|
|
271
|
+
sameOwner(binding.owner, owner),
|
|
272
|
+
);
|
|
273
|
+
const source: NoteSource = {
|
|
274
|
+
entries: await sourceEntries(header.parentSession),
|
|
275
|
+
file: header.parentSession,
|
|
276
|
+
};
|
|
277
|
+
if (own !== undefined) {
|
|
278
|
+
return { binding: own, source, created: false };
|
|
279
|
+
}
|
|
280
|
+
const inherited = bindingsOf([...manager.getEntries(), ...source.entries]);
|
|
281
|
+
const fork = header.parentSession !== undefined || inherited.length > 0;
|
|
282
|
+
const binding: NoteBinding = fork
|
|
283
|
+
? await forkBinding(
|
|
284
|
+
manager,
|
|
285
|
+
{
|
|
286
|
+
version: 1,
|
|
287
|
+
kind: "fork",
|
|
288
|
+
owner,
|
|
289
|
+
root,
|
|
290
|
+
scope: forkScope(owner),
|
|
291
|
+
source: null,
|
|
292
|
+
seeds: [],
|
|
293
|
+
absent: [],
|
|
294
|
+
},
|
|
295
|
+
source,
|
|
296
|
+
)
|
|
297
|
+
: { version: 1, kind: "project", owner, root, scope: "" };
|
|
298
|
+
return { binding, source, created: true };
|
|
299
|
+
}
|