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.
- package/README.md +7 -2
- package/index.ts +13 -3
- package/package.json +24 -21
- package/src/context-tools.ts +2 -2
- package/src/contracts.ts +39 -32
- package/src/files.ts +0 -5
- package/src/guidance.ts +41 -0
- package/src/history-entries.ts +16 -16
- package/src/history-pages.ts +1 -1
- package/src/history-search.ts +6 -6
- package/src/history.ts +5 -2
- 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 +118 -137
- package/src/paging.ts +2 -2
- package/src/recovery-note-touches.ts +389 -0
- package/src/recovery-notes.ts +54 -503
- package/src/recovery-records.ts +90 -0
- package/src/recovery-results.ts +7 -6
- package/src/recovery.ts +24 -108
- package/src/reminders.ts +3 -3
- package/src/retention.ts +23 -17
- package/src/rollover.ts +66 -88
- package/src/session-files.ts +29 -20
- package/src/session-lines.ts +1 -2
- package/src/ui/cards.ts +3 -2
- package/src/ui/display.ts +2 -2
- package/src/ui/text.ts +0 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { Type, type Static } from "typebox";
|
|
4
|
+
import { Check } from "typebox/value";
|
|
5
|
+
import { isRecord, type EntryLike } from "./contracts.ts";
|
|
6
|
+
|
|
7
|
+
export const NOTES_BINDING = "posthorse-notes-scope";
|
|
8
|
+
export const NOTES_TOUCH = "posthorse-note-touch";
|
|
9
|
+
const ownerSchema = Type.Object({ id: Type.String(), timestamp: Type.String() });
|
|
10
|
+
const scopeSchema = Type.Object({ root: Type.String(), scope: Type.String() });
|
|
11
|
+
const addressSchema = Type.Object({ ...scopeSchema.properties, path: Type.String() });
|
|
12
|
+
const identitySchema = Type.Object({
|
|
13
|
+
size: Type.Number(),
|
|
14
|
+
mtimeMs: Type.Number(),
|
|
15
|
+
ctimeMs: Type.Number(),
|
|
16
|
+
ino: Type.Number(),
|
|
17
|
+
dev: Type.Number(),
|
|
18
|
+
});
|
|
19
|
+
const seedSchema = Type.Object({
|
|
20
|
+
path: Type.String(),
|
|
21
|
+
source: addressSchema,
|
|
22
|
+
lineage: addressSchema,
|
|
23
|
+
digest: Type.String(),
|
|
24
|
+
identity: identitySchema,
|
|
25
|
+
anchors: Type.Array(Type.String()),
|
|
26
|
+
});
|
|
27
|
+
const sharedBinding = {
|
|
28
|
+
version: Type.Literal(1),
|
|
29
|
+
owner: ownerSchema,
|
|
30
|
+
root: Type.String(),
|
|
31
|
+
};
|
|
32
|
+
const bindingSchema = Type.Union([
|
|
33
|
+
Type.Object({ ...sharedBinding, kind: Type.Literal("project"), scope: Type.Literal("") }),
|
|
34
|
+
Type.Object({
|
|
35
|
+
...sharedBinding,
|
|
36
|
+
kind: Type.Literal("fork"),
|
|
37
|
+
scope: Type.String(),
|
|
38
|
+
source: Type.Union([scopeSchema, Type.Null()]),
|
|
39
|
+
seeds: Type.Array(seedSchema),
|
|
40
|
+
absent: Type.Array(Type.String()),
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
const touchSchema = Type.Object({
|
|
44
|
+
version: Type.Literal(1),
|
|
45
|
+
owner: ownerSchema,
|
|
46
|
+
scope: scopeSchema,
|
|
47
|
+
rootToolCallId: Type.String(),
|
|
48
|
+
toolCallId: Type.String(),
|
|
49
|
+
files: Type.Array(
|
|
50
|
+
Type.Object({
|
|
51
|
+
...addressSchema.properties,
|
|
52
|
+
seed: Type.Optional(seedSchema),
|
|
53
|
+
kind: Type.Union([
|
|
54
|
+
Type.Literal("read"),
|
|
55
|
+
Type.Literal("write"),
|
|
56
|
+
Type.Literal("clear"),
|
|
57
|
+
Type.Literal("delete"),
|
|
58
|
+
Type.Literal("adopt"),
|
|
59
|
+
]),
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
});
|
|
63
|
+
type Immutable<T> = T extends readonly (infer Item)[]
|
|
64
|
+
? readonly Immutable<Item>[]
|
|
65
|
+
: T extends object
|
|
66
|
+
? { readonly [Key in keyof T]: Immutable<T[Key]> }
|
|
67
|
+
: T;
|
|
68
|
+
export type NoteOwner = Immutable<Static<typeof ownerSchema>>;
|
|
69
|
+
export type NoteScope = Immutable<Static<typeof scopeSchema>>;
|
|
70
|
+
export type NoteAddress = Immutable<Static<typeof addressSchema>>;
|
|
71
|
+
export type NoteIdentity = Immutable<Static<typeof identitySchema>>;
|
|
72
|
+
export type NoteSeed = Immutable<Static<typeof seedSchema>>;
|
|
73
|
+
export type NoteBinding = Immutable<Static<typeof bindingSchema>>;
|
|
74
|
+
export type NoteTouchRecord = Immutable<Static<typeof touchSchema>>;
|
|
75
|
+
export type ForkBinding = Extract<NoteBinding, { readonly kind: "fork" }>;
|
|
76
|
+
|
|
77
|
+
export function sameOwner(left: NoteOwner, right: NoteOwner): boolean {
|
|
78
|
+
return left.id === right.id && left.timestamp === right.timestamp;
|
|
79
|
+
}
|
|
80
|
+
export function forkScope(owner: NoteOwner): string {
|
|
81
|
+
return `.forks/${new Date(owner.timestamp).toISOString().replace(/[:.]/gu, "-")}_${encodeURIComponent(owner.id)}/notes`;
|
|
82
|
+
}
|
|
83
|
+
export function scopeDirectory(scope: NoteScope): string {
|
|
84
|
+
return join(scope.root, scope.scope);
|
|
85
|
+
}
|
|
86
|
+
export function sameScope(left: NoteScope, right: NoteScope): boolean {
|
|
87
|
+
return left.root === right.root && left.scope === right.scope;
|
|
88
|
+
}
|
|
89
|
+
export function inside(directory: string, path: string): boolean {
|
|
90
|
+
const part = relative(directory, path);
|
|
91
|
+
return part === "" || (part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part));
|
|
92
|
+
}
|
|
93
|
+
export function noteRelative(path: string): string {
|
|
94
|
+
if (path === "" || isAbsolute(path) || path.includes("\0")) {
|
|
95
|
+
throw new Error(`Invalid note path ${JSON.stringify(path)}: use a relative path.`);
|
|
96
|
+
}
|
|
97
|
+
const normalized = relative(resolve("/"), resolve("/", path));
|
|
98
|
+
if (
|
|
99
|
+
path.split(/[/\\]/u).includes("..") ||
|
|
100
|
+
normalized === "" ||
|
|
101
|
+
normalized.split(sep)[0] === ".forks"
|
|
102
|
+
) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Invalid note path ${JSON.stringify(path)}: must stay outside managed .forks paths and inside its scope.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return normalized;
|
|
108
|
+
}
|
|
109
|
+
/** Resolve existing ancestors too, so aliases to files not created yet have one owner. */
|
|
110
|
+
export function canonicalPath(path: string): string {
|
|
111
|
+
const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`;
|
|
112
|
+
try {
|
|
113
|
+
return realpathSync(absolute);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (!isRecord(error) || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
const parent = dirname(absolute);
|
|
119
|
+
return parent === absolute
|
|
120
|
+
? absolute
|
|
121
|
+
: join(canonicalPath(parent), relative(parent, absolute));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function notePath(scope: NoteScope, path: string): string {
|
|
125
|
+
const directory = scopeDirectory(scope);
|
|
126
|
+
const target = join(directory, noteRelative(path));
|
|
127
|
+
const canonical = canonicalPath(target);
|
|
128
|
+
const escapesPrivate = scope.scope !== "" && !inside(canonicalPath(directory), canonical);
|
|
129
|
+
const entersManaged =
|
|
130
|
+
scope.scope === "" && inside(canonicalPath(join(scope.root, ".forks")), canonical);
|
|
131
|
+
if (escapesPrivate || entersManaged) {
|
|
132
|
+
throw new Error(`Note path leaves its scope through a filesystem alias: ${target}`);
|
|
133
|
+
}
|
|
134
|
+
return target;
|
|
135
|
+
}
|
|
136
|
+
function validScope(scope: NoteScope): boolean {
|
|
137
|
+
return (
|
|
138
|
+
isAbsolute(scope.root) &&
|
|
139
|
+
(scope.scope === "" || /^\.forks\/[^/\\]+\/notes$/u.test(scope.scope))
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
function validSeed(seed: NoteSeed): boolean {
|
|
143
|
+
try {
|
|
144
|
+
return (
|
|
145
|
+
noteRelative(seed.path) === seed.path &&
|
|
146
|
+
validScope(seed.source) &&
|
|
147
|
+
validScope(seed.lineage) &&
|
|
148
|
+
noteRelative(seed.source.path) === seed.source.path &&
|
|
149
|
+
noteRelative(seed.lineage.path) === seed.lineage.path
|
|
150
|
+
);
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function validNoteName(path: string): boolean {
|
|
156
|
+
try {
|
|
157
|
+
return noteRelative(path) === path;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function validFork(binding: ForkBinding): boolean {
|
|
163
|
+
return (
|
|
164
|
+
binding.scope === forkScope(binding.owner) &&
|
|
165
|
+
(binding.source === null || validScope(binding.source)) &&
|
|
166
|
+
binding.seeds.every(validSeed) &&
|
|
167
|
+
binding.absent.every(validNoteName)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
export function parseBinding(value: unknown): NoteBinding | undefined {
|
|
171
|
+
if (
|
|
172
|
+
!Check(bindingSchema, value) ||
|
|
173
|
+
!validScope(value) ||
|
|
174
|
+
value.owner.id === "" ||
|
|
175
|
+
!Number.isFinite(Date.parse(value.owner.timestamp))
|
|
176
|
+
) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (value.kind === "fork" && !validFork(value)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
export function bindingsOf(entries: readonly EntryLike[]): NoteBinding[] {
|
|
185
|
+
return entries.flatMap((entry) => {
|
|
186
|
+
const binding =
|
|
187
|
+
entry.type === "custom" && entry.customType === NOTES_BINDING
|
|
188
|
+
? parseBinding(entry.data)
|
|
189
|
+
: undefined;
|
|
190
|
+
return binding === undefined ? [] : [binding];
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
export function touchRecords(entries: readonly EntryLike[]): NoteTouchRecord[] {
|
|
194
|
+
return entries.flatMap((entry) => {
|
|
195
|
+
if (
|
|
196
|
+
entry.type !== "custom" ||
|
|
197
|
+
entry.customType !== NOTES_TOUCH ||
|
|
198
|
+
!Check(touchSchema, entry.data) ||
|
|
199
|
+
!validScope(entry.data.scope)
|
|
200
|
+
) {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return entry.data.files.every(
|
|
205
|
+
(file) =>
|
|
206
|
+
validScope(file) &&
|
|
207
|
+
noteRelative(file.path) === file.path &&
|
|
208
|
+
(file.seed === undefined || validSeed(file.seed)),
|
|
209
|
+
)
|
|
210
|
+
? [entry.data]
|
|
211
|
+
: [];
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
export function seedsOf(binding: NoteBinding, entries: readonly EntryLike[]): readonly NoteSeed[] {
|
|
218
|
+
const seeds = new Map(
|
|
219
|
+
binding.kind === "fork" ? binding.seeds.map((seed) => [seed.path, seed]) : [],
|
|
220
|
+
);
|
|
221
|
+
for (const record of touchRecords(entries)) {
|
|
222
|
+
if (!sameOwner(record.owner, binding.owner)) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
for (const file of record.files) {
|
|
226
|
+
if (file.seed !== undefined && sameScope(file, binding)) {
|
|
227
|
+
seeds.set(file.path, file.seed);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return [...seeds.values()];
|
|
232
|
+
}
|
|
233
|
+
export function ownedPaths(binding: NoteBinding, entries: readonly EntryLike[]): Set<string> {
|
|
234
|
+
const paths = new Set(
|
|
235
|
+
binding.kind === "fork"
|
|
236
|
+
? [...binding.absent, ...binding.seeds.map((seed) => seed.path)]
|
|
237
|
+
: [],
|
|
238
|
+
);
|
|
239
|
+
for (const record of touchRecords(entries)) {
|
|
240
|
+
if (!sameOwner(record.owner, binding.owner)) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
for (const file of record.files) {
|
|
244
|
+
if (file.kind !== "read" && sameScope(binding, file)) {
|
|
245
|
+
paths.add(file.path);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return paths;
|
|
250
|
+
}
|
|
251
|
+
/** Pure longest-prefix matching is also used for historical paths on unavailable filesystems. */
|
|
252
|
+
export function matchNoteDirectory(
|
|
253
|
+
path: string,
|
|
254
|
+
directories: readonly string[],
|
|
255
|
+
): { readonly directory: string; readonly path: string } | undefined {
|
|
256
|
+
const slash = path.replaceAll("\\", "/");
|
|
257
|
+
for (const directory of directories.toSorted((a, b) => b.length - a.length)) {
|
|
258
|
+
const normalized = directory.replaceAll("\\", "/").replace(/\/+$/u, "");
|
|
259
|
+
const part = slash.startsWith(`${normalized}/`) ? slash.slice(normalized.length + 1) : "";
|
|
260
|
+
if (part !== "" && part !== ".forks" && !part.startsWith(".forks/")) {
|
|
261
|
+
return { directory, path: part };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
export function addressOf(path: string, scopes: readonly NoteScope[]): NoteAddress | undefined {
|
|
267
|
+
const candidates = scopes.map((scope) => ({
|
|
268
|
+
scope,
|
|
269
|
+
directory: canonicalPath(scopeDirectory(scope)),
|
|
270
|
+
}));
|
|
271
|
+
for (const target of [canonicalPath(path), resolve(path)]) {
|
|
272
|
+
const match = matchNoteDirectory(
|
|
273
|
+
target,
|
|
274
|
+
candidates.map((candidate) => candidate.directory),
|
|
275
|
+
);
|
|
276
|
+
const candidate =
|
|
277
|
+
match === undefined
|
|
278
|
+
? undefined
|
|
279
|
+
: candidates.find((item) => item.directory === match.directory);
|
|
280
|
+
if (match !== undefined && candidate !== undefined) {
|
|
281
|
+
return { ...candidate.scope, path: match.path };
|
|
282
|
+
}
|
|
283
|
+
if (
|
|
284
|
+
scopes.some(
|
|
285
|
+
(scope) =>
|
|
286
|
+
scope.scope === "" && inside(canonicalPath(join(scope.root, ".forks")), target),
|
|
287
|
+
)
|
|
288
|
+
) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|