claude-memory-admin 1.1.0 → 1.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/src/mutate.mjs CHANGED
@@ -12,7 +12,21 @@
12
12
  import crypto from 'node:crypto';
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
- import { parseIndex, removeIndexEntries, removeLine, insertLines, unwrapWikilink } from './parse.mjs';
15
+ import {
16
+ parseIndex,
17
+ removeIndexEntries,
18
+ removeLine,
19
+ insertLines,
20
+ unwrapWikilink,
21
+ retargetWikilink,
22
+ setIndexHook,
23
+ moveIndexEntry as moveIndexEntryText,
24
+ sectionInsertIndex,
25
+ sectionStartIndex,
26
+ topInsertIndex,
27
+ insertIndexEntry,
28
+ dominantSeparator,
29
+ } from './parse.mjs';
16
30
  import { listMemoryFiles } from './projects.mjs';
17
31
  import { TRASH_DIR, loadMemory } from './model.mjs';
18
32
 
@@ -340,6 +354,55 @@ export function restoreMemory(dir, id) {
340
354
 
341
355
  const record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
342
356
 
357
+ if (record.kind === 'index-edit') {
358
+ const current = readIndex(dir);
359
+ const unchanged = current !== null && sha256(current) === record.indexSha256After;
360
+
361
+ if (record.indexCreated) {
362
+ if (!unchanged) throw new Error('MEMORY.md has changed since it was created - not deleting it');
363
+ fs.unlinkSync(indexPath(dir));
364
+ fs.unlinkSync(recordPath);
365
+ return { restored: true, file: 'MEMORY.md', indexRestored: 'exact', kind: 'index-edit' };
366
+ }
367
+
368
+ const backup = path.join(trashPath, record.indexBackupFile);
369
+ if (!fs.existsSync(backup)) throw new Error('The backup copy of MEMORY.md is gone');
370
+ writeIndexAtomic(dir, fs.readFileSync(backup, 'utf8'));
371
+ fs.unlinkSync(backup);
372
+ fs.unlinkSync(recordPath);
373
+ return {
374
+ restored: true,
375
+ file: 'MEMORY.md',
376
+ indexRestored: unchanged ? 'exact' : 'overwritten',
377
+ kind: 'index-edit',
378
+ };
379
+ }
380
+
381
+ if (record.kind === 'merge') {
382
+ const entry = record.files[0];
383
+ safeMemoryPath(dir, entry.file);
384
+ if (fs.existsSync(path.join(dir, entry.file))) {
385
+ throw new Error(`${entry.file} already exists - not overwriting it`);
386
+ }
387
+ for (const backup of record.backups) {
388
+ if (!fs.existsSync(path.join(trashPath, backup.backupFile))) {
389
+ throw new Error(`The backup copy of ${backup.file} is gone`);
390
+ }
391
+ }
392
+ if (!fs.existsSync(path.join(trashPath, entry.trashedFile))) {
393
+ throw new Error(`The trashed copy of ${entry.file} is gone`);
394
+ }
395
+
396
+ for (const backup of record.backups) {
397
+ writeFileAtomic(dir, backup.file, fs.readFileSync(path.join(trashPath, backup.backupFile), 'utf8'));
398
+ }
399
+ fs.renameSync(path.join(trashPath, entry.trashedFile), path.join(dir, entry.file));
400
+ for (const backup of record.backups) fs.unlinkSync(path.join(trashPath, backup.backupFile));
401
+ fs.unlinkSync(recordPath);
402
+
403
+ return { restored: true, files: [entry.file, record.into], indexRestored: 'exact', kind: 'merge' };
404
+ }
405
+
343
406
  if (record.kind === 'wikilink') {
344
407
  safeMemoryPath(dir, record.sourceFile);
345
408
  const backup = path.join(trashPath, record.backupFile);
@@ -406,3 +469,294 @@ export function deleteIndexLine(dir, lineIndex, expectedText) {
406
469
  writeIndexAtomic(dir, text);
407
470
  return { removed };
408
471
  }
472
+
473
+ function trashDir(dir) {
474
+ const trashPath = path.join(dir, TRASH_DIR);
475
+ fs.mkdirSync(trashPath, { recursive: true });
476
+ return trashPath;
477
+ }
478
+
479
+ function backupInto(trashPath, name, text) {
480
+ fs.writeFileSync(path.join(trashPath, name), text);
481
+ return name;
482
+ }
483
+
484
+ function editIndex(dir, { op, label, apply }) {
485
+ const before = readIndex(dir);
486
+ const created = before === null;
487
+ const { text, ...detail } = apply(before);
488
+
489
+ const trashPath = trashDir(dir);
490
+ const stamp = timestamp();
491
+ const backupFile = created ? null : backupInto(trashPath, `${stamp}_MEMORY.md.before-${op}.md`, before);
492
+
493
+ try {
494
+ writeIndexAtomic(dir, text);
495
+ } catch (err) {
496
+ if (backupFile) {
497
+ try { fs.unlinkSync(path.join(trashPath, backupFile)); } catch { /* best effort */ }
498
+ }
499
+ throw err;
500
+ }
501
+
502
+ const record = {
503
+ version: 2,
504
+ kind: 'index-edit',
505
+ op,
506
+ id: `${stamp}_${op}`,
507
+ label,
508
+ deletedAt: new Date().toISOString(),
509
+ indexBackupFile: backupFile,
510
+ indexCreated: created,
511
+ indexSha256Before: created ? null : sha256(before),
512
+ indexSha256After: sha256(text),
513
+ files: [],
514
+ removedLines: [],
515
+ };
516
+ fs.writeFileSync(path.join(trashPath, `${record.id}.restore.json`), JSON.stringify(record, null, 2));
517
+ return { record, ...detail };
518
+ }
519
+
520
+ function requireIndex(text) {
521
+ if (text === null) throw new Error('This project has no MEMORY.md');
522
+ return text;
523
+ }
524
+
525
+ export function editIndexHook(dir, { lineIndex, expectedText, hook }) {
526
+ return editIndex(dir, {
527
+ op: 'hook',
528
+ label: `hook on MEMORY.md line ${Number(lineIndex) + 1}`,
529
+ apply: (current) => {
530
+ const result = setIndexHook(requireIndex(current), lineIndex, expectedText, hook);
531
+ return { text: result.text, before: result.before, after: result.after, edited: true };
532
+ },
533
+ });
534
+ }
535
+
536
+ function resolveMoveTarget(parsed, { toIndex, section, top }) {
537
+ if (toIndex !== undefined && toIndex !== null) return Number(toIndex);
538
+ if (top) return topInsertIndex(parsed);
539
+ if (section) return sectionStartIndex(parsed, section);
540
+ throw new Error('No target given for the move');
541
+ }
542
+
543
+ export function moveIndexEntry(dir, { lineIndex, expectedText, toIndex, section = null, top = false }) {
544
+ return editIndex(dir, {
545
+ op: 'move',
546
+ label: `moved MEMORY.md line ${Number(lineIndex) + 1}`,
547
+ apply: (current) => {
548
+ const text = requireIndex(current);
549
+ const target = resolveMoveTarget(parseIndex(text), { toIndex, section, top });
550
+ const result = moveIndexEntryText(text, lineIndex, expectedText, target);
551
+ return { text: result.text, moved: result.moved, toIndex: result.toIndex, movedEntry: true };
552
+ },
553
+ });
554
+ }
555
+
556
+ function composeEntry(current, memory, title, hook) {
557
+ const parsed = current === null ? null : parseIndex(current);
558
+ const separator = parsed ? dominantSeparator(parsed) : ' — ';
559
+ const label = String(title ?? '').trim() || memory.name;
560
+ const text = String(hook ?? '').trim();
561
+ if (/[\r\n]/.test(label) || /[\r\n]/.test(String(hook ?? ''))) {
562
+ throw new Error('An index entry has to stay on one line');
563
+ }
564
+ const bullet = `- [${label.replace(/[[\]]/g, '')}](${memory.file})`;
565
+ return text ? `${bullet}${separator}${text}` : bullet;
566
+ }
567
+
568
+ function requireIndexable(dir, file) {
569
+ const full = safeMemoryPath(dir, file);
570
+ if (!fs.existsSync(full)) throw new Error(`No such memory: ${file}`);
571
+ const memory = loadMemory(dir, file);
572
+ if (!memory) throw new Error(`Could not read ${file}`);
573
+ return memory;
574
+ }
575
+
576
+ export function addIndexEntryPreview(dir, { file, section = null, title = null, hook = null }) {
577
+ const memory = requireIndexable(dir, file);
578
+ const current = readIndex(dir);
579
+ const parsed = current === null ? null : parseIndex(current);
580
+ const existing = parsed ? parsed.entries.find((entry) => entry.file === file) : null;
581
+
582
+ return {
583
+ file,
584
+ name: memory.name,
585
+ description: memory.description,
586
+ sections: parsed
587
+ ? [...new Set(parsed.parsedLines.filter((line) => line.kind === 'heading').map((line) => line.section))]
588
+ : [],
589
+ hasIndex: current !== null,
590
+ alreadyIndexed: Boolean(existing),
591
+ existingLine: existing ? { index: existing.index, text: existing.text } : null,
592
+ line: composeEntry(current, memory, title ?? memory.name, hook ?? memory.description),
593
+ at: parsed ? sectionInsertIndex(parsed, section) : 2,
594
+ };
595
+ }
596
+
597
+ export function addIndexEntry(dir, { file, section = null, title = null, hook = null }) {
598
+ const memory = requireIndexable(dir, file);
599
+
600
+ return editIndex(dir, {
601
+ op: 'add',
602
+ label: `index entry for ${file}`,
603
+ apply: (current) => {
604
+ const line = composeEntry(current, memory, title ?? memory.name, hook ?? memory.description);
605
+ if (current === null) return { text: `# Memory\n\n${line}\n`, line, at: 2, added: true };
606
+
607
+ const parsed = parseIndex(current);
608
+ if (parsed.entries.some((entry) => entry.file === file)) {
609
+ throw new Error(`MEMORY.md already has an entry for ${file}`);
610
+ }
611
+ const result = insertIndexEntry(current, sectionInsertIndex(parsed, section), line);
612
+ return { text: result.text, line, at: result.index, added: true };
613
+ },
614
+ });
615
+ }
616
+
617
+ function resolveTargets(memories) {
618
+ const byName = new Map();
619
+ const byStem = new Map();
620
+ for (const memory of memories) {
621
+ if (!byName.has(memory.name)) byName.set(memory.name, memory.file);
622
+ if (!byStem.has(memory.stem)) byStem.set(memory.stem, memory.file);
623
+ }
624
+ return (target) => byName.get(target) || byStem.get(target) || null;
625
+ }
626
+
627
+ function loadAll(dir) {
628
+ return listMemoryFiles(dir).map((file) => loadMemory(dir, file)).filter(Boolean);
629
+ }
630
+
631
+ function mergePlan(dir, into, from) {
632
+ if (into === from) throw new Error('A memory cannot be merged into itself');
633
+ const target = requireIndexable(dir, into);
634
+ const source = requireIndexable(dir, from);
635
+
636
+ const memories = loadAll(dir);
637
+ const resolve = resolveTargets(memories);
638
+ const pointsAtSource = (link) => resolve(link) === source.file;
639
+
640
+ const inbound = [];
641
+ const selfLinks = [];
642
+ for (const memory of memories) {
643
+ if (memory.file === source.file) continue;
644
+ const targets = memory.outbound.filter(pointsAtSource);
645
+ if (!targets.length) continue;
646
+ if (memory.file === target.file) selfLinks.push(...targets);
647
+ else inbound.push({ file: memory.file, name: memory.name, targets });
648
+ }
649
+
650
+ const indexText = readIndex(dir);
651
+ const parsed = indexText === null ? null : parseIndex(indexText);
652
+ const sourceEntries = parsed ? parsed.entries.filter((entry) => entry.file === from) : [];
653
+ const inlineRefs = parsed
654
+ ? parsed.inlineLinks.filter((link) => link.file === from).map((link) => ({ index: link.index, text: link.text }))
655
+ : [];
656
+
657
+ return { target, source, inbound, selfLinks, indexText, sourceEntries, inlineRefs };
658
+ }
659
+
660
+ export function mergePreview(dir, { into, from }) {
661
+ const plan = mergePlan(dir, into, from);
662
+ return {
663
+ into,
664
+ from,
665
+ intoName: plan.target.name,
666
+ fromName: plan.source.name,
667
+ heading: plan.source.name,
668
+ bodyLines: plan.source.body.trim() ? plan.source.body.trim().split('\n').length : 0,
669
+ inbound: plan.inbound,
670
+ selfLinks: plan.selfLinks,
671
+ indexLines: plan.sourceEntries.map((entry) => ({ index: entry.index, text: entry.text })),
672
+ inlineRefs: plan.inlineRefs,
673
+ hasIndex: plan.indexText !== null,
674
+ };
675
+ }
676
+
677
+ export function mergeMemories(dir, { into, from, heading = null }) {
678
+ const plan = mergePlan(dir, into, from);
679
+ const { target, source } = plan;
680
+
681
+ const title = String(heading ?? '').trim() || source.name;
682
+ if (/[\r\n]/.test(title)) throw new Error('A heading has to stay on one line');
683
+
684
+ const body = source.body.trim();
685
+ const joined = `${target.raw.replace(/\s*$/, '')}\n\n## ${title}\n\n${body}\n`;
686
+ const merged = plan.selfLinks.reduce((text, link) => unwrapWikilink(text, link).text, joined);
687
+
688
+ const rewrites = [{ file: target.file, text: merged }];
689
+ for (const entry of plan.inbound) {
690
+ const memory = loadMemory(dir, entry.file);
691
+ if (!memory) continue;
692
+ const text = entry.targets.reduce((current, link) => retargetWikilink(current, link, target.name).text, memory.raw);
693
+ if (text !== memory.raw) rewrites.push({ file: entry.file, text });
694
+ }
695
+
696
+ const indexBefore = plan.indexText;
697
+ const indexAfter = indexBefore === null ? null : removeIndexEntries(indexBefore, [from]);
698
+
699
+ const trashPath = trashDir(dir);
700
+ const stamp = timestamp();
701
+ const backups = [];
702
+
703
+ for (const rewrite of rewrites) {
704
+ const memory = loadMemory(dir, rewrite.file);
705
+ backups.push({
706
+ file: rewrite.file,
707
+ backupFile: backupInto(trashPath, `${stamp}_${rewrite.file}.before-merge.md`, memory.raw),
708
+ });
709
+ }
710
+ if (indexBefore !== null) {
711
+ backups.push({
712
+ file: 'MEMORY.md',
713
+ backupFile: backupInto(trashPath, `${stamp}_MEMORY.md.before-merge.md`, indexBefore),
714
+ });
715
+ }
716
+
717
+ const trashedFile = `${stamp}_${from}`;
718
+ const done = [];
719
+ try {
720
+ for (const rewrite of rewrites) {
721
+ writeFileAtomic(dir, rewrite.file, rewrite.text);
722
+ done.push(rewrite.file);
723
+ }
724
+ if (indexAfter && indexAfter.text !== indexBefore) writeIndexAtomic(dir, indexAfter.text);
725
+ fs.renameSync(path.join(dir, source.file), path.join(trashPath, trashedFile));
726
+ } catch (err) {
727
+ for (const backup of backups) {
728
+ try {
729
+ writeFileAtomic(dir, backup.file, fs.readFileSync(path.join(trashPath, backup.backupFile), 'utf8'));
730
+ } catch { /* best effort */ }
731
+ }
732
+ for (const backup of backups) {
733
+ try { fs.unlinkSync(path.join(trashPath, backup.backupFile)); } catch { /* best effort */ }
734
+ }
735
+ throw err;
736
+ }
737
+
738
+ const record = {
739
+ version: 2,
740
+ kind: 'merge',
741
+ id: `${stamp}_merge_${from}`,
742
+ label: `${source.name} into ${target.name}`,
743
+ deletedAt: new Date().toISOString(),
744
+ into: target.file,
745
+ from: source.file,
746
+ heading: title,
747
+ backups,
748
+ files: [{ file: source.file, trashedFile, name: source.name, description: source.description }],
749
+ removedLines: indexAfter ? indexAfter.removed : [],
750
+ indexSha256Before: indexBefore === null ? null : sha256(indexBefore),
751
+ indexSha256After: indexAfter === null ? null : sha256(indexAfter.text),
752
+ };
753
+ fs.writeFileSync(path.join(trashPath, `${record.id}.restore.json`), JSON.stringify(record, null, 2));
754
+
755
+ return {
756
+ merged: true,
757
+ record,
758
+ rewritten: rewrites.map((rewrite) => rewrite.file),
759
+ retargeted: plan.inbound.length,
760
+ unwrapped: plan.selfLinks.length,
761
+ };
762
+ }
package/src/parse.mjs CHANGED
@@ -253,3 +253,179 @@ export function unwrapWikilink(text, target) {
253
253
  });
254
254
  return { text: out, count };
255
255
  }
