claude-memory-admin 1.0.0 → 1.1.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.
@@ -0,0 +1,423 @@
1
+ // What Claude Code actually loads as instructions when a session starts in a
2
+ // directory: the CLAUDE.md chain, the files those import, and `.claude/rules/`.
3
+ //
4
+ // This is the other half of the startup context. MEMORY.md has a hard limit and
5
+ // this side does not, but the two are spent from the same budget, and the rules
6
+ // that decide which files load are fiddly enough that people are routinely
7
+ // surprised. Everything here is READ ONLY.
8
+ //
9
+ // The resolution is a re-derivation of documented behaviour, not a report from
10
+ // Claude Code itself. Where a rule is subtle - imports inside code spans do not
11
+ // count, a `paths:` glob with an unbalanced bracket matches nothing - the
12
+ // subtlety is implemented rather than smoothed over, because smoothing over it
13
+ // would hide exactly the problem this is meant to surface.
14
+
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { parseFrontmatter } from './parse.mjs';
19
+ import { estimateTokens } from './stats.mjs';
20
+ import { lookup, settingsLayers } from './settings.mjs';
21
+
22
+ /** Claude Code's own guidance for a CLAUDE.md. Guidance, not a cutoff. */
23
+ export const CLAUDE_MD_TARGET_LINES = 200;
24
+
25
+ /** An import chain deeper than this is not followed. */
26
+ export const MAX_IMPORT_DEPTH = 4;
27
+
28
+ /** A rule's whole `paths:` list shares this budget of expanded patterns. */
29
+ export const BRACE_PATTERN_BUDGET = 1000;
30
+
31
+ function managedClaudeMd() {
32
+ if (process.platform === 'darwin') return '/Library/Application Support/ClaudeCode/CLAUDE.md';
33
+ if (process.platform === 'win32') return 'C:\\Program Files\\ClaudeCode\\CLAUDE.md';
34
+ return '/etc/claude-code/CLAUDE.md';
35
+ }
36
+
37
+ function readIfFile(file) {
38
+ try {
39
+ if (!fs.statSync(file).isFile()) return null;
40
+ return fs.readFileSync(file, 'utf8');
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /* ------------------------------------------------------------------ imports */
47
+
48
+ /**
49
+ * Blank out fenced code blocks and code spans, keeping the byte length so every
50
+ * offset still lines up with the original.
51
+ *
52
+ * Import parsing skips both, which is the documented way to write `@README` in a
53
+ * CLAUDE.md without importing it. Scanning the raw text instead would report
54
+ * imports that never happen, and the file being wrong about its own examples is
55
+ * worse than not checking.
56
+ */
57
+ export function maskCode(text) {
58
+ const blank = (match) => match.replace(/[^\n]/g, ' ');
59
+ return text
60
+ .replace(/^[ \t]*(```|~~~)[\s\S]*?^[ \t]*\1[ \t]*$/gm, blank)
61
+ .replace(/`+[^`\n]*`+/g, blank);
62
+ }
63
+
64
+ // A bare @ that is part of an email address or an npm scope is not an import,
65
+ // so the character before it has to be a boundary.
66
+ const IMPORT = /(^|[\s(<'"])@((?:~\/|\.{0,2}\/)?[A-Za-z0-9._~\-][A-Za-z0-9._~\-/]*)/g;
67
+
68
+ /** Every `@path` import in a file's text, with the offset it was found at. */
69
+ export function findImports(text) {
70
+ const masked = maskCode(text);
71
+ const found = [];
72
+ for (const match of masked.matchAll(IMPORT)) {
73
+ const end = match.index + match[0].length;
74
+ // "@team@example.com" opens with something that looks exactly like an
75
+ // import until the second @ arrives.
76
+ if (masked[end] === '@') continue;
77
+ found.push({ spec: match[2], index: match.index + match[1].length });
78
+ }
79
+ return found;
80
+ }
81
+
82
+ function resolveImport(spec, fromFile) {
83
+ if (spec.startsWith('~/')) return path.join(os.homedir(), spec.slice(2));
84
+ if (path.isAbsolute(spec)) return spec;
85
+ // Relative to the file holding the import, not to the working directory.
86
+ return path.resolve(path.dirname(fromFile), spec);
87
+ }
88
+
89
+ /**
90
+ * Follow a file's imports, breadth-first, to the documented maximum depth.
91
+ *
92
+ * Returns every file reached and every import that did not work out. A cycle is
93
+ * reported once and not followed, because a file importing itself back is a
94
+ * mistake worth naming rather than a loop worth running.
95
+ */
96
+ export function expandImports(rootFile, rootText, { projectDir = null } = {}) {
97
+ const files = [];
98
+ const problems = [];
99
+ const seen = new Set([rootFile]);
100
+ let frontier = [{ file: rootFile, text: rootText, depth: 0 }];
101
+
102
+ while (frontier.length) {
103
+ const next = [];
104
+ for (const current of frontier) {
105
+ for (const { spec } of findImports(current.text)) {
106
+ const resolved = resolveImport(spec, current.file);
107
+
108
+ if (current.depth + 1 > MAX_IMPORT_DEPTH) {
109
+ problems.push({ kind: 'too-deep', spec, from: current.file, file: resolved });
110
+ continue;
111
+ }
112
+ if (seen.has(resolved)) {
113
+ problems.push({ kind: 'cycle', spec, from: current.file, file: resolved });
114
+ continue;
115
+ }
116
+
117
+ const text = readIfFile(resolved);
118
+ if (text === null) {
119
+ problems.push({ kind: 'missing', spec, from: current.file, file: resolved });
120
+ continue;
121
+ }
122
+
123
+ seen.add(resolved);
124
+ // An import resolving outside the project is the kind Claude Code asks
125
+ // you to approve once; a declined one then stays silently disabled.
126
+ const external = projectDir ? path.relative(projectDir, resolved).startsWith('..') : false;
127
+ const entry = { file: resolved, text, depth: current.depth + 1, importedBy: current.file, external };
128
+ files.push(entry);
129
+ if (external) problems.push({ kind: 'external', spec, from: current.file, file: resolved });
130
+ next.push(entry);
131
+ }
132
+ }
133
+ frontier = next;
134
+ }
135
+
136
+ return { files, problems };
137
+ }
138
+
139
+ /* -------------------------------------------------------------------- globs */
140
+
141
+ /**
142
+ * Check a `paths:` glob the way it will actually behave.
143
+ *
144
+ * Two documented failure modes make a rule silently inert, and both look
145
+ * completely normal in the file:
146
+ * - a `[` that cannot be read as a bracket expression makes the pattern match
147
+ * nothing at all;
148
+ * - brace expansion past the budget makes the pattern get used unexpanded, so
149
+ * its literal braces match no file either.
150
+ */
151
+ export function checkGlob(pattern) {
152
+ let expansions = 1;
153
+ for (const group of pattern.matchAll(/\{([^{}]*)\}/g)) {
154
+ expansions *= group[1].split(',').length;
155
+ }
156
+
157
+ // Walk the pattern looking for a bracket expression that never closes.
158
+ let open = -1;
159
+ for (let i = 0; i < pattern.length; i++) {
160
+ if (pattern[i] === '\\') { i += 1; continue; }
161
+ if (pattern[i] === '[' && open === -1) open = i;
162
+ else if (pattern[i] === ']' && open !== -1 && i > open + 1) open = -1;
163
+ }
164
+
165
+ if (open !== -1) {
166
+ return { pattern, expansions, valid: false, reason: 'unclosed [ - glob syntax reads it as a bracket expression, so the pattern matches nothing' };
167
+ }
168
+ return { pattern, expansions, valid: true, reason: null };
169
+ }
170
+
171
+ /** Whether a rule's whole `paths:` list fits the shared expansion budget. */
172
+ export function checkGlobList(patterns) {
173
+ const checks = patterns.map(checkGlob);
174
+ // Patterns without braces do not count against the budget.
175
+ const total = checks.reduce((sum, check) => sum + (check.expansions > 1 ? check.expansions : 0), 0);
176
+ return {
177
+ checks,
178
+ expansions: total,
179
+ overBudget: total > BRACE_PATTERN_BUDGET,
180
+ };
181
+ }
182
+
183
+ /* -------------------------------------------------------------------- rules */
184
+
185
+ function listRuleFiles(dir, { depth = 8, seenReal = new Set() } = {}) {
186
+ let real;
187
+ try {
188
+ real = fs.realpathSync(dir);
189
+ } catch {
190
+ return [];
191
+ }
192
+ // Symlinked rule directories are supported, and circular ones must not hang.
193
+ if (seenReal.has(real) || depth < 0) return [];
194
+ seenReal.add(real);
195
+
196
+ let entries;
197
+ try {
198
+ entries = fs.readdirSync(dir, { withFileTypes: true });
199
+ } catch {
200
+ return [];
201
+ }
202
+
203
+ const out = [];
204
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
205
+ const full = path.join(dir, entry.name);
206
+ let stat;
207
+ try {
208
+ stat = fs.statSync(full); // follows symlinks, which is the point
209
+ } catch {
210
+ continue;
211
+ }
212
+ if (stat.isDirectory()) out.push(...listRuleFiles(full, { depth: depth - 1, seenReal }));
213
+ else if (entry.name.endsWith('.md')) out.push(full);
214
+ }
215
+ return out;
216
+ }
217
+
218
+ function unquote(value) {
219
+ const trimmed = value.trim();
220
+ const first = trimmed[0];
221
+ const last = trimmed[trimmed.length - 1];
222
+ if (trimmed.length >= 2 && ((first === '"' && last === '"') || (first === "'" && last === "'"))) {
223
+ return trimmed.slice(1, -1);
224
+ }
225
+ return trimmed;
226
+ }
227
+
228
+ /**
229
+ * The `paths:` patterns of a rule, read straight from the frontmatter block.
230
+ *
231
+ * The frontmatter reader shared with memory files models scalars and one level
232
+ * of nesting, which is everything memory frontmatter uses and not a YAML list.
233
+ * Rather than widen a parser the whole app depends on, the one construct rules
234
+ * need is read here.
235
+ */
236
+ export function readPathsList(frontmatterRaw) {
237
+ const patterns = [];
238
+ let inPaths = false;
239
+
240
+ for (const line of frontmatterRaw.split('\n')) {
241
+ const key = line.match(/^([A-Za-z0-9_.\-]+):[ \t]*(.*)$/);
242
+ if (key) {
243
+ inPaths = key[1] === 'paths';
244
+ if (!inPaths) continue;
245
+ const value = key[2].trim();
246
+ if (value.startsWith('[') && value.endsWith(']')) {
247
+ for (const part of value.slice(1, -1).split(',')) {
248
+ const pattern = unquote(part);
249
+ if (pattern) patterns.push(pattern);
250
+ }
251
+ inPaths = false;
252
+ } else if (value) {
253
+ patterns.push(unquote(value));
254
+ inPaths = false;
255
+ }
256
+ continue;
257
+ }
258
+ if (!inPaths) continue;
259
+ const item = line.match(/^[ \t]*-[ \t]+(.*)$/);
260
+ if (item) {
261
+ const pattern = unquote(item[1]);
262
+ if (pattern) patterns.push(pattern);
263
+ }
264
+ }
265
+ return patterns;
266
+ }
267
+
268
+ function ruleEntry(file, scope) {
269
+ const text = readIfFile(file);
270
+ if (text === null) return null;
271
+ const { raw } = parseFrontmatter(text);
272
+ const patterns = readPathsList(raw);
273
+
274
+ return {
275
+ file,
276
+ scope,
277
+ kind: 'rule',
278
+ text,
279
+ conditional: patterns.length > 0,
280
+ globs: patterns.length ? checkGlobList(patterns) : null,
281
+ };
282
+ }
283
+
284
+ /* ------------------------------------------------------------------ loading */
285
+
286
+ function entry(file, scope, kind, { conditional = false } = {}) {
287
+ const text = readIfFile(file);
288
+ return text === null ? null : { file, scope, kind, text, conditional, globs: null };
289
+ }
290
+
291
+ /** Directories from the filesystem root down to `dir`, in load order. */
292
+ function ancestors(dir) {
293
+ const parts = path.resolve(dir).split(path.sep);
294
+ const out = [];
295
+ for (let i = 1; i < parts.length; i++) {
296
+ out.push(parts.slice(0, i + 1).join(path.sep) || path.sep);
297
+ }
298
+ return out;
299
+ }
300
+
301
+ function globToRegExp(pattern) {
302
+ // Only used for claudeMdExcludes, which matches absolute paths.
303
+ let out = '';
304
+ for (let i = 0; i < pattern.length; i++) {
305
+ const char = pattern[i];
306
+ if (char === '*') {
307
+ if (pattern[i + 1] === '*') { out += '.*'; i += 1; if (pattern[i + 1] === '/') i += 1; }
308
+ else out += '[^/]*';
309
+ } else if (char === '?') out += '[^/]';
310
+ else out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
311
+ }
312
+ return new RegExp(`^${out}$`);
313
+ }
314
+
315
+ /**
316
+ * Everything that would load for a session started in `projectDir`, in the order
317
+ * Claude Code loads it: broadest scope first, so the most specific instruction is
318
+ * the last thing read.
319
+ */
320
+ export function resolveInstructions(projectDir) {
321
+ const home = os.homedir();
322
+ const layers = settingsLayers({ projectDir });
323
+ const excludeFound = lookup(layers, 'claudeMdExcludes');
324
+ const excludes = Array.isArray(excludeFound?.value) ? excludeFound.value.map(String) : [];
325
+ const excluded = [];
326
+
327
+ const candidates = [];
328
+ candidates.push(entry(managedClaudeMd(), 'managed', 'claude-md'));
329
+
330
+ const managedInline = lookup(layers, 'claudeMd');
331
+ const inline = typeof managedInline?.value === 'string' && managedInline.value.trim()
332
+ && (managedInline.scope === 'managed')
333
+ ? { file: managedInline.file, scope: 'managed', kind: 'managed-settings', text: managedInline.value, conditional: false, globs: null }
334
+ : null;
335
+ if (inline) candidates.push(inline);
336
+
337
+ candidates.push(entry(path.join(home, '.claude', 'CLAUDE.md'), 'user', 'claude-md'));
338
+ for (const file of listRuleFiles(path.join(home, '.claude', 'rules'))) {
339
+ candidates.push(ruleEntry(file, 'user'));
340
+ }
341
+
342
+ // Root-to-cwd, CLAUDE.md then CLAUDE.local.md at each level.
343
+ for (const dir of ancestors(projectDir)) {
344
+ candidates.push(entry(path.join(dir, 'CLAUDE.md'), 'project', 'claude-md'));
345
+ candidates.push(entry(path.join(dir, 'CLAUDE.local.md'), 'local', 'claude-md'));
346
+ }
347
+ candidates.push(entry(path.join(projectDir, '.claude', 'CLAUDE.md'), 'project', 'claude-md'));
348
+ for (const file of listRuleFiles(path.join(projectDir, '.claude', 'rules'))) {
349
+ candidates.push(ruleEntry(file, 'project'));
350
+ }
351
+
352
+ const matchers = excludes.map(globToRegExp);
353
+ const loaded = [];
354
+ const problems = [];
355
+
356
+ for (const candidate of candidates.filter(Boolean)) {
357
+ // Managed policy cannot be excluded; that is the point of managed policy.
358
+ if (candidate.scope !== 'managed' && matchers.some((re) => re.test(candidate.file))) {
359
+ excluded.push(candidate.file);
360
+ continue;
361
+ }
362
+ loaded.push(candidate);
363
+
364
+ if (candidate.kind === 'managed-settings') continue;
365
+ const expanded = expandImports(candidate.file, candidate.text, { projectDir });
366
+ problems.push(...expanded.problems.map((p) => ({ ...p, scope: candidate.scope })));
367
+ for (const imported of expanded.files) {
368
+ loaded.push({
369
+ file: imported.file,
370
+ scope: candidate.scope,
371
+ kind: 'import',
372
+ text: imported.text,
373
+ conditional: candidate.conditional,
374
+ globs: null,
375
+ importedBy: imported.importedBy,
376
+ depth: imported.depth,
377
+ external: imported.external,
378
+ });
379
+ }
380
+ }
381
+
382
+ // AGENTS.md is read by other tools, not by Claude Code, so one sitting next to
383
+ // an unrelated CLAUDE.md is a real and easy-to-miss divergence.
384
+ const agentsFile = path.join(projectDir, 'AGENTS.md');
385
+ if (fs.existsSync(agentsFile) && !loaded.some((item) => item.file === agentsFile)) {
386
+ problems.push({ kind: 'agents-md-not-imported', file: agentsFile, scope: 'project' });
387
+ }
388
+
389
+ for (const item of loaded) {
390
+ item.lines = item.text.split('\n').length;
391
+ item.bytes = Buffer.byteLength(item.text, 'utf8');
392
+ item.tokens = estimateTokens(item.text);
393
+ if (item.globs && !item.globs.checks.every((c) => c.valid)) {
394
+ for (const check of item.globs.checks.filter((c) => !c.valid)) {
395
+ problems.push({ kind: 'invalid-glob', file: item.file, scope: item.scope, pattern: check.pattern, reason: check.reason });
396
+ }
397
+ }
398
+ if (item.globs?.overBudget) {
399
+ problems.push({ kind: 'glob-budget', file: item.file, scope: item.scope, expansions: item.globs.expansions });
400
+ }
401
+ if (item.kind === 'claude-md' && item.lines > CLAUDE_MD_TARGET_LINES) {
402
+ problems.push({ kind: 'long-claude-md', file: item.file, scope: item.scope, lines: item.lines });
403
+ }
404
+ }
405
+
406
+ const unconditional = loaded.filter((item) => !item.conditional);
407
+ return {
408
+ projectDir,
409
+ files: loaded,
410
+ excluded,
411
+ problems,
412
+ totals: {
413
+ files: loaded.length,
414
+ // Conditional rules only load when Claude reads a matching file, so they
415
+ // are counted apart from what every session pays for.
416
+ alwaysLines: unconditional.reduce((sum, item) => sum + item.lines, 0),
417
+ alwaysBytes: unconditional.reduce((sum, item) => sum + item.bytes, 0),
418
+ alwaysTokens: unconditional.reduce((sum, item) => sum + item.tokens, 0),
419
+ conditionalFiles: loaded.length - unconditional.length,
420
+ conditionalTokens: loaded.filter((i) => i.conditional).reduce((sum, item) => sum + item.tokens, 0),
421
+ },
422
+ };
423
+ }
package/src/model.mjs CHANGED
@@ -1,5 +1,10 @@
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.
1
+ // Builds the full view model for one memory store: its index, its memory files,
2
+ // the wikilink graph between them, and the consistency problems worth surfacing.
3
+ //
4
+ // A store is addressed by its directory, not by a root and a slug. Auto memory
5
+ // and subagent memory sit in unrelated places on disk but hold the same shape, so
6
+ // everything below is written against the shape and knows nothing about which
7
+ // kind it was handed.
3
8
  //
4
9
  // The whole store is a few hundred kilobytes, so this runs per request. No
5
10
  // cache, no watcher, no invalidation bugs.
@@ -8,6 +13,7 @@ import fs from 'node:fs';
8
13
  import path from 'node:path';
9
14
  import { parseIndex, parseFrontmatter, extractWikilinks } from './parse.mjs';
10
15
  import { listMemoryFiles, memoryDir, resolveProjectPath, shortLabel } from './projects.mjs';
16
+ import { autoMemoryState } from './settings.mjs';
11
17
  import { ageInDays, estimateTokens, findDuplicates, indexStats } from './stats.mjs';
12
18
 
13
19
  export const TRASH_DIR = '.trash';
@@ -46,6 +52,13 @@ export function loadMemory(dir, file) {
46
52
  stat = fs.statSync(path.join(dir, file));
47
53
  } catch { /* raced with a delete */ }
48
54
 
55
+ // Claude Code stamps `modified` only on files that already begin with
56
+ // frontmatter, and never adds frontmatter to a file that has none, so for those
57
+ // files the mtime fallback is permanent. mtime is also reset by any copy, rsync
58
+ // or restore - including this tool's own trash round-trip - so which of the two
59
+ // a date came from is the difference between evidence and a guess.
60
+ const stamped = typeof metadata.modified === 'string' && metadata.modified ? metadata.modified : null;
61
+
49
62
  return {
50
63
  file,
51
64
  stem,
@@ -60,7 +73,8 @@ export function loadMemory(dir, file) {
60
73
  body,
61
74
  raw,
62
75
  bytes: stat ? stat.size : raw.length,
63
- modified: metadata.modified || (stat ? new Date(stat.mtimeMs).toISOString() : null),
76
+ modified: stamped || (stat ? new Date(stat.mtimeMs).toISOString() : null),
77
+ modifiedFrom: stamped ? 'frontmatter' : (stat ? 'mtime' : null),
64
78
  tokens: estimateTokens(raw),
65
79
  outbound: extractWikilinks(body).map((w) => w.target),
66
80
  };
@@ -80,10 +94,8 @@ function buildResolver(memories) {
80
94
  return (target) => byName.get(target) || byStem.get(target) || null;
81
95
  }
82
96
 
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);
97
+ export function buildStore(store) {
98
+ const { dir } = store;
87
99
  const hasMemoryDir = fs.existsSync(dir);
88
100
 
89
101
  const indexRaw = readIfExists(path.join(dir, 'MEMORY.md'));
@@ -165,10 +177,7 @@ export function buildProject(root, slug) {
165
177
  health.issueCount = health.issues.length;
166
178
 
167
179
  return {
168
- slug,
169
- path: resolved.path,
170
- label: shortLabel(resolved.path),
171
- resolvedBy: resolved.resolvedBy,
180
+ ...store,
172
181
  hasMemoryDir,
173
182
  hasIndex: index !== null,
174
183
  index: index
@@ -197,6 +206,30 @@ export function buildProject(root, slug) {
197
206
  };
198
207
  }
199
208
 
209
+ /**
210
+ * The auto-memory store for one project. Everything the project model carries
211
+ * beyond the store itself - its real path, the directories that feed it, whether
212
+ * Claude is still writing to it - is resolved here and handed to buildStore.
213
+ */
214
+ export function buildProject(root, slug) {
215
+ const resolved = resolveProjectPath(path.join(root, slug), slug);
216
+ return buildStore({
217
+ slug,
218
+ kind: 'auto',
219
+ dir: memoryDir(root, slug),
220
+ path: resolved.path,
221
+ label: shortLabel(resolved.path),
222
+ resolvedBy: resolved.resolvedBy,
223
+ // The decode that could not be confirmed, offered as a starting point when
224
+ // the user is asked to name the path themselves.
225
+ guess: resolved.guess || null,
226
+ workingDirs: resolved.workingDirs || [],
227
+ // Off means this store will never grow again, which on disk looks exactly
228
+ // like a project Claude has not learned anything about yet.
229
+ autoMemory: autoMemoryState({ projectDir: resolved.exists ? resolved.path : null }),
230
+ });
231
+ }
232
+
200
233
  /** Restore records left behind by soft deletes, newest first. */
201
234
  export function listTrash(dir) {
202
235
  const trashPath = path.join(dir, TRASH_DIR);
package/src/mutate.mjs CHANGED
@@ -1,4 +1,9 @@
1
- // The only code in the app that writes. Everything else is read-only.
1
+ // The only code that writes inside a memory store. Everything else is read-only.
2
+ //
3
+ // Every entry point takes the store's directory rather than a root and a slug:
4
+ // auto memory and subagent memory live in unrelated places on disk but hold the
5
+ // same MEMORY.md-plus-topic-files shape, so nothing below needs to know which
6
+ // kind it is working on.
2
7
  //
3
8
  // Deletes are soft: the file moves into memory/.trash/ and a restore record
4
9
  // captures the MEMORY.md lines that were removed, with their original indices,
@@ -8,7 +13,7 @@ import crypto from 'node:crypto';
8
13
  import fs from 'node:fs';
9
14
  import path from 'node:path';
10
15
  import { parseIndex, removeIndexEntries, removeLine, insertLines, unwrapWikilink } from './parse.mjs';
11
- import { listMemoryFiles, memoryDir } from './projects.mjs';
16
+ import { listMemoryFiles } from './projects.mjs';
12
17
  import { TRASH_DIR, loadMemory } from './model.mjs';
13
18
 
14
19
  function sha256(text) {
@@ -94,8 +99,7 @@ const writeIndexAtomic = (dir, text) => writeFileAtomic(dir, 'MEMORY.md', text);
94
99
  * What a delete would do, without doing it. This is what the confirm dialog
95
100
  * renders, so it has to be exhaustive about the collateral.
96
101
  */
97
- export function deletePreview(root, slug, file) {
98
- const dir = memoryDir(root, slug);
102
+ export function deletePreview(dir, file) {
99
103
  const full = safeMemoryPath(dir, file);
100
104
  const exists = fs.existsSync(full);
101
105
  const indexText = readIndex(dir);
@@ -165,8 +169,7 @@ export function deletePreview(root, slug, file) {
165
169
  * clearing a whole project both come through here, so there is a single restore
166
170
  * path rather than three.
167
171
  */
168
- export function deleteMemories(root, slug, files, { includeIndex = false, label = null } = {}) {
169
- const dir = memoryDir(root, slug);
172
+ export function deleteMemories(dir, files, { includeIndex = false, label = null } = {}) {
170
173
  const wanted = [...new Set([].concat(files))];
171
174
  if (!wanted.length) throw new Error('Nothing selected to delete');
172
175
 
@@ -221,7 +224,6 @@ export function deleteMemories(root, slug, files, { includeIndex = false, label
221
224
  id: `${stamp}_${includeIndex ? 'project' : targets[0].file}`,
222
225
  label: label || (targets.length === 1 ? targets[0].name : `${targets.length} memories`),
223
226
  deletedAt: new Date().toISOString(),
224
- slug,
225
227
  files: moved.map(({ file, trashedFile, name, description }) => ({ file, trashedFile, name, description })),
226
228
  indexTrashedFile: moved.indexTrashed || null,
227
229
  removedLines: removed,
@@ -233,18 +235,16 @@ export function deleteMemories(root, slug, files, { includeIndex = false, label
233
235
  }
234
236
 
235
237
  /** Single-memory delete, optionally cascading to the memories that link to it. */
236
- export function deleteMemory(root, slug, file, alsoDelete = []) {
238
+ export function deleteMemory(dir, file, alsoDelete = []) {
237
239
  const extra = [].concat(alsoDelete).filter((f) => f && f !== file);
238
- return deleteMemories(root, slug, [file, ...extra]);
240
+ return deleteMemories(dir, [file, ...extra]);
239
241
  }
240
242
 
241
243
  /** What clearing a whole project would remove. */
242
- export function projectDeletePreview(root, slug) {
243
- const dir = memoryDir(root, slug);
244
+ export function projectDeletePreview(dir) {
244
245
  const files = listMemoryFiles(dir);
245
246
  const indexText = readIndex(dir);
246
247
  return {
247
- slug,
248
248
  files: files.map((file) => {
249
249
  const memory = loadMemory(dir, file);
250
250
  return { file, name: memory?.name || file, description: memory?.description || '' };
@@ -255,8 +255,7 @@ export function projectDeletePreview(root, slug) {
255
255
  }
256
256
 
257
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);
258
+ export function deleteProject(dir) {
260
259
  const files = listMemoryFiles(dir);
261
260
  const indexText = readIndex(dir);
262
261
  if (!files.length && indexText === null) throw new Error('This project has no memory to delete');
@@ -274,7 +273,6 @@ export function deleteProject(root, slug) {
274
273
  id: `${stamp}_project`,
275
274
  label: 'MEMORY.md',
276
275
  deletedAt: new Date().toISOString(),
277
- slug,
278
276
  files: [],
279
277
  indexTrashedFile: trashedIndex,
280
278
  removedLines: [],
@@ -285,7 +283,7 @@ export function deleteProject(root, slug) {
285
283
  return { deleted: true, record };
286
284
  }
287
285
 
288
- return deleteMemories(root, slug, files, {
286
+ return deleteMemories(dir, files, {
289
287
  includeIndex: indexText !== null,
290
288
  label: `whole project (${files.length} memories)`,
291
289
  });
@@ -295,8 +293,7 @@ export function deleteProject(root, slug) {
295
293
  * Turn a broken `[[target]]` into plain text in one memory. The original file
296
294
  * is copied into .trash first so the edit can be undone like any delete.
297
295
  */
298
- export function removeWikilink(root, slug, file, target) {
299
- const dir = memoryDir(root, slug);
296
+ export function removeWikilink(dir, file, target) {
300
297
  const full = safeMemoryPath(dir, file);
301
298
  if (!fs.existsSync(full)) throw new Error(`No such memory: ${file}`);
302
299
  if (typeof target !== 'string' || !target.trim()) throw new Error('No link target given');
@@ -324,7 +321,6 @@ export function removeWikilink(root, slug, file, target) {
324
321
  id: `${stamp}_${file}.unlink`,
325
322
  label: `[[${target}]] in ${file}`,
326
323
  deletedAt: new Date().toISOString(),
327
- slug,
328
324
  sourceFile: file,
329
325
  target,
330
326
  occurrences: count,
@@ -337,8 +333,7 @@ export function removeWikilink(root, slug, file, target) {
337
333
  }
338
334
 
339
335
  /** 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);
336
+ export function restoreMemory(dir, id) {
342
337
  const trashPath = path.join(dir, TRASH_DIR);
343
338
  const recordPath = path.join(trashPath, `${id}.restore.json`);
344
339
  if (!fs.existsSync(recordPath)) throw new Error('No such trash record');
@@ -403,8 +398,7 @@ export function restoreMemory(root, slug, id) {
403
398
  }
404
399
 
405
400
  /** 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);
401
+ export function deleteIndexLine(dir, lineIndex, expectedText) {
408
402
  const current = readIndex(dir);
409
403
  if (current === null) throw new Error('This project has no MEMORY.md');
410
404