@seangle/pi-remember 0.0.0-stage → 0.2.0

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/inject.ts ADDED
@@ -0,0 +1,45 @@
1
+ export interface InjectOptions {
2
+ maxGlobalChars: number;
3
+ maxLocalChars: number;
4
+ }
5
+
6
+ export interface Snapshot {
7
+ text: string;
8
+ globalChars: number;
9
+ localChars: number;
10
+ }
11
+
12
+ /** Keep head and tail, cut the middle. Recent and oldest facts survive. */
13
+ export function middleTruncate(text: string, maxChars: number): string {
14
+ if (text.length <= maxChars) return text;
15
+ const head = Math.floor(maxChars * 0.2);
16
+ const tail = maxChars - head;
17
+ return (
18
+ text.slice(0, head) +
19
+ `\n...[truncated ${text.length - maxChars} chars]...\n` +
20
+ text.slice(-tail)
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Build the byte-stable injection block. Same input files => same output bytes,
26
+ * so provider prefix caches stay valid across turns within a session.
27
+ */
28
+ export function buildSnapshot(
29
+ globalText: string,
30
+ localText: string,
31
+ projectName: string,
32
+ opts: InjectOptions,
33
+ ): Snapshot {
34
+ const g = middleTruncate(globalText, opts.maxGlobalChars).trim();
35
+ const l = middleTruncate(localText, opts.maxLocalChars).trim();
36
+ if (!g && !l) return { text: "", globalChars: 0, localChars: 0 };
37
+ const parts: string[] = [
38
+ "<pi-remember>",
39
+ "Persistent memory: one-line facts plus a note catalogue derived from frontmatter (description, ID, dates, path). Note bodies are NOT included; read them by ID/path when needed. Global = user/machine facts; Project = this workspace. Refreshed at session start and after memory writes, stable between turns.",
40
+ ];
41
+ if (g) parts.push("## Global memory", g);
42
+ if (l) parts.push(`## Project memory (${projectName})`, l);
43
+ parts.push("</pi-remember>");
44
+ return { text: parts.join("\n\n"), globalChars: g.length, localChars: l.length };
45
+ }
package/migration.ts ADDED
@@ -0,0 +1,458 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, readdir, realpath, rename, unlink } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { isNoteTimestamp, legacyNote, parseNote, serializeNote, type NoteMetadata } from "./notes.ts";
6
+ import type { MemoId, MemoryEntry, StorePaths } from "./store.ts";
7
+
8
+ export const CURRENT_VERSION = 1;
9
+ const VERSION_FILE = "pi-remember.json";
10
+ const LOCK_FILE = ".pi-remember-migration.lock";
11
+ const inFlight = new Map<string, Promise<void>>();
12
+
13
+ export class StoreMigrationError extends Error {
14
+ constructor(message: string) { super(message); this.name = "StoreMigrationError"; }
15
+ }
16
+ function fail(message: string): never { throw new StoreMigrationError(`Memory migration: ${message}`); }
17
+ function object(value: unknown): value is Record<string, unknown> {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value);
19
+ }
20
+ function missing(error: unknown): boolean { return object(error) && error.code === "ENOENT"; }
21
+ function memoId(value: unknown): MemoId | undefined {
22
+ return typeof value === "string" && /^[a-f0-9]{8}$/i.test(value) ? value.toLowerCase() as MemoId : undefined;
23
+ }
24
+ function canonical(metadata: NoteMetadata): string { return `<!-- ${metadata.created} [${metadata.id}] -->`; }
25
+ function digest(text: string): string { return createHash("sha256").update(text).digest("hex"); }
26
+
27
+ interface Snapshot { rel: string; bytes: Buffer; text: string; mode: number; modified: string }
28
+ interface Reference { rel: string; title: string; meta?: string; id?: MemoId; created?: string }
29
+ interface NoteState { rel: string; metadata: NoteMetadata; text: string; source?: Snapshot; inactive: boolean }
30
+ interface Recovery { source: Snapshot; record: Record<string, unknown>; refs: Reference[]; snapshots: Map<string, string> }
31
+
32
+ /** Reject noncanonical paths instead of silently normalizing traversal or aliases. */
33
+ function safeRel(rel: string): string {
34
+ if (!rel || isAbsolute(rel) || rel.includes("\\") || /[\x00-\x1f\x7f]/.test(rel) ||
35
+ rel.split("/").some((part) => !part || part === "." || part === "..")) fail(`unsafe path: ${rel}`);
36
+ return rel;
37
+ }
38
+ function noteRel(rel: string): string {
39
+ safeRel(rel);
40
+ if (!rel.startsWith("notes/") || !rel.endsWith(".md")) fail(`unsafe note reference: ${rel}`);
41
+ return rel;
42
+ }
43
+ function metadataComment(meta?: string): { id?: MemoId; created?: string } {
44
+ if (!meta) return {};
45
+ const id = memoId(meta.match(/\[([^\]]+)\]/)?.[1]);
46
+ const stamp = meta.match(/^<!--\s+(\d{4}-\S+)/)?.[1];
47
+ if (stamp && !isNoteTimestamp(stamp)) fail(`invalid index metadata timestamp: ${stamp}`);
48
+ return { id, created: stamp };
49
+ }
50
+ function reference(entry: MemoryEntry): Reference | null {
51
+ if (!/^\s*#note\b/.test(entry.line)) return null;
52
+ const match = /^\s*#note[ \t]+\[\[([^\]]+)\]\](?:[ \t]+(.*?))?[ \t]*$/.exec(entry.line);
53
+ if (!match) fail(`malformed note reference: ${entry.line}`);
54
+ const rel = noteRel(match[1]);
55
+ const info = metadataComment(entry.meta);
56
+ if (entry.meta?.includes("[") && !info.id) fail(`invalid memo ID for ${rel}`);
57
+ return { rel, title: match[2]?.trim() ?? "", meta: entry.meta, ...info };
58
+ }
59
+
60
+ /** Preserve every unrelated byte, including blank lines and user comments. */
61
+ function indexPlan(text: string): { text: string; refs: Map<string, Reference>; factIds: Set<MemoId> } {
62
+ const lines = [...text.matchAll(/[^\n]*\n|[^\n]+$/g)].map((m) => ({ raw: m[0], line: m[0].replace(/\r?\n$/, ""), start: m.index! }));
63
+ const refs = new Map<string, Reference>();
64
+ const ids = new Set<MemoId>(), factIds = new Set<MemoId>(), removed = new Set<number>();
65
+ let pending: number | undefined;
66
+ for (let i = 0; i < lines.length; i++) {
67
+ const trimmed = lines[i].line.trim();
68
+ if (!trimmed) continue;
69
+ if (trimmed.startsWith("<!--") && trimmed.endsWith("-->")) { pending = i; continue; }
70
+ const meta = pending === undefined ? undefined : lines[pending].line.trim();
71
+ const ref = reference({ line: lines[i].line, meta });
72
+ const id = ref?.id ?? memoId(meta?.match(/\[([a-f0-9]{8})\]/i)?.[1]);
73
+ if (id) {
74
+ if (ids.has(id)) fail(`duplicate memo ID in MEMORY.md: ${id}`);
75
+ ids.add(id);
76
+ if (!ref) factIds.add(id);
77
+ }
78
+ if (ref) {
79
+ if (refs.has(ref.rel)) fail(`duplicate note reference: ${ref.rel}`);
80
+ refs.set(ref.rel, ref);
81
+ removed.add(i);
82
+ // Only managed metadata is ours to remove. A generic user comment is not.
83
+ if (pending !== undefined && (ref.id || ref.created)) removed.add(pending);
84
+ }
85
+ pending = undefined;
86
+ }
87
+ return { text: lines.filter((_, i) => !removed.has(i)).map((line) => line.raw).join(""), refs, factIds };
88
+ }
89
+
90
+ /** All migration I/O is store-relative; symlinks (including internal aliases) are conservatively refused. */
91
+ class Files {
92
+ readonly snapshots = new Map<string, Snapshot | null>();
93
+ readonly root: string;
94
+ constructor(root: string) { this.root = root; }
95
+
96
+ async check(rel: string): Promise<boolean> {
97
+ safeRel(rel);
98
+ const parts = rel.split("/");
99
+ for (let i = 0; i < parts.length; i++) {
100
+ const path = join(this.root, ...parts.slice(0, i + 1));
101
+ let info;
102
+ try { info = await lstat(path); } catch (error) { if (missing(error)) return false; throw error; }
103
+ if (info.isSymbolicLink()) fail(`symlink is not safe for migration: ${rel}`);
104
+ if (i < parts.length - 1 && !info.isDirectory()) fail(`not a directory: ${path}`);
105
+ }
106
+ return true;
107
+ }
108
+
109
+ async read(rel: string): Promise<Snapshot | null> {
110
+ if (this.snapshots.has(rel)) return this.snapshots.get(rel)!;
111
+ if (!await this.check(rel)) { this.snapshots.set(rel, null); return null; }
112
+ if (!(await lstat(join(this.root, rel))).isFile()) fail(`not a regular file: ${rel}`);
113
+ const handle = await open(join(this.root, rel), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
114
+ try {
115
+ const info = await handle.stat();
116
+ if (!info.isFile()) fail(`not a regular file: ${rel}`);
117
+ const bytes = await handle.readFile();
118
+ let text: string;
119
+ try { text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
120
+ catch { fail(`invalid UTF-8: ${rel}`); }
121
+ const result = { rel, bytes, text, mode: info.mode & 0o7777, modified: info.mtime.toISOString() };
122
+ this.snapshots.set(rel, result);
123
+ return result;
124
+ } finally { await handle.close(); }
125
+ }
126
+
127
+ async list(rel: string): Promise<string[]> {
128
+ if (!await this.check(rel)) return [];
129
+ if (!(await lstat(join(this.root, rel))).isDirectory()) fail(`not a directory: ${rel}`);
130
+ return (await readdir(join(this.root, rel))).sort();
131
+ }
132
+
133
+ async notes(rel = "notes"): Promise<Snapshot[]> {
134
+ const result: Snapshot[] = [];
135
+ for (const name of await this.list(rel)) {
136
+ const child = `${rel}/${name}`;
137
+ await this.check(child);
138
+ const info = await lstat(join(this.root, child));
139
+ if (info.isDirectory()) result.push(...await this.notes(child));
140
+ else if (name.endsWith(".md")) result.push((await this.read(child))!);
141
+ }
142
+ return result;
143
+ }
144
+ }
145
+
146
+ async function version(files: Files): Promise<number> {
147
+ const file = await files.read(VERSION_FILE);
148
+ if (!file) return 0;
149
+ let value: unknown;
150
+ try { value = JSON.parse(file.text); } catch { fail(`malformed ${VERSION_FILE}: expected {"version": number}`); }
151
+ if (!object(value) || typeof value.version !== "number" || !Number.isSafeInteger(value.version) || value.version < 0)
152
+ fail(`invalid store version in ${VERSION_FILE}; expected a nonnegative safe integer`);
153
+ if (value.version > CURRENT_VERSION) fail(`future store version ${value.version}; supported version is ${CURRENT_VERSION}`);
154
+ return value.version;
155
+ }
156
+
157
+ function entries(value: unknown): MemoryEntry[] {
158
+ if (!Array.isArray(value) || !value.every((entry) => object(entry) && typeof entry.line === "string" &&
159
+ !/[\r\n]/.test(entry.line) && (entry.meta === undefined || (typeof entry.meta === "string" && !/[\r\n]/.test(entry.meta)))))
160
+ fail("malformed recovery entries");
161
+ return value as MemoryEntry[];
162
+ }
163
+
164
+ async function recoveries(files: Files, scope: StorePaths["scope"]): Promise<Recovery[]> {
165
+ const result: Recovery[] = [];
166
+ for (const name of await files.list("recovery")) {
167
+ if (!name.endsWith(".json")) continue;
168
+ const source = (await files.read(`recovery/${name}`))!;
169
+ let record: unknown;
170
+ try { record = JSON.parse(source.text); } catch { fail(`malformed recovery JSON: ${source.rel}`); }
171
+ if (!object(record) || !memoId(record.id) || record.id !== basename(name, ".json") || record.scope !== scope ||
172
+ typeof record.timestamp !== "string" || !isNoteTimestamp(record.timestamp)) fail(`invalid recovery record: ${source.rel}`);
173
+ const refs: Reference[] = [], snapshots = new Map<string, string>();
174
+ if (record.kind === "update") {
175
+ if (!memoId(record.memoId) || typeof record.before !== "string" || typeof record.after !== "string" ||
176
+ (record.noteRel !== undefined && typeof record.noteRel !== "string") ||
177
+ (record.meta !== undefined && (typeof record.meta !== "string" || /[\r\n]/.test(record.meta))))
178
+ fail(`invalid update recovery: ${source.rel}`);
179
+ if (typeof record.noteRel === "string") noteRel(record.noteRel);
180
+ } else {
181
+ if (record.kind !== undefined && record.kind !== "forget") fail(`unknown recovery kind: ${source.rel}`);
182
+ const seen = new Set<string>(), ids = new Set<MemoId>();
183
+ for (const entry of entries(record.entries)) {
184
+ const ref = reference(entry);
185
+ const id = ref?.id ?? metadataComment(entry.meta).id;
186
+ if (id && ids.has(id)) fail(`duplicate recovery memo ID: ${id}`);
187
+ if (id) ids.add(id);
188
+ if (ref) {
189
+ if (seen.has(ref.rel)) fail(`duplicate recovery note reference: ${ref.rel}`);
190
+ seen.add(ref.rel); refs.push(ref);
191
+ }
192
+ }
193
+ if (record.notes !== undefined) {
194
+ if (!Array.isArray(record.notes)) fail(`invalid recovery notes: ${source.rel}`);
195
+ for (const note of record.notes) {
196
+ if (!object(note) || typeof note.rel !== "string" || typeof note.text !== "string") fail(`invalid recovery note: ${source.rel}`);
197
+ noteRel(note.rel);
198
+ if (snapshots.has(note.rel)) fail(`duplicate recovery note: ${note.rel}`);
199
+ if (!parseNote(note.text)) fail(`recovery note must have frontmatter: ${note.rel}`);
200
+ snapshots.set(note.rel, note.text);
201
+ }
202
+ for (const ref of refs) if (!snapshots.has(ref.rel)) fail(`missing recovery note snapshot: ${ref.rel}`);
203
+ }
204
+ }
205
+ result.push({ source, record, refs, snapshots });
206
+ }
207
+ return result;
208
+ }
209
+
210
+ /** Build and validate the ENTIRE plan before making even a backup directory. */
211
+ async function plan(files: Files, store: StorePaths, memoryRel: string): Promise<Map<string, string | null>> {
212
+ const memory = await files.read(memoryRel);
213
+ const index = indexPlan(memory?.text ?? "");
214
+ const disk = await files.notes();
215
+ const records = await recoveries(files, store.scope);
216
+ const deletedRefs = new Map<string, Reference[]>(), saved = new Map<string, string>();
217
+ for (const recovery of records) {
218
+ for (const ref of recovery.refs) deletedRefs.set(ref.rel, [...deletedRefs.get(ref.rel) ?? [], ref]);
219
+ for (const [rel, text] of recovery.snapshots) {
220
+ if (saved.has(rel) && saved.get(rel) !== text) fail(`conflicting deletion snapshots for ${rel}`);
221
+ saved.set(rel, text);
222
+ }
223
+ }
224
+ const byPath = new Map(disk.map((file) => [file.rel, file]));
225
+ for (const ref of index.refs.values()) if (!byPath.has(ref.rel)) fail(`dangling note reference: ${ref.rel}`);
226
+ for (const rel of deletedRefs.keys()) if (!byPath.has(rel) && !saved.has(rel)) fail(`dangling deleted note reference: ${rel}`);
227
+
228
+ const states = new Map<string, NoteState>();
229
+ const ids = new Map<MemoId, string>([...index.factIds].map((id) => [id, memoryRel]));
230
+ // Reserve all known IDs before generating any IDs for genuinely unindexed old notes.
231
+ const reserved = new Set<MemoId>(index.factIds);
232
+ for (const ref of [...index.refs.values(), ...[...deletedRefs.values()].flat()]) if (ref.id) reserved.add(ref.id);
233
+ for (const source of disk) { const parsed = parseNote(source.text); if (parsed) reserved.add(parsed.metadata.id); }
234
+ for (const text of saved.values()) reserved.add(parseNote(text)!.metadata.id);
235
+
236
+ for (const rel of [...new Set([...byPath.keys(), ...saved.keys()])].sort()) {
237
+ const source = byPath.get(rel);
238
+ const raw = source?.text ?? saved.get(rel)!;
239
+ const parsed = parseNote(raw);
240
+ const ref = index.refs.get(rel) ?? deletedRefs.get(rel)?.[0];
241
+ let metadata: NoteMetadata, text: string;
242
+ if (parsed) { metadata = parsed.metadata; text = raw; }
243
+ else {
244
+ const old = legacyNote(raw);
245
+ let id = ref?.id;
246
+ const prior = saved.get(rel);
247
+ const priorMetadata = prior ? parseNote(prior)!.metadata : undefined;
248
+ if (!id) id = priorMetadata?.id;
249
+ if (!id) {
250
+ do { id = randomUUID().slice(0, 8) as MemoId; } while (reserved.has(id));
251
+ reserved.add(id);
252
+ }
253
+ // Legacy notes did not record last-modified. Infer it from the original file mtime;
254
+ // when no creation timestamp exists, infer creation from that same mtime too.
255
+ metadata = priorMetadata ?? {
256
+ id, description: ref?.title || old.description || raw.match(/^# ([^\r\n]+)/m)?.[1] || basename(rel, ".md"),
257
+ created: ref?.created ?? old.created ?? source!.modified, modified: source!.modified,
258
+ };
259
+ text = serializeNote(metadata, old.body);
260
+ }
261
+ for (const associated of [index.refs.get(rel), ...deletedRefs.get(rel) ?? []]) {
262
+ if (associated?.id && associated.id !== metadata.id) fail(`note ID/reference mismatch: ${rel}`);
263
+ if (associated?.created && associated.created !== metadata.created) fail(`note creation/reference mismatch: ${rel}`);
264
+ }
265
+ const duplicate = ids.get(metadata.id);
266
+ if (duplicate) fail(`duplicate memo ID ${metadata.id}: ${duplicate} and ${rel}`);
267
+ ids.set(metadata.id, rel);
268
+ const inactive = !index.refs.has(rel) && (deletedRefs.has(rel) || saved.has(rel));
269
+ if (inactive && saved.has(rel) && saved.get(rel) !== text) fail(`deleted note changed since recovery snapshot: ${rel}`);
270
+ states.set(rel, { rel, source, metadata, text, inactive });
271
+ }
272
+
273
+ const changes = new Map<string, string | null>();
274
+ // Recovery conversions are deliberately applied before note/index edits. A retry can
275
+ // use their snapshots and IDs even if an inactive note was already removed.
276
+ for (const recovery of records) {
277
+ const { record, source } = recovery;
278
+ let next: Record<string, unknown>;
279
+ if (record.kind === "update") {
280
+ if (typeof record.noteRel !== "string") continue;
281
+ const state = states.get(record.noteRel);
282
+ if (!state) fail(`dangling update recovery note: ${record.noteRel}`);
283
+ const meta = metadataComment(record.meta as string | undefined);
284
+ if (memoId(record.memoId) !== state.metadata.id || meta.id !== state.metadata.id || meta.created !== state.metadata.created)
285
+ fail(`update recovery identity mismatch: ${source.rel}`);
286
+ const oldMeta = index.refs.get(state.rel)?.meta ?? deletedRefs.get(state.rel)?.[0]?.meta;
287
+ if (oldMeta && record.meta !== oldMeta && record.meta !== canonical(state.metadata))
288
+ fail(`update recovery metadata conflict: ${source.rel}`);
289
+ const convert = (raw: string): string => {
290
+ const parsed = parseNote(raw);
291
+ if (parsed) {
292
+ if (parsed.metadata.id !== state.metadata.id) fail(`update recovery content ID mismatch: ${source.rel}`);
293
+ return raw; // Existing frontmatter bytes must retain exact conflict semantics.
294
+ }
295
+ return serializeNote(state.metadata, legacyNote(raw).body);
296
+ };
297
+ const before = convert(record.before as string), after = convert(record.after as string);
298
+ if (record.before !== record.after && before === after) fail(`update differs only in a managed legacy header: ${source.rel}`);
299
+ if (record.migrationLegacyAfterSha256 !== undefined &&
300
+ (typeof record.migrationLegacyAfterSha256 !== "string" || !/^[a-f0-9]{64}$/.test(record.migrationLegacyAfterSha256)))
301
+ fail(`invalid legacy update fingerprint: ${source.rel}`);
302
+ // Retain the old byte identity across an interruption after recovery was
303
+ // rewritten but before the note was replaced. Header edits must not vanish.
304
+ const legacyAfterHash = !parseNote(record.after as string) ? digest(record.after as string) : record.migrationLegacyAfterSha256;
305
+ if (state.source && !parseNote(state.source.text) && legacyAfterHash &&
306
+ digest(state.source.text) !== legacyAfterHash && state.text === after)
307
+ fail(`update recovery would hide a legacy header conflict: ${source.rel}`);
308
+ next = { ...record, meta: canonical(state.metadata), before, after,
309
+ ...(legacyAfterHash ? { migrationLegacyAfterSha256: legacyAfterHash } : {}) };
310
+ } else {
311
+ const snapshots = new Map(recovery.snapshots);
312
+ const convertedEntries = entries(record.entries).map((entry) => {
313
+ const ref = reference(entry);
314
+ if (!ref) return entry;
315
+ const state = states.get(ref.rel)!;
316
+ const text = snapshots.get(ref.rel) ?? state.text;
317
+ const metadata = parseNote(text)!.metadata;
318
+ if (ref.id && ref.id !== metadata.id) fail(`deletion recovery ID mismatch: ${source.rel}`);
319
+ snapshots.set(ref.rel, text);
320
+ return { line: `#note [[${ref.rel}]] ${metadata.description}`, meta: canonical(metadata) };
321
+ });
322
+ for (const [rel, text] of snapshots) {
323
+ if (!recovery.refs.some((ref) => ref.rel === rel)) {
324
+ const metadata = parseNote(text)!.metadata;
325
+ convertedEntries.push({ line: `#note [[${rel}]] ${metadata.description}`, meta: canonical(metadata) });
326
+ }
327
+ }
328
+ const entryIds = new Set<MemoId>();
329
+ for (const entry of convertedEntries) {
330
+ const id = metadataComment(entry.meta).id;
331
+ if (id && entryIds.has(id)) fail(`duplicate deletion recovery memo ID: ${id}`);
332
+ if (id) entryIds.add(id);
333
+ }
334
+ next = { ...record, kind: "forget", entries: convertedEntries, notes: [...snapshots].map(([rel, text]) => ({ rel, text })) };
335
+ }
336
+ const text = JSON.stringify(next, null, 2) + "\n";
337
+ if (text !== source.text) changes.set(source.rel, text);
338
+ }
339
+ for (const state of states.values()) {
340
+ if (!state.source) continue;
341
+ if (state.inactive) changes.set(state.rel, null);
342
+ else if (state.text !== state.source.text) changes.set(state.rel, state.text);
343
+ }
344
+ if (memory && memory.text !== index.text) changes.set(memoryRel, index.text);
345
+ changes.set(VERSION_FILE, JSON.stringify({ version: CURRENT_VERSION }, null, 2) + "\n");
346
+ return changes;
347
+ }
348
+
349
+ async function newFile(path: string, content: string | Buffer, mode: number): Promise<void> {
350
+ const handle = await open(path, "wx", mode);
351
+ try { await handle.writeFile(content); await handle.chmod(mode); await handle.sync(); }
352
+ finally { await handle.close(); }
353
+ }
354
+
355
+ async function syncDirectory(path: string): Promise<void> {
356
+ const handle = await open(path, constants.O_RDONLY | constants.O_DIRECTORY);
357
+ try { await handle.sync(); } finally { await handle.close(); }
358
+ }
359
+
360
+ async function apply(files: Files, changes: Map<string, string | null>): Promise<void> {
361
+ await files.check("migrations");
362
+ await mkdir(join(files.root, "migrations"), { recursive: true, mode: 0o700 });
363
+ const backupRel = `migrations/v0-to-v1-${randomUUID()}`;
364
+ await mkdir(join(files.root, backupRel), { mode: 0o700 });
365
+ const manifest = [];
366
+ const backupDirs = new Set([files.root, join(files.root, "migrations"), join(files.root, backupRel)]);
367
+ // Back up ALL affected originals before modifying ANY originals. Every run gets
368
+ // its own immutable (exclusive-created) backup set, including on partial retries.
369
+ for (const [rel, text] of changes) {
370
+ const source = files.snapshots.get(rel);
371
+ const backup = source ? `files/${rel}` : null;
372
+ if (source) {
373
+ const target = join(files.root, backupRel, backup!);
374
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
375
+ await newFile(target, source.bytes, 0o400);
376
+ for (let dir = dirname(target); dir.startsWith(join(files.root, backupRel) + sep); dir = dirname(dir)) backupDirs.add(dir);
377
+ }
378
+ manifest.push({ rel, backup, existed: !!source, mode: source?.mode ?? null,
379
+ modified: source?.modified ?? null, action: text === null ? "remove" : "replace" });
380
+ }
381
+ await newFile(join(files.root, backupRel, "manifest.json"), JSON.stringify({
382
+ fromVersion: 0, toVersion: CURRENT_VERSION, timestamp: new Date().toISOString(),
383
+ inference: "Legacy modified dates use original filesystem mtime; creation falls back to mtime when index/generated timestamps are absent.",
384
+ files: manifest,
385
+ }, null, 2) + "\n", 0o400);
386
+ for (const dir of [...backupDirs].sort((a, b) => b.length - a.length)) await syncDirectory(dir);
387
+
388
+ // Detect edits during planning/backups instead of overwriting them. This is not a
389
+ // substitute for the caller's ordinary mutation queue; the lock serializes migrators.
390
+ const verify = new Files(files.root);
391
+ for (const [rel, original] of files.snapshots) {
392
+ const current = await verify.read(rel);
393
+ if ((original === null) !== (current === null) || (original && current &&
394
+ (!original.bytes.equals(current.bytes) || original.mode !== current.mode || original.modified !== current.modified)))
395
+ fail(`file changed while preparing migration: ${rel}; backups retained`);
396
+ }
397
+ for (const [rel, text] of changes) {
398
+ await files.check(rel);
399
+ const path = join(files.root, rel);
400
+ if (text === null) { await unlink(path); await syncDirectory(dirname(path)); continue; }
401
+ const temp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
402
+ try {
403
+ await newFile(temp, text, files.snapshots.get(rel)?.mode ?? 0o600);
404
+ await rename(temp, path);
405
+ await syncDirectory(dirname(path));
406
+ } finally {
407
+ try { await unlink(temp); } catch (error) { if (!missing(error)) throw error; }
408
+ }
409
+ }
410
+ }
411
+
412
+ async function migrate(root: string, store: StorePaths): Promise<void> {
413
+ if (await version(new Files(root)) === CURRENT_VERSION) return;
414
+ const lock = join(root, LOCK_FILE);
415
+ let handle;
416
+ try { handle = await open(lock, "wx", 0o600); }
417
+ catch (error) {
418
+ if (object(error) && error.code === "EEXIST") fail(`migration lock already exists: ${lock}. Another process may be migrating; inspect it manually (locks are never stolen).`);
419
+ throw error;
420
+ }
421
+ try {
422
+ await handle.writeFile(JSON.stringify({ pid: process.pid, timestamp: new Date().toISOString() }) + "\n");
423
+ const files = new Files(root);
424
+ if (await version(files) === CURRENT_VERSION) return;
425
+ const configuredRoot = resolve(store.dir);
426
+ const configured = (path: string): string => {
427
+ const rel = relative(configuredRoot, resolve(path)).split(sep).join("/");
428
+ return safeRel(rel);
429
+ };
430
+ if (configured(store.notesDir) !== "notes" || configured(store.recoveryDir) !== "recovery")
431
+ fail("expected notes/ and recovery/ directories inside the store");
432
+ const changes = await plan(files, store, configured(store.memoryFile));
433
+ await apply(files, changes);
434
+ } finally {
435
+ try { await handle.close(); } finally { await unlink(lock); }
436
+ }
437
+ }
438
+
439
+ /** Read-only guard for optional context: never inject a legacy, invalid or future store. */
440
+ export async function assertCurrentStoreVersion(store: StorePaths): Promise<void> {
441
+ let root: string;
442
+ try { root = await realpath(store.dir); }
443
+ catch (error) { if (missing(error)) return; throw error; }
444
+ if (await version(new Files(root)) !== CURRENT_VERSION) fail("store has not been migrated; context omitted");
445
+ }
446
+
447
+ /** No missing-store creation, no permanent cache: every completed call rechecks the version. */
448
+ export async function ensureStoreVersion(store: StorePaths): Promise<void> {
449
+ let root: string;
450
+ try { root = await realpath(store.dir); }
451
+ catch (error) { if (missing(error)) return; throw error; }
452
+ if (!(await lstat(root)).isDirectory()) fail(`store is not a directory: ${store.dir}`);
453
+ const existing = inFlight.get(root);
454
+ if (existing) return existing;
455
+ const operation = migrate(root, store);
456
+ inFlight.set(root, operation);
457
+ try { await operation; } finally { if (inFlight.get(root) === operation) inFlight.delete(root); }
458
+ }
package/notes.ts ADDED
@@ -0,0 +1,131 @@
1
+ import { open } from "node:fs/promises";
2
+ import type { MemoId } from "./store.ts";
3
+
4
+ export interface NoteMetadata {
5
+ description: string;
6
+ id: MemoId;
7
+ created: string;
8
+ modified: string;
9
+ }
10
+
11
+ /** Maximum UTF-8 header size, including the opening/closing delimiters. */
12
+ const HEADER_BYTES = 16 * 1024;
13
+
14
+ export class InvalidNoteError extends Error {
15
+ constructor(message: string) { super(message); this.name = "InvalidNoteError"; }
16
+ }
17
+
18
+ /** ISO timestamps only, with real calendar dates (Date.parse alone accepts February 30). */
19
+ export function isNoteTimestamp(value: string): boolean {
20
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/.exec(value);
21
+ if (!m) return false;
22
+ const [, year, month, day, hour, minute, second, zone] = m;
23
+ const y = Number(year), mo = Number(month), d = Number(day);
24
+ const leap = y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0);
25
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
26
+ return mo >= 1 && mo <= 12 && d >= 1 && d <= days[mo - 1] &&
27
+ Number(hour) < 24 && Number(minute) < 60 && Number(second) < 60 &&
28
+ (zone === "Z" || (Number(zone.slice(1, 3)) < 24 && Number(zone.slice(4)) < 60)) &&
29
+ Number.isFinite(Date.parse(value));
30
+ }
31
+
32
+ function invalid(message: string): never { throw new InvalidNoteError(`Invalid note frontmatter: ${message}`); }
33
+
34
+ function validate(metadata: NoteMetadata): NoteMetadata {
35
+ if (typeof metadata.description !== "string" || !metadata.description.trim() || /[\x00-\x1f\x7f]/.test(metadata.description))
36
+ invalid("description must be a nonempty single-line string");
37
+ if (typeof metadata.id !== "string" || !/^[a-f0-9]{8}$/i.test(metadata.id)) invalid("memory-id must be eight hexadecimal characters");
38
+ if (typeof metadata.created !== "string" || !isNoteTimestamp(metadata.created)) invalid("invalid created-date ISO timestamp");
39
+ if (typeof metadata.modified !== "string" || !isNoteTimestamp(metadata.modified)) invalid("invalid last-modified-date ISO timestamp");
40
+ return { ...metadata, id: metadata.id.toLowerCase() as MemoId };
41
+ }
42
+
43
+ /** A deliberately small YAML subset: one scalar per line, no tags, aliases or multiline values. */
44
+ function scalar(raw: string): string {
45
+ const value = raw.trim();
46
+ if (value.startsWith('"')) {
47
+ const match = /^"(?:[^"\\\r\n]|\\.)*"/.exec(value);
48
+ if (!match || !/^(?:\s+#.*)?\s*$/.test(value.slice(match[0].length))) invalid("malformed double-quoted scalar");
49
+ try { return JSON.parse(match[0]) as string; } catch { invalid("unsupported double-quoted escape (use JSON escapes)"); }
50
+ }
51
+ if (value.startsWith("'")) {
52
+ const match = /^'(?:[^']|'')*'/.exec(value);
53
+ if (!match || !/^(?:\s+#.*)?\s*$/.test(value.slice(match[0].length))) invalid("malformed single-quoted scalar");
54
+ return match[0].slice(1, -1).replace(/''/g, "'");
55
+ }
56
+ const plain = value.replace(/\s+#.*$/, "").trimEnd();
57
+ if (!plain || /^[!&*|>{}\[\],@`#%]/.test(plain) || /^[-?:](?:\s|$)/.test(plain) ||
58
+ /:\s/.test(plain) || /^(?:null|~|true|false)$/i.test(plain))
59
+ invalid("unsupported plain scalar; quote the value");
60
+ return plain;
61
+ }
62
+
63
+ /** null means genuinely legacy text; a started but invalid header always throws. Body bytes are untouched. */
64
+ export function parseNote(text: string): { metadata: NoteMetadata; body: string } | null {
65
+ const start = /^(?:\uFEFF)?---[ \t]*(?:\r?\n|$)/.exec(text);
66
+ if (!start) return null;
67
+ const remainder = text.slice(start[0].length);
68
+ const close = /^---[ \t]*(?:\r?\n|$)/m.exec(remainder);
69
+ if (!close) invalid("missing closing delimiter (or header exceeds 16 KiB)");
70
+ const end = start[0].length + close.index + close[0].length;
71
+ if (Buffer.byteLength(text.slice(0, end), "utf8") > HEADER_BYTES) invalid("header exceeds 16 KiB");
72
+ const fields = new Map<string, string>();
73
+ const names = new Set(["description", "memory-id", "created-date", "last-modified-date"]);
74
+ for (const line of remainder.slice(0, close.index).split(/\r?\n/)) {
75
+ if (!line.trim() || /^\s*#/.test(line)) continue;
76
+ const match = /^([a-z-]+):[ \t]*(.*)$/.exec(line);
77
+ if (!match || !names.has(match[1])) invalid(`unsupported field: ${line}`);
78
+ if (fields.has(match[1])) invalid(`duplicate field: ${match[1]}`);
79
+ fields.set(match[1], scalar(match[2]));
80
+ }
81
+ if (fields.size !== 4) invalid("expected description, memory-id, created-date and last-modified-date");
82
+ const metadata = validate({ description: fields.get("description")!, id: fields.get("memory-id")! as MemoId,
83
+ created: fields.get("created-date")!, modified: fields.get("last-modified-date")! });
84
+ return { metadata, body: text.slice(end) };
85
+ }
86
+
87
+ export function serializeNote(metadata: NoteMetadata, body: string): string {
88
+ const m = validate(metadata);
89
+ const header = `---\ndescription: ${JSON.stringify(m.description)}\nmemory-id: ${JSON.stringify(m.id)}\ncreated-date: ${JSON.stringify(m.created)}\nlast-modified-date: ${JSON.stringify(m.modified)}\n---\n`;
90
+ if (Buffer.byteLength(header, "utf8") > HEADER_BYTES) invalid("header exceeds 16 KiB");
91
+ return header + body;
92
+ }
93
+
94
+ /** Stop at the header in small chunks; cap reads at 16 KiB plus one lookahead byte. */
95
+ export async function readNoteHeader(path: string): Promise<NoteMetadata | null> {
96
+ const file = await open(path, "r");
97
+ try {
98
+ // Lookahead prevents a truncated "---not-a-delimiter" at the bound from
99
+ // masquerading as a valid closing delimiter at end of file.
100
+ const buffer = Buffer.alloc(HEADER_BYTES + 1);
101
+ let used = 0;
102
+ while (used < buffer.length) {
103
+ const { bytesRead } = await file.read(buffer, used, Math.min(512, buffer.length - used), used);
104
+ if (!bytesRead) break;
105
+ used += bytesRead;
106
+ const prefix = buffer.subarray(0, used).toString("utf8");
107
+ const start = /^(?:\uFEFF)?---[ \t]*(?:\r?\n|$)/.exec(prefix);
108
+ if (!start) {
109
+ if (used < 512 && !prefix.includes("\n")) continue; // A short read may split the opening delimiter/BOM.
110
+ return null;
111
+ }
112
+ // Require a newline while still streaming: a chunk ending in "---"
113
+ // could be followed by non-delimiter text in the next chunk.
114
+ if (/^---[ \t]*\r?\n/m.test(prefix.slice(start[0].length))) {
115
+ return parseNote(prefix)!.metadata;
116
+ }
117
+ }
118
+ return parseNote(buffer.subarray(0, used).toString("utf8"))?.metadata ?? null;
119
+ } finally { await file.close(); }
120
+ }
121
+
122
+ /** Strip only pi-remember's generated marker, its immediate H1 and one generated blank line. */
123
+ export function legacyNote(text: string): { description?: string; created?: string; body: string } {
124
+ if (!/^(?:\uFEFF)?<!-- pi-remember note:/.test(text)) return { body: text };
125
+ const marker = /^(?:\uFEFF)?<!-- pi-remember note: ([^\r\n]+) -->(?:\r?\n|$)/.exec(text);
126
+ if (!marker || !isNoteTimestamp(marker[1])) throw new InvalidNoteError("Malformed generated pi-remember note marker");
127
+ let body = text.slice(marker[0].length);
128
+ const heading = /^# ([^\r\n]*)(?:\r?\n|$)/.exec(body);
129
+ if (heading) body = body.slice(heading[0].length).replace(/^\r?\n/, "");
130
+ return { ...(heading ? { description: heading[1] } : {}), created: marker[1], body };
131
+ }