claude-memory-admin 1.0.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/src/model.mjs ADDED
@@ -0,0 +1,241 @@
1
+ // Builds the full view model for one project: its index, its memory files, the
2
+ // wikilink graph between them, and the consistency problems worth surfacing.
3
+ //
4
+ // The whole store is a few hundred kilobytes, so this runs per request. No
5
+ // cache, no watcher, no invalidation bugs.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { parseIndex, parseFrontmatter, extractWikilinks } from './parse.mjs';
10
+ import { listMemoryFiles, memoryDir, resolveProjectPath, shortLabel } from './projects.mjs';
11
+ import { ageInDays, estimateTokens, findDuplicates, indexStats } from './stats.mjs';
12
+
13
+ export const TRASH_DIR = '.trash';
14
+
15
+ function readIfExists(file) {
16
+ try {
17
+ return fs.readFileSync(file, 'utf8');
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ /** Parse one memory file into its metadata, body and outbound wikilinks. */
24
+ export function loadMemory(dir, file) {
25
+ const raw = readIfExists(path.join(dir, file));
26
+ if (raw === null) return null;
27
+
28
+ const { data, body, hasFrontmatter } = parseFrontmatter(raw);
29
+
30
+ // Two frontmatter shapes exist in the wild: most files nest everything under
31
+ // `metadata:`, but some write `type:`/`originSessionId:` at the root instead.
32
+ // Root-level extras are merged in first so a nested block still wins.
33
+ const nested = (data.metadata && typeof data.metadata === 'object') ? data.metadata : {};
34
+ const rootExtras = {};
35
+ for (const [key, value] of Object.entries(data)) {
36
+ if (key === 'name' || key === 'description' || key === 'metadata') continue;
37
+ if (value && typeof value === 'object') continue;
38
+ rootExtras[key] = value;
39
+ }
40
+ const metadata = { ...rootExtras, ...nested };
41
+ const stem = file.replace(/\.md$/, '');
42
+ const name = typeof data.name === 'string' && data.name ? data.name : stem;
43
+
44
+ let stat = null;
45
+ try {
46
+ stat = fs.statSync(path.join(dir, file));
47
+ } catch { /* raced with a delete */ }
48
+
49
+ return {
50
+ file,
51
+ stem,
52
+ name,
53
+ description: typeof data.description === 'string' ? data.description : '',
54
+ type: metadata.type || 'unknown',
55
+ metadata,
56
+ hasFrontmatter,
57
+ // `name` and the filename usually agree but not always, so both are kept
58
+ // and the mismatch is reported under health.
59
+ nameMatchesFile: name === stem,
60
+ body,
61
+ raw,
62
+ bytes: stat ? stat.size : raw.length,
63
+ modified: metadata.modified || (stat ? new Date(stat.mtimeMs).toISOString() : null),
64
+ tokens: estimateTokens(raw),
65
+ outbound: extractWikilinks(body).map((w) => w.target),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Resolve a wikilink target to a memory file. Targets normally match a `name`,
71
+ * but fall back to the filename stem, which is what the mismatching files need.
72
+ */
73
+ function buildResolver(memories) {
74
+ const byName = new Map();
75
+ const byStem = new Map();
76
+ for (const memory of memories) {
77
+ if (!byName.has(memory.name)) byName.set(memory.name, memory.file);
78
+ if (!byStem.has(memory.stem)) byStem.set(memory.stem, memory.file);
79
+ }
80
+ return (target) => byName.get(target) || byStem.get(target) || null;
81
+ }
82
+
83
+ export function buildProject(root, slug) {
84
+ const dir = memoryDir(root, slug);
85
+ const projectDir = path.join(root, slug);
86
+ const resolved = resolveProjectPath(projectDir, slug);
87
+ const hasMemoryDir = fs.existsSync(dir);
88
+
89
+ const indexRaw = readIfExists(path.join(dir, 'MEMORY.md'));
90
+ const index = indexRaw === null ? null : parseIndex(indexRaw);
91
+ const files = hasMemoryDir ? listMemoryFiles(dir) : [];
92
+ const memories = files.map((file) => loadMemory(dir, file)).filter(Boolean);
93
+
94
+ const indexedFiles = index ? index.indexedFiles : new Set();
95
+ const referencedFiles = index ? index.referencedFiles : new Set();
96
+ const resolve = buildResolver(memories);
97
+
98
+ // Outbound edges first, then inbound derived from them, so the two can never
99
+ // disagree.
100
+ const inbound = new Map(memories.map((m) => [m.file, []]));
101
+ const danglingWikilinks = [];
102
+ const edges = [];
103
+
104
+ for (const memory of memories) {
105
+ memory.outboundResolved = [];
106
+ for (const target of memory.outbound) {
107
+ const targetFile = resolve(target);
108
+ if (targetFile && targetFile !== memory.file) {
109
+ memory.outboundResolved.push({ target, file: targetFile });
110
+ edges.push({ from: memory.file, to: targetFile });
111
+ inbound.get(targetFile).push({ from: memory.file, target });
112
+ } else if (!targetFile) {
113
+ memory.outboundResolved.push({ target, file: null });
114
+ danglingWikilinks.push({ from: memory.file, fromName: memory.name, target });
115
+ }
116
+ }
117
+ }
118
+
119
+ for (const memory of memories) {
120
+ memory.ageDays = ageInDays(memory.modified);
121
+ memory.inbound = inbound.get(memory.file) || [];
122
+ memory.entry = index ? index.entries.find((e) => e.file === memory.file) || null : null;
123
+ memory.section = memory.entry ? memory.entry.section : null;
124
+ memory.status = indexedFiles.has(memory.file)
125
+ ? 'indexed'
126
+ : referencedFiles.has(memory.file)
127
+ ? 'referenced'
128
+ : 'orphan';
129
+ }
130
+
131
+ const existingFiles = new Set(files);
132
+ const danglingIndex = index
133
+ ? index.links
134
+ .filter((l) => !existingFiles.has(l.file))
135
+ .map((l) => ({ index: l.index, file: l.file, label: l.label, text: l.text }))
136
+ : [];
137
+
138
+ const health = {
139
+ orphans: memories.filter((m) => m.status === 'orphan').map((m) => m.file),
140
+ referencedOnly: memories.filter((m) => m.status === 'referenced').map((m) => m.file),
141
+ danglingIndex,
142
+ danglingWikilinks,
143
+ nameMismatches: memories
144
+ .filter((m) => !m.nameMatchesFile)
145
+ .map((m) => ({ file: m.file, name: m.name })),
146
+ missingFrontmatter: memories.filter((m) => !m.hasFrontmatter).map((m) => m.file),
147
+ longHooks: index ? indexStats(indexRaw, index.entries).longHooks : [],
148
+ };
149
+ // Must count every category the Health tab renders, or the badge disagrees
150
+ // with the list underneath it.
151
+ // One flat list is the single source of truth for both the badge and the tab.
152
+ // Keeping a separate count in sync with what the UI renders failed twice: a
153
+ // category counted but not rendered shows a badge over an empty tab.
154
+ health.issues = [
155
+ ...health.danglingIndex.map((entry) => ({ kind: 'dangling-index', severity: 'bad', entry })),
156
+ ...health.danglingWikilinks.map((link) => ({ kind: 'dangling-wikilink', severity: 'bad', link })),
157
+ ...health.orphans.map((file) => ({ kind: 'orphan', severity: 'warn', file })),
158
+ ...health.referencedOnly.map((file) => ({ kind: 'referenced-only', severity: 'warn', file })),
159
+ ...health.nameMismatches.map((mismatch) => ({ kind: 'name-mismatch', severity: 'warn', mismatch })),
160
+ ...health.missingFrontmatter.map((file) => ({ kind: 'missing-frontmatter', severity: 'warn', file })),
161
+ ...(health.longHooks.length
162
+ ? [{ kind: 'long-hooks', severity: 'warn', count: health.longHooks.length, longest: health.longHooks[0] }]
163
+ : []),
164
+ ];
165
+ health.issueCount = health.issues.length;
166
+
167
+ return {
168
+ slug,
169
+ path: resolved.path,
170
+ label: shortLabel(resolved.path),
171
+ resolvedBy: resolved.resolvedBy,
172
+ hasMemoryDir,
173
+ hasIndex: index !== null,
174
+ index: index
175
+ ? { raw: indexRaw, lines: index.parsedLines, entries: index.entries, inlineLinks: index.inlineLinks }
176
+ : null,
177
+ memories: memories.map(({ raw, ...rest }) => rest),
178
+ graph: {
179
+ nodes: memories.map((m) => ({
180
+ id: m.file,
181
+ label: m.name,
182
+ type: m.type,
183
+ status: m.status,
184
+ degree: m.inbound.length + m.outboundResolved.filter((o) => o.file).length,
185
+ })),
186
+ edges,
187
+ dangling: danglingWikilinks,
188
+ },
189
+ health,
190
+ stats: {
191
+ index: indexStats(indexRaw, index ? index.entries : []),
192
+ memoryBytes: memories.reduce((sum, m) => sum + m.bytes, 0),
193
+ memoryTokens: memories.reduce((sum, m) => sum + m.tokens, 0),
194
+ },
195
+ duplicates: findDuplicates(memories),
196
+ trash: listTrash(dir),
197
+ };
198
+ }
199
+
200
+ /** Restore records left behind by soft deletes, newest first. */
201
+ export function listTrash(dir) {
202
+ const trashPath = path.join(dir, TRASH_DIR);
203
+ let entries;
204
+ try {
205
+ entries = fs.readdirSync(trashPath);
206
+ } catch {
207
+ return [];
208
+ }
209
+ return entries
210
+ .filter((name) => name.endsWith('.restore.json'))
211
+ .map((name) => {
212
+ const record = readIfExists(path.join(trashPath, name));
213
+ if (!record) return null;
214
+ try {
215
+ const parsed = JSON.parse(record);
216
+ // Records written before batching had a single trashedFile at the root.
217
+ const entries = parsed.files?.length
218
+ ? parsed.files
219
+ : parsed.trashedFile
220
+ ? [{ file: parsed.memoryFile, trashedFile: parsed.trashedFile }]
221
+ : [];
222
+ const backups = [
223
+ ...entries.map((e) => e.trashedFile),
224
+ parsed.indexTrashedFile,
225
+ parsed.backupFile,
226
+ ].filter(Boolean);
227
+ return {
228
+ ...parsed,
229
+ kind: parsed.kind || 'memories',
230
+ files: entries,
231
+ label: parsed.label || parsed.name || parsed.memoryFile || parsed.id,
232
+ recordFile: name,
233
+ present: backups.length > 0 && backups.every((f) => fs.existsSync(path.join(trashPath, f))),
234
+ };
235
+ } catch {
236
+ return null;
237
+ }
238
+ })
239
+ .filter(Boolean)
240
+ .sort((a, b) => String(b.deletedAt).localeCompare(String(a.deletedAt)));
241
+ }
package/src/mutate.mjs ADDED
@@ -0,0 +1,414 @@
1
+ // The only code in the app that writes. Everything else is read-only.
2
+ //
3
+ // Deletes are soft: the file moves into memory/.trash/ and a restore record
4
+ // captures the MEMORY.md lines that were removed, with their original indices,
5
+ // so the whole operation can be undone.
6
+
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { parseIndex, removeIndexEntries, removeLine, insertLines, unwrapWikilink } from './parse.mjs';
11
+ import { listMemoryFiles, memoryDir } from './projects.mjs';
12
+ import { TRASH_DIR, loadMemory } from './model.mjs';
13
+
14
+ function sha256(text) {
15
+ return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
16
+ }
17
+
18
+ function timestamp() {
19
+ return new Date().toISOString().replace(/[:.]/g, '-').replace(/Z$/, '');
20
+ }
21
+
22
+ /**
23
+ * Reject anything that is not a plain filename inside this project's memory dir.
24
+ * Checked before touching the filesystem, and again via realpath after, so a
25
+ * symlink cannot be used to escape.
26
+ */
27
+ export function safeMemoryPath(dir, file) {
28
+ if (typeof file !== 'string' || !file || file.includes('\0')) {
29
+ throw new Error('Invalid filename');
30
+ }
31
+ if (file !== path.basename(file) || file.startsWith('.') || !file.endsWith('.md')) {
32
+ throw new Error(`Refusing to touch "${file}": must be a plain .md filename`);
33
+ }
34
+ const full = path.join(dir, file);
35
+ const rel = path.relative(dir, full);
36
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
37
+ throw new Error('Path escapes the memory directory');
38
+ }
39
+ if (fs.existsSync(full)) {
40
+ const realDir = fs.realpathSync(dir);
41
+ const realFull = fs.realpathSync(full);
42
+ if (path.relative(realDir, realFull).startsWith('..')) {
43
+ throw new Error('Path escapes the memory directory');
44
+ }
45
+ }
46
+ return full;
47
+ }
48
+
49
+ function indexPath(dir) {
50
+ return path.join(dir, 'MEMORY.md');
51
+ }
52
+
53
+ function readIndex(dir) {
54
+ try {
55
+ return fs.readFileSync(indexPath(dir), 'utf8');
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Replace MEMORY.md atomically: write a sibling temp file, fsync it, rename over
63
+ * the target. A .backup copy is kept for the duration and restored if anything
64
+ * throws, so a crash can never leave a truncated index.
65
+ */
66
+ function writeFileAtomic(dir, name, text) {
67
+ const target = path.join(dir, name);
68
+ const tmp = path.join(dir, `.${name}.tmp-${process.pid}`);
69
+ const backup = path.join(dir, `.${name}.backup`);
70
+ const original = fs.existsSync(target) ? fs.readFileSync(target) : null;
71
+
72
+ if (original !== null) fs.writeFileSync(backup, original);
73
+ try {
74
+ const fd = fs.openSync(tmp, 'w');
75
+ try {
76
+ fs.writeFileSync(fd, text, 'utf8');
77
+ fs.fsyncSync(fd);
78
+ } finally {
79
+ fs.closeSync(fd);
80
+ }
81
+ fs.renameSync(tmp, target);
82
+ } catch (err) {
83
+ if (original !== null) fs.writeFileSync(target, original);
84
+ try { fs.unlinkSync(tmp); } catch { /* already gone */ }
85
+ throw err;
86
+ } finally {
87
+ try { if (fs.existsSync(backup)) fs.unlinkSync(backup); } catch { /* best effort */ }
88
+ }
89
+ }
90
+
91
+ const writeIndexAtomic = (dir, text) => writeFileAtomic(dir, 'MEMORY.md', text);
92
+
93
+ /**
94
+ * What a delete would do, without doing it. This is what the confirm dialog
95
+ * renders, so it has to be exhaustive about the collateral.
96
+ */
97
+ export function deletePreview(root, slug, file) {
98
+ const dir = memoryDir(root, slug);
99
+ const full = safeMemoryPath(dir, file);
100
+ const exists = fs.existsSync(full);
101
+ const indexText = readIndex(dir);
102
+ const parsed = indexText === null ? null : parseIndex(indexText);
103
+
104
+ const indexLines = parsed
105
+ ? parsed.entries.filter((e) => e.file === file).map((e) => ({ index: e.index, text: e.text }))
106
+ : [];
107
+
108
+ // Continuation lines get removed along with their bullet.
109
+ const continuations = [];
110
+ if (parsed) {
111
+ for (const entry of indexLines) {
112
+ for (let i = entry.index + 1; i < parsed.lines.length; i++) {
113
+ const line = parsed.lines[i];
114
+ if (!line.trim() || !/^[ \t]/.test(line)) break;
115
+ continuations.push({ index: i, text: line });
116
+ }
117
+ }
118
+ }
119
+
120
+ // Links inside prose are deliberately left alone: cutting them out would
121
+ // mangle the sentence around them. They are reported so they can be fixed
122
+ // by hand.
123
+ const inlineRefs = parsed
124
+ ? parsed.inlineLinks.filter((l) => l.file === file).map((l) => ({ index: l.index, text: l.text }))
125
+ : [];
126
+
127
+ const target = exists ? loadMemory(dir, file) : null;
128
+ const inboundWikilinks = [];
129
+ if (target) {
130
+ const dirFiles = fs.readdirSync(dir).filter((f) => f.endsWith('.md') && f !== 'MEMORY.md' && f !== file);
131
+ for (const other of dirFiles) {
132
+ const memory = loadMemory(dir, other);
133
+ if (!memory) continue;
134
+ for (const outbound of memory.outbound) {
135
+ if (outbound === target.name || outbound === target.stem) {
136
+ const entry = parsed ? parsed.entries.find((e) => e.file === other) : null;
137
+ inboundWikilinks.push({
138
+ from: other,
139
+ fromName: memory.name,
140
+ description: memory.description,
141
+ target: outbound,
142
+ indexLine: entry ? { index: entry.index, text: entry.text } : null,
143
+ });
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ return {
150
+ file,
151
+ exists,
152
+ name: target ? target.name : null,
153
+ description: target ? target.description : null,
154
+ indexLines,
155
+ continuations,
156
+ inlineRefs,
157
+ inboundWikilinks,
158
+ hasIndex: indexText !== null,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Trash one or more memories and drop their index bullets as ONE undoable
164
+ * operation. Cascading deletes (a memory plus the memories linking to it) and
165
+ * clearing a whole project both come through here, so there is a single restore
166
+ * path rather than three.
167
+ */
168
+ export function deleteMemories(root, slug, files, { includeIndex = false, label = null } = {}) {
169
+ const dir = memoryDir(root, slug);
170
+ const wanted = [...new Set([].concat(files))];
171
+ if (!wanted.length) throw new Error('Nothing selected to delete');
172
+
173
+ const targets = wanted.map((file) => {
174
+ const full = safeMemoryPath(dir, file);
175
+ if (!fs.existsSync(full)) throw new Error(`No such memory: ${file}`);
176
+ const memory = loadMemory(dir, file);
177
+ return { file, full, name: memory?.name || file, description: memory?.description || '' };
178
+ });
179
+
180
+ const trashPath = path.join(dir, TRASH_DIR);
181
+ fs.mkdirSync(trashPath, { recursive: true });
182
+ const stamp = timestamp();
183
+ const indexBefore = readIndex(dir);
184
+
185
+ // All index lines are computed against the original text in one pass, so the
186
+ // recorded indices still describe the file we started from.
187
+ let removed = [];
188
+ let indexAfter = indexBefore;
189
+ if (indexBefore !== null && !includeIndex) {
190
+ const result = removeIndexEntries(indexBefore, wanted);
191
+ removed = result.removed;
192
+ indexAfter = result.text;
193
+ }
194
+
195
+ // Files move first: if a later step fails they are recoverable from .trash
196
+ // rather than lost, and the moves already made are rolled back.
197
+ const moved = [];
198
+ try {
199
+ for (const target of targets) {
200
+ const trashedFile = `${stamp}_${target.file}`;
201
+ fs.renameSync(target.full, path.join(trashPath, trashedFile));
202
+ moved.push({ ...target, trashedFile });
203
+ }
204
+ if (includeIndex && indexBefore !== null) {
205
+ const trashedIndex = `${stamp}_MEMORY.md`;
206
+ fs.renameSync(indexPath(dir), path.join(trashPath, trashedIndex));
207
+ moved.indexTrashed = trashedIndex;
208
+ } else if (indexBefore !== null && indexAfter !== indexBefore) {
209
+ writeIndexAtomic(dir, indexAfter);
210
+ }
211
+ } catch (err) {
212
+ for (const entry of moved) {
213
+ try { fs.renameSync(path.join(trashPath, entry.trashedFile), entry.full); } catch { /* best effort */ }
214
+ }
215
+ throw err;
216
+ }
217
+
218
+ const record = {
219
+ version: 2,
220
+ kind: includeIndex ? 'project' : 'memories',
221
+ id: `${stamp}_${includeIndex ? 'project' : targets[0].file}`,
222
+ label: label || (targets.length === 1 ? targets[0].name : `${targets.length} memories`),
223
+ deletedAt: new Date().toISOString(),
224
+ slug,
225
+ files: moved.map(({ file, trashedFile, name, description }) => ({ file, trashedFile, name, description })),
226
+ indexTrashedFile: moved.indexTrashed || null,
227
+ removedLines: removed,
228
+ indexSha256Before: indexBefore === null ? null : sha256(indexBefore),
229
+ indexSha256After: indexAfter === null || includeIndex ? null : sha256(indexAfter),
230
+ };
231
+ fs.writeFileSync(path.join(trashPath, `${record.id}.restore.json`), JSON.stringify(record, null, 2));
232
+ return { deleted: true, record };
233
+ }
234
+
235
+ /** Single-memory delete, optionally cascading to the memories that link to it. */
236
+ export function deleteMemory(root, slug, file, alsoDelete = []) {
237
+ const extra = [].concat(alsoDelete).filter((f) => f && f !== file);
238
+ return deleteMemories(root, slug, [file, ...extra]);
239
+ }
240
+
241
+ /** What clearing a whole project would remove. */
242
+ export function projectDeletePreview(root, slug) {
243
+ const dir = memoryDir(root, slug);
244
+ const files = listMemoryFiles(dir);
245
+ const indexText = readIndex(dir);
246
+ return {
247
+ slug,
248
+ files: files.map((file) => {
249
+ const memory = loadMemory(dir, file);
250
+ return { file, name: memory?.name || file, description: memory?.description || '' };
251
+ }),
252
+ hasIndex: indexText !== null,
253
+ indexLines: indexText === null ? 0 : indexText.split('\n').length,
254
+ };
255
+ }
256
+
257
+ /** Trash every memory in a project, MEMORY.md included, as one operation. */
258
+ export function deleteProject(root, slug) {
259
+ const dir = memoryDir(root, slug);
260
+ const files = listMemoryFiles(dir);
261
+ const indexText = readIndex(dir);
262
+ if (!files.length && indexText === null) throw new Error('This project has no memory to delete');
263
+
264
+ if (!files.length) {
265
+ // Only MEMORY.md exists: trash it on its own.
266
+ const trashPath = path.join(dir, TRASH_DIR);
267
+ fs.mkdirSync(trashPath, { recursive: true });
268
+ const stamp = timestamp();
269
+ const trashedIndex = `${stamp}_MEMORY.md`;
270
+ fs.renameSync(indexPath(dir), path.join(trashPath, trashedIndex));
271
+ const record = {
272
+ version: 2,
273
+ kind: 'project',
274
+ id: `${stamp}_project`,
275
+ label: 'MEMORY.md',
276
+ deletedAt: new Date().toISOString(),
277
+ slug,
278
+ files: [],
279
+ indexTrashedFile: trashedIndex,
280
+ removedLines: [],
281
+ indexSha256Before: sha256(indexText),
282
+ indexSha256After: null,
283
+ };
284
+ fs.writeFileSync(path.join(trashPath, `${record.id}.restore.json`), JSON.stringify(record, null, 2));
285
+ return { deleted: true, record };
286
+ }
287
+
288
+ return deleteMemories(root, slug, files, {
289
+ includeIndex: indexText !== null,
290
+ label: `whole project (${files.length} memories)`,
291
+ });
292
+ }
293
+
294
+ /**
295
+ * Turn a broken `[[target]]` into plain text in one memory. The original file
296
+ * is copied into .trash first so the edit can be undone like any delete.
297
+ */
298
+ export function removeWikilink(root, slug, file, target) {
299
+ const dir = memoryDir(root, slug);
300
+ const full = safeMemoryPath(dir, file);
301
+ if (!fs.existsSync(full)) throw new Error(`No such memory: ${file}`);
302
+ if (typeof target !== 'string' || !target.trim()) throw new Error('No link target given');
303
+
304
+ const original = fs.readFileSync(full, 'utf8');
305
+ const { text, count } = unwrapWikilink(original, target);
306
+ if (count === 0) throw new Error(`No [[${target}]] found in ${file}`);
307
+
308
+ const trashPath = path.join(dir, TRASH_DIR);
309
+ fs.mkdirSync(trashPath, { recursive: true });
310
+ const stamp = timestamp();
311
+ const backupFile = `${stamp}_${file}.before-unlink.md`;
312
+ fs.writeFileSync(path.join(trashPath, backupFile), original);
313
+
314
+ try {
315
+ writeFileAtomic(dir, file, text);
316
+ } catch (err) {
317
+ try { fs.unlinkSync(path.join(trashPath, backupFile)); } catch { /* best effort */ }
318
+ throw err;
319
+ }
320
+
321
+ const record = {
322
+ version: 2,
323
+ kind: 'wikilink',
324
+ id: `${stamp}_${file}.unlink`,
325
+ label: `[[${target}]] in ${file}`,
326
+ deletedAt: new Date().toISOString(),
327
+ slug,
328
+ sourceFile: file,
329
+ target,
330
+ occurrences: count,
331
+ backupFile,
332
+ files: [],
333
+ removedLines: [],
334
+ };
335
+ fs.writeFileSync(path.join(trashPath, `${record.id}.restore.json`), JSON.stringify(record, null, 2));
336
+ return { removed: true, occurrences: count, record };
337
+ }
338
+
339
+ /** Undo any trashed operation: a delete, a cascade, a project clear, or an unlink. */
340
+ export function restoreMemory(root, slug, id) {
341
+ const dir = memoryDir(root, slug);
342
+ const trashPath = path.join(dir, TRASH_DIR);
343
+ const recordPath = path.join(trashPath, `${id}.restore.json`);
344
+ if (!fs.existsSync(recordPath)) throw new Error('No such trash record');
345
+
346
+ const record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
347
+
348
+ if (record.kind === 'wikilink') {
349
+ safeMemoryPath(dir, record.sourceFile);
350
+ const backup = path.join(trashPath, record.backupFile);
351
+ if (!fs.existsSync(backup)) throw new Error('The backup copy is gone');
352
+ writeFileAtomic(dir, record.sourceFile, fs.readFileSync(backup, 'utf8'));
353
+ fs.unlinkSync(backup);
354
+ fs.unlinkSync(recordPath);
355
+ return { restored: true, file: record.sourceFile, indexRestored: 'n/a', kind: 'wikilink' };
356
+ }
357
+
358
+ // Older single-file records used `memoryFile`/`trashedFile` at the top level.
359
+ const entries = record.files?.length
360
+ ? record.files
361
+ : [{ file: record.memoryFile, trashedFile: record.trashedFile }];
362
+
363
+ for (const entry of entries) {
364
+ const target = safeMemoryPath(dir, entry.file);
365
+ if (fs.existsSync(target)) throw new Error(`${entry.file} already exists - not overwriting it`);
366
+ if (!fs.existsSync(path.join(trashPath, entry.trashedFile))) {
367
+ throw new Error(`The trashed copy of ${entry.file} is gone`);
368
+ }
369
+ }
370
+
371
+ for (const entry of entries) {
372
+ fs.renameSync(path.join(trashPath, entry.trashedFile), path.join(dir, entry.file));
373
+ }
374
+
375
+ let indexRestored = 'skipped';
376
+ if (record.indexTrashedFile) {
377
+ // A whole-project clear took MEMORY.md with it.
378
+ if (fs.existsSync(indexPath(dir))) {
379
+ indexRestored = 'skipped';
380
+ } else {
381
+ fs.renameSync(path.join(trashPath, record.indexTrashedFile), indexPath(dir));
382
+ indexRestored = 'exact';
383
+ }
384
+ } else {
385
+ const current = readIndex(dir);
386
+ if (current !== null && record.removedLines?.length) {
387
+ // Only splice lines back at their exact positions if MEMORY.md is still
388
+ // what the delete left behind; otherwise append and say so.
389
+ if (sha256(current) === record.indexSha256After) {
390
+ writeIndexAtomic(dir, insertLines(current, record.removedLines));
391
+ indexRestored = 'exact';
392
+ } else {
393
+ const suffix = record.removedLines.map((l) => l.text).join('\n');
394
+ const separator = current.endsWith('\n') ? '' : '\n';
395
+ writeIndexAtomic(dir, `${current}${separator}${suffix}\n`);
396
+ indexRestored = 'appended';
397
+ }
398
+ }
399
+ }
400
+
401
+ fs.unlinkSync(recordPath);
402
+ return { restored: true, files: entries.map((e) => e.file), indexRestored, kind: record.kind || 'memories' };
403
+ }
404
+
405
+ /** Drop a single MEMORY.md line, used to clear a pointer whose file is gone. */
406
+ export function deleteIndexLine(root, slug, lineIndex, expectedText) {
407
+ const dir = memoryDir(root, slug);
408
+ const current = readIndex(dir);
409
+ if (current === null) throw new Error('This project has no MEMORY.md');
410
+
411
+ const { text, removed } = removeLine(current, lineIndex, expectedText);
412
+ writeIndexAtomic(dir, text);
413
+ return { removed };
414
+ }