256
+
257
+ function splitEntryLine(line) {
258
+ const match = line.match(INDEX_LINE);
259
+ if (!match || match[1] !== '') return null;
260
+ const file = match[3];
261
+ const marker = `](${file})`;
262
+ const linkEnd = line.indexOf(marker) + marker.length;
263
+ const tail = line.slice(linkEnd);
264
+ const separator = tail.match(HOOK_SEP);
265
+ return {
266
+ head: line.slice(0, linkEnd),
267
+ title: match[2].trim(),
268
+ file,
269
+ separator: separator ? separator[0] : null,
270
+ hook: tail.replace(HOOK_SEP, ''),
271
+ };
272
+ }
273
+
274
+ function normalizeSeparator(raw) {
275
+ const char = raw.trim();
276
+ return char === ':' ? ': ' : ` ${char} `;
277
+ }
278
+
279
+ export function dominantSeparator(parsed) {
280
+ const counts = new Map();
281
+ for (const entry of parsed.entries) {
282
+ const parts = splitEntryLine(entry.text);
283
+ if (!parts || !parts.separator) continue;
284
+ const separator = normalizeSeparator(parts.separator);
285
+ counts.set(separator, (counts.get(separator) || 0) + 1);
286
+ }
287
+ let best = ' — ';
288
+ let bestCount = 0;
289
+ for (const [separator, count] of counts) {
290
+ if (count > bestCount) {
291
+ best = separator;
292
+ bestCount = count;
293
+ }
294
+ }
295
+ return best;
296
+ }
297
+
298
+ function requireLine(lines, index, expectedText) {
299
+ if (!Number.isInteger(index) || index < 0 || index >= lines.length) {
300
+ throw new Error(`Line ${index} is out of range`);
301
+ }
302
+ if (typeof expectedText === 'string' && lines[index] !== expectedText) {
303
+ throw new Error('MEMORY.md changed since this was loaded - reload and try again');
304
+ }
305
+ }
306
+
307
+ export function setIndexHook(text, index, expectedText, hook) {
308
+ const lines = text.split('\n');
309
+ requireLine(lines, index, expectedText);
310
+
311
+ const parts = splitEntryLine(lines[index]);
312
+ if (!parts) throw new Error(`Line ${index + 1} is not an index entry`);
313
+
314
+ const next = String(hook ?? '');
315
+ if (/[\r\n]/.test(next)) throw new Error('A hook has to stay on one line');
316
+ const trimmed = next.trim();
317
+
318
+ const separator = parts.separator || dominantSeparator(parseIndex(text));
319
+ const before = lines[index];
320
+ lines[index] = trimmed ? `${parts.head}${separator}${trimmed}` : parts.head;
321
+ return { text: lines.join('\n'), before, after: lines[index] };
322
+ }
323
+
324
+ export function entryBlock(lines, index) {
325
+ const block = [index];
326
+ for (let i = index + 1; i < lines.length; i++) {
327
+ if (!lines[i].trim()) break;
328
+ if (!/^[ \t]/.test(lines[i])) break;
329
+ block.push(i);
330
+ }
331
+ return block;
332
+ }
333
+
334
+ export function moveIndexEntry(text, index, expectedText, toIndex) {
335
+ const lines = text.split('\n');
336
+ requireLine(lines, index, expectedText);
337
+ if (!splitEntryLine(lines[index])) throw new Error(`Line ${index + 1} is not an index entry`);
338
+
339
+ const block = entryBlock(lines, index);
340
+ const target = Number(toIndex);
341
+ if (!Number.isInteger(target) || target < 0 || target > lines.length) {
342
+ throw new Error(`Cannot move to line ${toIndex}`);
343
+ }
344
+ if (target >= index && target <= block[block.length - 1] + 1) {
345
+ throw new Error('That target is inside the entry being moved');
346
+ }
347
+
348
+ const moved = block.map((i) => ({ index: i, text: lines[i] }));
349
+ const dropped = new Set(block);
350
+ const kept = lines.filter((_, i) => !dropped.has(i));
351
+ const at = target - block.filter((i) => i < target).length;
352
+ kept.splice(at, 0, ...moved.map((entry) => entry.text));
353
+ return { text: kept.join('\n'), moved, toIndex: at };
354
+ }
355
+
356
+ export function retargetWikilink(text, from, to) {
357
+ let count = 0;
358
+ const pattern = new RegExp(WIKILINK.source, 'g');
359
+ const out = text.replace(pattern, (match, name, alias) => {
360
+ if (name.trim() !== from) return match;
361
+ count += 1;
362
+ return alias === undefined ? `[[${to}]]` : `[[${to}|${alias}]]`;
363
+ });
364
+ return { text: out, count };
365
+ }
366
+
367
+ function endOfContent(lines, limit, floor = 0) {
368
+ let at = limit;
369
+ while (at > floor && !lines[at - 1].trim()) at -= 1;
370
+ return at;
371
+ }
372
+
373
+ export function sectionInsertIndex(parsed, section) {
374
+ const heading = section
375
+ ? parsed.parsedLines.find((line) => line.kind === 'heading' && line.section === section)
376
+ : null;
377
+ if (!heading) return endOfContent(parsed.lines, parsed.lines.length);
378
+
379
+ let end = parsed.lines.length;
380
+ for (const line of parsed.parsedLines) {
381
+ if (line.kind === 'heading' && line.index > heading.index && line.level <= heading.level) {
382
+ end = line.index;
383
+ break;
384
+ }
385
+ }
386
+
387
+ const entries = parsed.entries.filter((entry) => entry.index > heading.index && entry.index < end);
388
+ if (entries.length) {
389
+ const last = entries[entries.length - 1];
390
+ return entryBlock(parsed.lines, last.index).pop() + 1;
391
+ }
392
+
393
+ return endOfContent(parsed.lines, end, heading.index + 1);
394
+ }
395
+
396
+ export function insertIndexEntry(text, at, line) {
397
+ const lines = text.split('\n');
398
+ const target = Math.max(0, Math.min(Number(at), lines.length));
399
+ lines.splice(target, 0, line);
400
+ return { text: lines.join('\n'), index: target };
401
+ }
402
+
403
+ export function topInsertIndex(parsed) {
404
+ const { lines } = parsed;
405
+ let at = 0;
406
+
407
+ if (lines.length && lines[0].trim() === '---') {
408
+ for (let i = 1; i < lines.length; i++) {
409
+ if (lines[i].trim() === '---') {
410
+ at = i + 1;
411
+ break;
412
+ }
413
+ }
414
+ }
415
+
416
+ while (at < lines.length && !lines[at].trim()) at += 1;
417
+ if (parsed.parsedLines.some((line) => line.index === at && line.kind === 'heading')) {
418
+ at += 1;
419
+ while (at < lines.length && !lines[at].trim()) at += 1;
420
+ }
421
+ return at;
422
+ }
423
+
424
+ export function sectionStartIndex(parsed, section) {
425
+ const heading = parsed.parsedLines.find((line) => line.kind === 'heading' && line.section === section);
426
+ if (!heading) return topInsertIndex(parsed);
427
+
428
+ let at = heading.index + 1;
429
+ while (at < parsed.lines.length && !parsed.lines[at].trim()) at += 1;
430
+ return at;
431
+ }