@seangle/pi-remember 0.2.0 → 0.3.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/CHANGELOG.md +41 -9
- package/README.md +149 -223
- package/dist/command.js +56 -0
- package/dist/context.js +57 -0
- package/dist/index.js +124 -0
- package/dist/tools.js +552 -0
- package/package.json +23 -24
- package/command.ts +0 -36
- package/index.ts +0 -751
- package/inject.ts +0 -45
- package/migration.ts +0 -458
- package/notes.ts +0 -131
- package/search.ts +0 -250
- package/store.ts +0 -730
- package/summary.ts +0 -70
- package/workspace.ts +0 -158
package/store.ts
DELETED
|
@@ -1,730 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
|
|
3
|
-
import { chmod, link, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
-
import { join, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
-
import { parseNote, readNoteHeader, serializeNote, type NoteMetadata } from "./notes.ts";
|
|
6
|
-
export type { NoteMetadata } from "./notes.ts";
|
|
7
|
-
|
|
8
|
-
export type Scope = "global" | "local";
|
|
9
|
-
export type MemoId = string & { readonly __memoId: unique symbol };
|
|
10
|
-
|
|
11
|
-
export function parseMemoId(input: string): MemoId | null {
|
|
12
|
-
return /^[a-f0-9]{8}$/i.test(input) ? input.toLowerCase() as MemoId : null;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function entryId(entry: MemoryEntry): MemoId | null {
|
|
16
|
-
const match = entry.meta?.match(/\[([a-f0-9]{8})\]/i);
|
|
17
|
-
return match ? parseMemoId(match[1]) : null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function noteReference(entry: MemoryEntry): string | null {
|
|
21
|
-
return entry.line.match(/^#note\s+\[\[([^\]]+)\]\]/)?.[1] ?? null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Atomic replacement, retaining permissions and following existing symlinks. */
|
|
25
|
-
async function replaceFile(path: string, content: string): Promise<void> {
|
|
26
|
-
const target = realpathSync(path);
|
|
27
|
-
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
28
|
-
const mode = (await stat(target)).mode & 0o7777;
|
|
29
|
-
try {
|
|
30
|
-
await writeFile(temporary, content, { flag: "wx", mode });
|
|
31
|
-
await chmod(temporary, mode); // Creation mode alone is reduced by the process umask.
|
|
32
|
-
await rename(temporary, target);
|
|
33
|
-
} finally {
|
|
34
|
-
await rm(temporary, { force: true });
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Publish a complete file atomically without ever overwriting an existing pathname. */
|
|
39
|
-
async function createFileExclusive(path: string, content: string, mode = 0o600): Promise<void> {
|
|
40
|
-
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
41
|
-
try {
|
|
42
|
-
await writeFile(temporary, content, { flag: "wx", mode });
|
|
43
|
-
await chmod(temporary, mode);
|
|
44
|
-
await link(temporary, path);
|
|
45
|
-
} finally { await rm(temporary, { force: true }); }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Check existing store-relative components without following links or creating directories. */
|
|
49
|
-
function assertNoRedirect(store: StorePaths, path: string): void {
|
|
50
|
-
const rel = relative(resolve(store.dir), resolve(path));
|
|
51
|
-
if (!rel || isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) {
|
|
52
|
-
throw new MemoryConflictError("Memory path is outside its store; refusing to use it.");
|
|
53
|
-
}
|
|
54
|
-
let current = store.dir;
|
|
55
|
-
for (const part of rel.split(sep)) {
|
|
56
|
-
current = join(current, part);
|
|
57
|
-
try {
|
|
58
|
-
if (lstatSync(current).isSymbolicLink()) {
|
|
59
|
-
throw new MemoryConflictError("Memory path contains a symlink; refusing to use it.");
|
|
60
|
-
}
|
|
61
|
-
} catch (error) {
|
|
62
|
-
if (isObject(error) && error.code === "ENOENT") return;
|
|
63
|
-
throw error;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Notes/recovery may not be redirected through a symlink, even inside a versioned store. */
|
|
69
|
-
async function ensureOwnedDirectory(store: StorePaths, path: string, mode: number): Promise<void> {
|
|
70
|
-
assertNoRedirect(store, path);
|
|
71
|
-
await mkdir(path, { recursive: true, mode });
|
|
72
|
-
assertNoRedirect(store, path);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface StorePaths {
|
|
76
|
-
scope: Scope;
|
|
77
|
-
dir: string;
|
|
78
|
-
memoryFile: string;
|
|
79
|
-
recoveryDir: string;
|
|
80
|
-
notesDir: string;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export interface MemoryEntry {
|
|
84
|
-
/** The `<!-- ... -->` comment line directly above this entry, if any. */
|
|
85
|
-
meta?: string;
|
|
86
|
-
/** Raw fact line (no trailing whitespace). */
|
|
87
|
-
line: string;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function resolveStores(
|
|
91
|
-
cwd: string,
|
|
92
|
-
globalDir: string,
|
|
93
|
-
configDirName: string,
|
|
94
|
-
): Record<Scope, StorePaths> {
|
|
95
|
-
const localDir = join(cwd, configDirName, "memory");
|
|
96
|
-
return {
|
|
97
|
-
global: {
|
|
98
|
-
scope: "global",
|
|
99
|
-
dir: globalDir,
|
|
100
|
-
memoryFile: join(globalDir, "MEMORY.md"),
|
|
101
|
-
recoveryDir: join(globalDir, "recovery"),
|
|
102
|
-
notesDir: join(globalDir, "notes"),
|
|
103
|
-
},
|
|
104
|
-
local: {
|
|
105
|
-
scope: "local",
|
|
106
|
-
dir: localDir,
|
|
107
|
-
memoryFile: join(localDir, "MEMORY.md"),
|
|
108
|
-
recoveryDir: join(localDir, "recovery"),
|
|
109
|
-
notesDir: join(localDir, "notes"),
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Split raw MEMORY.md text into fact entries with their metadata comments. */
|
|
115
|
-
export function parseEntries(text: string): MemoryEntry[] {
|
|
116
|
-
return entrySpans(text).map(({ entry }) => entry);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Locate entry lines so targeted changes preserve all unrelated bytes. */
|
|
120
|
-
function entrySpans(text: string): { entry: MemoryEntry; start: number; end: number }[] {
|
|
121
|
-
const entries: { entry: MemoryEntry; start: number; end: number }[] = [];
|
|
122
|
-
let pendingMeta: string | undefined;
|
|
123
|
-
let start = 0;
|
|
124
|
-
for (const raw of text.split(/\r?\n/)) {
|
|
125
|
-
const trimmed = raw.trim();
|
|
126
|
-
if (trimmed.startsWith("<!--") && trimmed.endsWith("-->")) {
|
|
127
|
-
pendingMeta = trimmed;
|
|
128
|
-
} else if (trimmed.length > 0) {
|
|
129
|
-
entries.push({ entry: { meta: pendingMeta, line: raw.replace(/\s+$/, "") }, start, end: start + raw.length });
|
|
130
|
-
pendingMeta = undefined;
|
|
131
|
-
}
|
|
132
|
-
start += raw.length + (text.slice(start + raw.length, start + raw.length + 2) === "\r\n" ? 2 : 1);
|
|
133
|
-
}
|
|
134
|
-
return entries;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function serializeEntries(entries: MemoryEntry[]): string {
|
|
138
|
-
if (entries.length === 0) return "";
|
|
139
|
-
return (
|
|
140
|
-
entries.map((e) => `${e.meta ? `${e.meta}\n` : ""}${e.line}`).join("\n\n") + "\n"
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Normalize for dedupe: strip metadata/tags/links, lowercase, collapse whitespace. */
|
|
145
|
-
export function normalizeForCompare(line: string): string {
|
|
146
|
-
return line
|
|
147
|
-
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
148
|
-
.replace(/#[\w-]+/g, " ")
|
|
149
|
-
.replace(/\[\[[\w\-/:.]+]]/g, " ")
|
|
150
|
-
.toLowerCase()
|
|
151
|
-
.replace(/[^\w]+/g, " ")
|
|
152
|
-
.trim();
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Existing entries that equal, contain, or are contained by the candidate. */
|
|
156
|
-
export function findDuplicates(existing: MemoryEntry[], candidate: string): MemoryEntry[] {
|
|
157
|
-
const c = normalizeForCompare(candidate);
|
|
158
|
-
if (c.length === 0) return [];
|
|
159
|
-
return existing.filter((e) => {
|
|
160
|
-
const n = normalizeForCompare(e.line);
|
|
161
|
-
if (n.length === 0) return false;
|
|
162
|
-
return n === c || n.includes(c) || c.includes(n);
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Similar-but-not-identical entries for cross-scope warnings: either
|
|
168
|
-
* normalized string containment with >=50% overlap, or token containment
|
|
169
|
-
* (>=50% of the shorter entry's tokens appear in the other). Very short
|
|
170
|
-
* entries are skipped — exact matches are handled by findDuplicates.
|
|
171
|
-
*/
|
|
172
|
-
export function findSimilar(existing: MemoryEntry[], candidate: string): MemoryEntry[] {
|
|
173
|
-
const c = normalizeForCompare(candidate);
|
|
174
|
-
if (c.length < 10) return [];
|
|
175
|
-
const cTokens = new Set(c.split(" "));
|
|
176
|
-
return existing.filter((e) => {
|
|
177
|
-
const n = normalizeForCompare(e.line);
|
|
178
|
-
if (n.length < 10 || n === c) return false;
|
|
179
|
-
const shorter = Math.min(n.length, c.length);
|
|
180
|
-
const longer = Math.max(n.length, c.length);
|
|
181
|
-
if (shorter / longer >= 0.5 && (n.includes(c) || c.includes(n))) return true;
|
|
182
|
-
const nTokens = new Set(n.split(" "));
|
|
183
|
-
let inter = 0;
|
|
184
|
-
for (const t of cTokens) if (nTokens.has(t)) inter++;
|
|
185
|
-
return inter / Math.min(cTokens.size, nTokens.size) >= 0.5;
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export async function readMemoryText(store: StorePaths): Promise<string> {
|
|
190
|
-
if (!existsSync(store.memoryFile)) return "";
|
|
191
|
-
return readFile(store.memoryFile, "utf8");
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
export interface NoteRecord {
|
|
195
|
-
entry: MemoryEntry;
|
|
196
|
-
rel: string;
|
|
197
|
-
path: string;
|
|
198
|
-
metadata: NoteMetadata;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export function noteEntry(rel: string, metadata: NoteMetadata): MemoryEntry {
|
|
202
|
-
return { meta: `<!-- ${metadata.created} [${metadata.id}] -->`, line: `#note [[${rel}]] ${metadata.description}` };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/** Discover only note metadata. Bodies are read only by explicit reads, updates and search. */
|
|
206
|
-
export async function readNotes(store: StorePaths): Promise<NoteRecord[]> {
|
|
207
|
-
if (!existsSync(store.notesDir)) return [];
|
|
208
|
-
const notes: NoteRecord[] = [];
|
|
209
|
-
const stores = { local: store, global: store };
|
|
210
|
-
async function walk(directory: string): Promise<void> {
|
|
211
|
-
for (const child of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
212
|
-
const path = join(directory, child.name);
|
|
213
|
-
// Do not traverse symlink directories. resolveNotePath also excludes escaped files.
|
|
214
|
-
if (child.isDirectory()) await walk(path);
|
|
215
|
-
else if (child.isFile() && child.name.endsWith(".md")) {
|
|
216
|
-
const rel = relative(store.dir, path).split(sep).join("/");
|
|
217
|
-
const resolved = resolveNotePath(stores, rel, [store.scope]);
|
|
218
|
-
if (resolved.status === "error" || !resolved.absPath.startsWith(realpathSync(store.notesDir) + sep)) continue;
|
|
219
|
-
const metadata = await readNoteHeader(resolved.absPath);
|
|
220
|
-
if (metadata) notes.push({ entry: noteEntry(rel, metadata), rel, path: resolved.absPath, metadata });
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
// An externally linked notes directory is not part of this store.
|
|
225
|
-
if (lstatSync(store.notesDir).isSymbolicLink() || !realpathSync(store.notesDir).startsWith(realpathSync(store.dir) + sep)) return [];
|
|
226
|
-
await walk(store.notesDir);
|
|
227
|
-
return notes;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** A derived catalogue, never persisted back into MEMORY.md. */
|
|
231
|
-
export async function readCatalogue(store: StorePaths): Promise<string> {
|
|
232
|
-
const facts = await readMemoryText(store);
|
|
233
|
-
const refs = new Set(parseEntries(facts).map(noteReference).filter((rel) => rel !== null));
|
|
234
|
-
const notes = (await readNotes(store)).filter((note) => !refs.has(note.rel));
|
|
235
|
-
const catalogue = serializeEntries(notes.map((note) => ({ ...note.entry,
|
|
236
|
-
meta: `<!-- ${note.metadata.created} [${note.metadata.id}] modified ${note.metadata.modified} -->` })));
|
|
237
|
-
return facts + (facts && catalogue && !facts.endsWith("\n") ? "\n" : "") + catalogue;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export interface AppendResult {
|
|
241
|
-
duplicate: boolean;
|
|
242
|
-
line: string;
|
|
243
|
-
existing?: MemoryEntry[];
|
|
244
|
-
id?: MemoId;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** Append a fact line with a metadata comment. Refuses duplicates. */
|
|
248
|
-
export async function appendEntry(store: StorePaths, content: string): Promise<AppendResult> {
|
|
249
|
-
await mkdir(store.dir, { recursive: true });
|
|
250
|
-
const existing = parseEntries(await readCatalogue(store));
|
|
251
|
-
const dups = findDuplicates(existing, content);
|
|
252
|
-
if (dups.length > 0) {
|
|
253
|
-
return { duplicate: true, line: "", existing: dups };
|
|
254
|
-
}
|
|
255
|
-
const line = content.replace(/\s+/g, " ").trim();
|
|
256
|
-
if (line.length === 0) return { duplicate: false, line: "" };
|
|
257
|
-
const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
258
|
-
const id = parseMemoId(randomUUID().slice(0, 8))!;
|
|
259
|
-
const meta = `<!-- ${stamp} [${id}] -->`;
|
|
260
|
-
const chunk = `\n${meta}\n${line}\n`;
|
|
261
|
-
await writeFile(store.memoryFile, chunk, { encoding: "utf8", flag: "a" });
|
|
262
|
-
return { duplicate: false, line, id };
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
export interface ForgetResult {
|
|
266
|
-
removed: MemoryEntry[];
|
|
267
|
-
recoveryId: string | null;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** Remove entries whose line or meta contains the query (case-insensitive). */
|
|
271
|
-
export async function forgetEntries(
|
|
272
|
-
store: StorePaths, query: string,
|
|
273
|
-
queue: MutationQueue = async (_path, operation) => operation(),
|
|
274
|
-
): Promise<ForgetResult> {
|
|
275
|
-
const text = await readMemoryText(store);
|
|
276
|
-
const entries = parseEntries(text);
|
|
277
|
-
const q = query.toLowerCase();
|
|
278
|
-
if (!q.trim()) return { removed: [], recoveryId: null };
|
|
279
|
-
const removed: MemoryEntry[] = [];
|
|
280
|
-
const kept: MemoryEntry[] = [];
|
|
281
|
-
const notes = (await readNotes(store)).filter((note) =>
|
|
282
|
-
note.entry.line.toLowerCase().includes(q) || note.entry.meta!.toLowerCase().includes(q));
|
|
283
|
-
for (const e of entries) {
|
|
284
|
-
if (e.line.toLowerCase().includes(q) || (e.meta ?? "").toLowerCase().includes(q)) {
|
|
285
|
-
removed.push(e);
|
|
286
|
-
} else {
|
|
287
|
-
kept.push(e);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
const indexed = new Set(removed.map(noteReference));
|
|
291
|
-
removed.push(...notes.filter((note) => !indexed.has(note.rel)).map((note) => note.entry));
|
|
292
|
-
if (removed.length === 0) return { removed, recoveryId: null };
|
|
293
|
-
// Share ordinary edit/write queues: capture the latest body and delete it while
|
|
294
|
-
// holding every selected note lock, always in deterministic path order.
|
|
295
|
-
const locked = [...notes].sort((a, b) => a.path.localeCompare(b.path));
|
|
296
|
-
async function forgetLocked(index: number): Promise<ForgetResult> {
|
|
297
|
-
if (index < locked.length) return queue(locked[index].path, () => forgetLocked(index + 1));
|
|
298
|
-
const snapshots = await Promise.all(notes.map(async (note) => {
|
|
299
|
-
if (lstatSync(note.path).isSymbolicLink()) throw new MemoryConflictError("Selected note became a symlink; retry after inspecting it.");
|
|
300
|
-
const text = await readFile(note.path, "utf8");
|
|
301
|
-
const parsed = parseNote(text);
|
|
302
|
-
if (!parsed || parsed.metadata.id !== note.metadata.id) throw new MemoryConflictError("Selected note identity changed; read it again before forgetting it.");
|
|
303
|
-
return { rel: note.rel, text, mode: (await stat(note.path)).mode & 0o777 };
|
|
304
|
-
}));
|
|
305
|
-
const noteRels = new Set(notes.map((note) => note.rel));
|
|
306
|
-
const deletedEntries = [...removed.filter((entry) => !noteRels.has(noteReference(entry) ?? "")),
|
|
307
|
-
...snapshots.map((note) => noteEntry(note.rel, parseNote(note.text)!.metadata))];
|
|
308
|
-
await ensureOwnedDirectory(store, store.recoveryDir, 0o700);
|
|
309
|
-
const id = randomUUID().slice(0, 8);
|
|
310
|
-
const record = { id, scope: store.scope, timestamp: new Date().toISOString(), kind: "forget",
|
|
311
|
-
entries: deletedEntries, notes: snapshots };
|
|
312
|
-
await createFileExclusive(join(store.recoveryDir, `${id}.json`), JSON.stringify(record, null, 2));
|
|
313
|
-
if (entries.some((entry) => removed.includes(entry))) await replaceFile(store.memoryFile, serializeEntries(kept));
|
|
314
|
-
for (const note of notes) await rm(note.path);
|
|
315
|
-
return { removed: deletedEntries, recoveryId: id };
|
|
316
|
-
}
|
|
317
|
-
return forgetLocked(0);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
export type RestoreResult =
|
|
321
|
-
| { status: "restored"; scope: Scope; entries: MemoryEntry[] }
|
|
322
|
-
| { status: "error"; message: string };
|
|
323
|
-
|
|
324
|
-
type MutationQueue = <T>(path: string, operation: () => Promise<T>) => Promise<T>;
|
|
325
|
-
|
|
326
|
-
class InvalidRecoveryRecordError extends Error {
|
|
327
|
-
constructor(message: string) { super(message); this.name = "InvalidRecoveryRecordError"; }
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function isObject(value: unknown): value is Record<string, unknown> {
|
|
331
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function isMemoryEntry(value: unknown): value is MemoryEntry {
|
|
335
|
-
return isObject(value) && typeof value.line === "string" && !/[\r\n]/.test(value.line) &&
|
|
336
|
-
(value.meta === undefined || (typeof value.meta === "string" && !/[\r\n]/.test(value.meta)));
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/** Restore old deletion records or undo targeted updates, without overwriting later changes. */
|
|
340
|
-
export async function restoreEntry(
|
|
341
|
-
stores: Record<Scope, StorePaths>,
|
|
342
|
-
recoveryId: string,
|
|
343
|
-
queue: MutationQueue = async (_path, operation) => operation(),
|
|
344
|
-
scopes: readonly Scope[] = ["global", "local"],
|
|
345
|
-
validateStore?: (store: StorePaths) => string | null,
|
|
346
|
-
prepareStore?: (store: StorePaths) => Promise<void>,
|
|
347
|
-
): Promise<RestoreResult | null> {
|
|
348
|
-
const parsedId = parseMemoId(recoveryId);
|
|
349
|
-
if (!parsedId) return null;
|
|
350
|
-
const matches = scopes.filter((scope) => {
|
|
351
|
-
const file = join(stores[scope].recoveryDir, `${parsedId}.json`);
|
|
352
|
-
assertNoRedirect(stores[scope], file);
|
|
353
|
-
return existsSync(file);
|
|
354
|
-
});
|
|
355
|
-
if (matches.length === 0) return null;
|
|
356
|
-
if (matches.length > 1) return { status: "error", message: "Ambiguous recovery ID across scopes; no changes made." };
|
|
357
|
-
const scope = matches[0];
|
|
358
|
-
const store = stores[scope];
|
|
359
|
-
return queue(store.memoryFile, async () => {
|
|
360
|
-
const error = validateStore?.(store);
|
|
361
|
-
if (error) return { status: "error", message: error };
|
|
362
|
-
const file = join(store.recoveryDir, `${parsedId}.json`);
|
|
363
|
-
assertNoRedirect(store, file);
|
|
364
|
-
// A queued sibling restore may already have consumed this record.
|
|
365
|
-
if (!existsSync(file)) return null;
|
|
366
|
-
const readRecord = async (): Promise<Record<string, unknown>> => {
|
|
367
|
-
assertNoRedirect(store, file);
|
|
368
|
-
let record: unknown;
|
|
369
|
-
try { record = JSON.parse(await readFile(file, "utf8")); }
|
|
370
|
-
catch (cause) { throw new InvalidRecoveryRecordError(`Cannot read recovery record: ${String(cause)}`); }
|
|
371
|
-
if (!isObject(record) || record.id !== parsedId || record.scope !== scope) {
|
|
372
|
-
throw new InvalidRecoveryRecordError("Recovery record ID/scope mismatch or invalid record.");
|
|
373
|
-
}
|
|
374
|
-
return record;
|
|
375
|
-
};
|
|
376
|
-
let record = await readRecord();
|
|
377
|
-
// Preflight every recovered reference before preparation can mutate the store,
|
|
378
|
-
// including legacy deletion records that contain references but no snapshots.
|
|
379
|
-
try {
|
|
380
|
-
if (typeof record.noteRel === "string") recoveryNoteTarget(store, record.noteRel);
|
|
381
|
-
if (Array.isArray(record.entries)) {
|
|
382
|
-
for (const entry of record.entries) {
|
|
383
|
-
const rel = isMemoryEntry(entry) ? noteReference(entry) : null;
|
|
384
|
-
if (rel !== null) recoveryNoteTarget(store, rel);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
if (Array.isArray(record.notes)) {
|
|
388
|
-
for (const note of record.notes) {
|
|
389
|
-
if (isObject(note) && typeof note.rel === "string") recoveryNoteTarget(store, note.rel);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
} catch (cause) { return { status: "error", message: String(cause) }; }
|
|
393
|
-
try { await prepareStore?.(store); }
|
|
394
|
-
catch (cause) { return { status: "error", message: String(cause) }; }
|
|
395
|
-
// Migration may have rewritten the recovery record along with its note paths.
|
|
396
|
-
if (prepareStore) record = await readRecord();
|
|
397
|
-
if (record.kind === "update") {
|
|
398
|
-
if (typeof record.memoId !== "string" || !parseMemoId(record.memoId) ||
|
|
399
|
-
typeof record.before !== "string" || typeof record.after !== "string" ||
|
|
400
|
-
(record.meta !== undefined && typeof record.meta !== "string") ||
|
|
401
|
-
(record.noteRel !== undefined && typeof record.noteRel !== "string")) {
|
|
402
|
-
throw new InvalidRecoveryRecordError("Invalid update recovery record.");
|
|
403
|
-
}
|
|
404
|
-
try { if (typeof record.noteRel === "string") recoveryNoteTarget(store, record.noteRel); }
|
|
405
|
-
catch (cause) { return { status: "error", message: String(cause) }; }
|
|
406
|
-
const memo = await readMemo(stores, record.memoId, [scope]);
|
|
407
|
-
if (memo.status === "error") return memo;
|
|
408
|
-
if (memo.entry.meta !== record.meta || memo.noteRel !== record.noteRel) {
|
|
409
|
-
return { status: "error", message: "Memo identity/reference changed since the update; recovery retained." };
|
|
410
|
-
}
|
|
411
|
-
if (!memo.notePath && (/[\r\n]/.test(record.before) || /[\r\n]/.test(record.after))) {
|
|
412
|
-
throw new InvalidRecoveryRecordError("Invalid fact recovery content.");
|
|
413
|
-
}
|
|
414
|
-
const before = record.before;
|
|
415
|
-
const after = record.after;
|
|
416
|
-
const restore = async (): Promise<RestoreResult> => {
|
|
417
|
-
assertNoRedirect(store, file);
|
|
418
|
-
if (memo.noteRel !== undefined) recoveryNoteTarget(store, memo.noteRel);
|
|
419
|
-
if (memo.notePath) assertNoRedirect(store, memo.notePath);
|
|
420
|
-
const current = memo.notePath ? await readFile(memo.notePath, "utf8") : memo.text.slice(memo.start, memo.end);
|
|
421
|
-
if (current !== after) return { status: "error", message: "Memo changed since the update; refusing to overwrite later edits. Recovery retained." };
|
|
422
|
-
await replaceFile(memo.notePath ?? store.memoryFile, memo.notePath ? before :
|
|
423
|
-
memo.text.slice(0, memo.start) + before + memo.text.slice(memo.end));
|
|
424
|
-
await rm(file);
|
|
425
|
-
return { status: "restored", scope, entries: [memo.notePath ? memo.entry : { ...memo.entry, line: before.replace(/\s+$/, "") }] };
|
|
426
|
-
};
|
|
427
|
-
return memo.notePath ? queue(memo.notePath, restore) : restore();
|
|
428
|
-
}
|
|
429
|
-
if ((record.kind !== undefined && record.kind !== "forget") ||
|
|
430
|
-
!Array.isArray(record.entries) || !record.entries.every(isMemoryEntry)) {
|
|
431
|
-
throw new InvalidRecoveryRecordError("Invalid deletion recovery record.");
|
|
432
|
-
}
|
|
433
|
-
const text = await readMemoryText(store);
|
|
434
|
-
const current = parseEntries(await readCatalogue(store));
|
|
435
|
-
const entries = record.entries;
|
|
436
|
-
const notes: { rel: string; text: string; path: string; mode: number }[] = [];
|
|
437
|
-
if (record.notes !== undefined) {
|
|
438
|
-
if (!Array.isArray(record.notes)) throw new InvalidRecoveryRecordError("Invalid deletion note snapshots.");
|
|
439
|
-
const paths = new Set<string>();
|
|
440
|
-
for (const value of record.notes) {
|
|
441
|
-
if (!isObject(value) || typeof value.rel !== "string" || typeof value.text !== "string" ||
|
|
442
|
-
(value.mode !== undefined && (typeof value.mode !== "number" || !Number.isInteger(value.mode) || value.mode < 0 || value.mode > 0o777))) {
|
|
443
|
-
throw new InvalidRecoveryRecordError("Invalid deletion note snapshot.");
|
|
444
|
-
}
|
|
445
|
-
const path = recoveryNoteTarget(store, value.rel);
|
|
446
|
-
const parsed = parseNote(value.text);
|
|
447
|
-
if (!parsed || paths.has(path) || !entries.some((entry) =>
|
|
448
|
-
noteReference(entry) === value.rel && entryId(entry) === parsed.metadata.id)) {
|
|
449
|
-
throw new InvalidRecoveryRecordError("Missing, duplicate or mismatched recovered note metadata.");
|
|
450
|
-
}
|
|
451
|
-
paths.add(path);
|
|
452
|
-
if (existsSync(path)) return { status: "error", message: "A recovered note path already exists; recovery retained." };
|
|
453
|
-
notes.push({ rel: value.rel, text: value.text, path, mode: typeof value.mode === "number" ? value.mode : 0o600 });
|
|
454
|
-
}
|
|
455
|
-
if (entries.some((entry) => noteReference(entry) && !notes.some((note) => note.rel === noteReference(entry)))) {
|
|
456
|
-
throw new InvalidRecoveryRecordError("Missing recovered note snapshot.");
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
const ids = entries.map(entryId).filter((id) => id !== null);
|
|
460
|
-
if (new Set(ids).size !== ids.length) {
|
|
461
|
-
return { status: "error", message: "Recovery record contains duplicate memo IDs; recovery retained." };
|
|
462
|
-
}
|
|
463
|
-
if (entries.some((entry) => current.some((existing) =>
|
|
464
|
-
(entryId(entry) !== null && entryId(entry) === entryId(existing)) || entry.line === existing.line))) {
|
|
465
|
-
return { status: "error", message: "A recovered entry already exists; refusing to duplicate or overwrite it. Recovery retained." };
|
|
466
|
-
}
|
|
467
|
-
const facts = record.notes === undefined ? entries : entries.filter((entry) => !noteReference(entry));
|
|
468
|
-
const body = `${text}${text && !text.endsWith("\n") ? "\n" : ""}${serializeEntries(facts)}`;
|
|
469
|
-
const created: string[] = [];
|
|
470
|
-
assertNoRedirect(store, file);
|
|
471
|
-
for (const entry of entries) {
|
|
472
|
-
const rel = noteReference(entry);
|
|
473
|
-
if (rel !== null) recoveryNoteTarget(store, rel);
|
|
474
|
-
}
|
|
475
|
-
try {
|
|
476
|
-
for (const note of notes) {
|
|
477
|
-
recoveryNoteTarget(store, note.rel);
|
|
478
|
-
await mkdir(resolve(note.path, ".."), { recursive: true });
|
|
479
|
-
// Check again after directory creation, before exclusive file creation.
|
|
480
|
-
recoveryNoteTarget(store, note.rel);
|
|
481
|
-
await createFileExclusive(note.path, note.text, note.mode);
|
|
482
|
-
created.push(note.path);
|
|
483
|
-
}
|
|
484
|
-
if (facts.length) {
|
|
485
|
-
if (existsSync(store.memoryFile)) await replaceFile(store.memoryFile, body);
|
|
486
|
-
else await writeFile(store.memoryFile, body, { flag: "wx" });
|
|
487
|
-
}
|
|
488
|
-
} catch (error) {
|
|
489
|
-
for (const path of created) await rm(path, { force: true });
|
|
490
|
-
throw error;
|
|
491
|
-
}
|
|
492
|
-
await rm(file);
|
|
493
|
-
return { status: "restored", scope, entries };
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/** Check every existing ancestor, including dangling symlinks, before recovery creates anything. */
|
|
498
|
-
function recoveryNoteTarget(store: StorePaths, rel: string): string {
|
|
499
|
-
if (!/^notes\/.+\.md$/.test(rel) || rel.includes("\\") || /[\x00-\x1f\x7f]/.test(rel) ||
|
|
500
|
-
rel.split("/").some((part) => !part || part === "." || part === "..")) {
|
|
501
|
-
throw new InvalidRecoveryRecordError("Unsafe recovered note path.");
|
|
502
|
-
}
|
|
503
|
-
const path = join(store.dir, rel);
|
|
504
|
-
assertNoRedirect(store, path);
|
|
505
|
-
return path;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
export interface MemoryStat {
|
|
509
|
-
scope: Scope;
|
|
510
|
-
path: string;
|
|
511
|
-
exists: boolean;
|
|
512
|
-
bytes: number;
|
|
513
|
-
entries: number;
|
|
514
|
-
recoveryCount: number;
|
|
515
|
-
notesCount: number;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
export async function statMemory(store: StorePaths): Promise<MemoryStat> {
|
|
519
|
-
const text = await readMemoryText(store);
|
|
520
|
-
let recoveryCount = 0;
|
|
521
|
-
if (existsSync(store.recoveryDir)) {
|
|
522
|
-
recoveryCount = (await readdir(store.recoveryDir)).filter((f) => f.endsWith(".json"))
|
|
523
|
-
.length;
|
|
524
|
-
}
|
|
525
|
-
const notesCount = (await readNotes(store)).length;
|
|
526
|
-
return {
|
|
527
|
-
scope: store.scope,
|
|
528
|
-
path: store.memoryFile,
|
|
529
|
-
exists: existsSync(store.memoryFile) || notesCount > 0,
|
|
530
|
-
bytes: Buffer.byteLength(text, "utf8"),
|
|
531
|
-
entries: parseEntries(text).length,
|
|
532
|
-
recoveryCount,
|
|
533
|
-
notesCount,
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// ---------------------------------------------------------------------------
|
|
538
|
-
// Notes (frontmatter is authoritative; references are derived on demand)
|
|
539
|
-
// ---------------------------------------------------------------------------
|
|
540
|
-
|
|
541
|
-
export function slugifyTitle(title: string): string {
|
|
542
|
-
return (
|
|
543
|
-
title
|
|
544
|
-
.toLowerCase()
|
|
545
|
-
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
546
|
-
.slice(0, 48)
|
|
547
|
-
.replace(/^-+|-+$/g, "") || "note"
|
|
548
|
-
);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
export interface NoteResult {
|
|
552
|
-
indexLine: string;
|
|
553
|
-
notePath?: string;
|
|
554
|
-
noteRel?: string;
|
|
555
|
-
duplicate: boolean;
|
|
556
|
-
existing?: MemoryEntry[];
|
|
557
|
-
id?: MemoId;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
/** Model-chosen basename only; the UTC creation date is always prepended. */
|
|
561
|
-
export function validNoteSlug(slug: string): boolean {
|
|
562
|
-
return slug.length <= 80 && /^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*$/u.test(slug);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/** Write a standalone frontmatter note; never append its metadata to MEMORY.md. */
|
|
566
|
-
export async function writeNote(
|
|
567
|
-
store: StorePaths,
|
|
568
|
-
title: string,
|
|
569
|
-
content: string,
|
|
570
|
-
slug?: string,
|
|
571
|
-
): Promise<NoteResult> {
|
|
572
|
-
if (slug !== undefined && !validNoteSlug(slug)) throw new InvalidNoteSlugError();
|
|
573
|
-
const existingEntries = parseEntries(await readCatalogue(store));
|
|
574
|
-
const duplicates = findDuplicates(existingEntries, `#note [[notes/new.md]] ${title.trim()}`);
|
|
575
|
-
if (duplicates.length > 0) {
|
|
576
|
-
const existing = duplicates[0];
|
|
577
|
-
const noteRel = noteReference(existing) ?? undefined;
|
|
578
|
-
return { duplicate: true, existing: duplicates, indexLine: existing.line, noteRel, id: entryId(existing) ?? undefined };
|
|
579
|
-
}
|
|
580
|
-
await ensureOwnedDirectory(store, store.notesDir, 0o700);
|
|
581
|
-
const stamp = new Date().toISOString();
|
|
582
|
-
const base = `${stamp.slice(0, 10)}-${slug ?? slugifyTitle(title)}`;
|
|
583
|
-
const ids = new Set(existingEntries.map(entryId));
|
|
584
|
-
let id: MemoId;
|
|
585
|
-
do { id = parseMemoId(randomUUID().slice(0, 8))!; } while (ids.has(id));
|
|
586
|
-
const metadata: NoteMetadata = { description: title.trim(), id, created: stamp, modified: stamp };
|
|
587
|
-
const body = serializeNote(metadata, `${content.trim()}\n`);
|
|
588
|
-
for (let suffix = 1; ; suffix++) {
|
|
589
|
-
const rel = `notes/${base}${suffix === 1 ? "" : `-${suffix}`}.md`;
|
|
590
|
-
const abs = join(store.dir, rel);
|
|
591
|
-
try { await createFileExclusive(abs, body); }
|
|
592
|
-
catch (error) {
|
|
593
|
-
if (isObject(error) && error.code === "EEXIST") continue;
|
|
594
|
-
throw error;
|
|
595
|
-
}
|
|
596
|
-
return { indexLine: noteEntry(rel, metadata).line, notePath: abs, noteRel: rel, duplicate: false, id };
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
class MemoryConflictError extends Error {
|
|
601
|
-
constructor(message: string) { super(message); this.name = "MemoryConflictError"; }
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
class InvalidNoteSlugError extends Error {
|
|
605
|
-
constructor() { super("Invalid note slug: use 1–80 letters/digits separated by hyphens, without a date, extension or path."); this.name = "InvalidNoteSlugError"; }
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
export type NoteResolution =
|
|
609
|
-
| { status: "found"; store: StorePaths; absPath: string; rel: string }
|
|
610
|
-
| { status: "error"; message: string };
|
|
611
|
-
|
|
612
|
-
/** Resolve existing scoped paths; reject traversal and symlinks outside a store. */
|
|
613
|
-
export function resolveNotePath(
|
|
614
|
-
stores: Record<Scope, StorePaths>,
|
|
615
|
-
input: string,
|
|
616
|
-
scopes: Scope[] = ["global", "local"],
|
|
617
|
-
): NoteResolution {
|
|
618
|
-
const matches: Extract<NoteResolution, { status: "found" }>[] = [];
|
|
619
|
-
for (const scope of scopes) {
|
|
620
|
-
const store = stores[scope];
|
|
621
|
-
const dir = resolve(store.dir);
|
|
622
|
-
const abs = isAbsolute(input) ? resolve(input) : resolve(dir, input);
|
|
623
|
-
if (!abs.startsWith(dir + sep) || !existsSync(abs)) continue;
|
|
624
|
-
const actual = realpathSync(abs);
|
|
625
|
-
if (!actual.startsWith(realpathSync(dir) + sep) || !statSync(actual).isFile()) continue;
|
|
626
|
-
matches.push({ status: "found", store, absPath: actual, rel: relative(dir, abs) });
|
|
627
|
-
}
|
|
628
|
-
if (matches.length === 1) return matches[0];
|
|
629
|
-
return { status: "error", message: matches.length > 1
|
|
630
|
-
? `Ambiguous note path: ${input}. Specify scope 'local' or 'global'.`
|
|
631
|
-
: `Note not found or outside the selected memory scope(s): ${input}.` };
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
export type MemoLookup =
|
|
635
|
-
| { status: "found"; store: StorePaths; entry: MemoryEntry; text: string; start: number; end: number;
|
|
636
|
-
noteRel?: string; notePath?: string; noteText?: string }
|
|
637
|
-
| { status: "error"; message: string };
|
|
638
|
-
|
|
639
|
-
/** Exact metadata ID lookup. Existing notes retain their index entry's ID. */
|
|
640
|
-
export async function readMemo(
|
|
641
|
-
stores: Record<Scope, StorePaths>,
|
|
642
|
-
input: string,
|
|
643
|
-
scopes: Scope[] = ["global", "local"],
|
|
644
|
-
): Promise<MemoLookup> {
|
|
645
|
-
const id = parseMemoId(input);
|
|
646
|
-
if (!id) return { status: "error", message: "Invalid memo ID: expected exactly eight hexadecimal characters." };
|
|
647
|
-
const matches: Extract<MemoLookup, { status: "found" }>[] = [];
|
|
648
|
-
for (const scope of scopes) {
|
|
649
|
-
const store = stores[scope];
|
|
650
|
-
const text = await readMemoryText(store);
|
|
651
|
-
const spans = entrySpans(text);
|
|
652
|
-
for (const span of spans) {
|
|
653
|
-
if (entryId(span.entry) === id) matches.push({ status: "found", store, text, ...span });
|
|
654
|
-
}
|
|
655
|
-
const refs = new Set(spans.map((span) => noteReference(span.entry)));
|
|
656
|
-
for (const note of await readNotes(store)) {
|
|
657
|
-
if (note.metadata.id === id && !refs.has(note.rel)) matches.push({ status: "found", store, entry: note.entry,
|
|
658
|
-
text, start: 0, end: 0, noteRel: note.rel, notePath: note.path, noteText: await readFile(note.path, "utf8") });
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
if (matches.length !== 1) return { status: "error", message: matches.length === 0
|
|
662
|
-
? `Memo not found: ${id}.`
|
|
663
|
-
: `Ambiguous memo ID: ${id}. Specify scope; duplicate IDs within a scope must be resolved manually.` };
|
|
664
|
-
const memo = matches[0];
|
|
665
|
-
if (memo.notePath) return memo;
|
|
666
|
-
const noteRel = noteReference(memo.entry);
|
|
667
|
-
if (noteRel === null) return memo;
|
|
668
|
-
if (!/^notes\/.+\.md$/.test(noteRel)) return { status: "error", message: `Unsafe note reference for memo ${id}.` };
|
|
669
|
-
const resolved = resolveNotePath(stores, noteRel, [memo.store.scope]);
|
|
670
|
-
if (resolved.status === "error") return resolved;
|
|
671
|
-
if (!resolved.absPath.startsWith(realpathSync(memo.store.notesDir) + sep) ||
|
|
672
|
-
(existsSync(memo.store.memoryFile) && resolved.absPath === realpathSync(memo.store.memoryFile))) {
|
|
673
|
-
return { status: "error", message: `Note reference for memo ${id} points outside the notes directory.` };
|
|
674
|
-
}
|
|
675
|
-
return { ...memo, noteRel, notePath: resolved.absPath, noteText: await readFile(resolved.absPath, "utf8") };
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
export type UpdateResult =
|
|
679
|
-
| { status: "updated"; entry: MemoryEntry; recoveryId: string }
|
|
680
|
-
| { status: "unchanged"; entry: MemoryEntry }
|
|
681
|
-
| { status: "error"; message: string };
|
|
682
|
-
|
|
683
|
-
/** Called under the scope's memory-file mutation queue by the extension. */
|
|
684
|
-
export async function updateMemo(
|
|
685
|
-
store: StorePaths, id: string, content: string,
|
|
686
|
-
queue: MutationQueue = async (_path, operation) => operation(),
|
|
687
|
-
): Promise<UpdateResult> {
|
|
688
|
-
if (!content.trim()) return { status: "error", message: "Empty content — nothing updated." };
|
|
689
|
-
const memo = await readMemo({ local: store, global: store }, id, [store.scope]);
|
|
690
|
-
if (memo.status === "error") return memo;
|
|
691
|
-
let before: string;
|
|
692
|
-
let after: string;
|
|
693
|
-
if (memo.noteText !== undefined) {
|
|
694
|
-
before = memo.noteText;
|
|
695
|
-
const parsed = parseNote(before);
|
|
696
|
-
if (parsed) {
|
|
697
|
-
if (parsed.body.trim() === content.trim()) return { status: "unchanged", entry: memo.entry };
|
|
698
|
-
after = serializeNote({ ...parsed.metadata, modified: new Date().toISOString() }, `${content.trim()}\n`);
|
|
699
|
-
} else {
|
|
700
|
-
// Legacy low-level callers remain supported; extension entrypoints migrate first.
|
|
701
|
-
const prefix = before.match(/^(?:<!-- pi-remember note: [^\r\n]* -->\r?\n)?# [^\r\n]*\r?\n(?:\r?\n)?/)?.[0] ?? "";
|
|
702
|
-
if (before.slice(prefix.length).trim() === content.trim()) return { status: "unchanged", entry: memo.entry };
|
|
703
|
-
after = `${prefix}${content.trim()}\n`;
|
|
704
|
-
}
|
|
705
|
-
} else {
|
|
706
|
-
after = content.replace(/\s+/g, " ").trim();
|
|
707
|
-
if (after.length > 300) return { status: "error", message: "Fact exceeds 300 characters. Use a detailed note instead." };
|
|
708
|
-
if (noteReference({ line: after })) return { status: "error", message: "Cannot turn a fact into a note reference using memory_update." };
|
|
709
|
-
if (after.startsWith("<!--") && after.endsWith("-->")) {
|
|
710
|
-
return { status: "error", message: "A fact cannot be replaced with a metadata comment." };
|
|
711
|
-
}
|
|
712
|
-
before = memo.text.slice(memo.start, memo.end);
|
|
713
|
-
if (memo.entry.line === after) return { status: "unchanged", entry: memo.entry };
|
|
714
|
-
}
|
|
715
|
-
const apply = async (): Promise<UpdateResult> => {
|
|
716
|
-
// Ordinary file edits use this queue too. Do not overwrite a newer note body.
|
|
717
|
-
if (memo.notePath && await readFile(memo.notePath, "utf8") !== before) {
|
|
718
|
-
return { status: "error", message: "Note changed while preparing the update; read it again before retrying." };
|
|
719
|
-
}
|
|
720
|
-
const recoveryId = randomUUID().slice(0, 8);
|
|
721
|
-
await ensureOwnedDirectory(store, store.recoveryDir, 0o700);
|
|
722
|
-
const record = { kind: "update", id: recoveryId, scope: store.scope, timestamp: new Date().toISOString(),
|
|
723
|
-
memoId: parseMemoId(id), before, after, meta: memo.entry.meta, noteRel: memo.noteRel };
|
|
724
|
-
await createFileExclusive(join(store.recoveryDir, `${recoveryId}.json`), JSON.stringify(record, null, 2));
|
|
725
|
-
await replaceFile(memo.notePath ?? store.memoryFile, memo.notePath ? after :
|
|
726
|
-
memo.text.slice(0, memo.start) + after + memo.text.slice(memo.end));
|
|
727
|
-
return { status: "updated", entry: memo.notePath ? memo.entry : { ...memo.entry, line: after }, recoveryId };
|
|
728
|
-
};
|
|
729
|
-
return memo.notePath ? queue(memo.notePath, apply) : apply();
|
|
730
|
-
}
|