claude-memory-admin 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -11
- package/package.json +1 -1
- package/public/app.mjs +487 -20
- package/public/styles.css +1 -1
- package/public/ui.mjs +19 -1
- package/server.mjs +82 -5
- package/src/instructions.mjs +123 -21
- package/src/model.mjs +4 -1
- package/src/mutate.mjs +355 -1
- package/src/parse.mjs +176 -0
- package/src/settings.mjs +120 -19
- package/src/stores.mjs +34 -4
package/public/app.mjs
CHANGED
|
@@ -80,6 +80,7 @@ function renderBody(container, memory, project) {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
function storeSubtitle(store) {
|
|
83
|
+
if (store.kind === 'global') return store.dir;
|
|
83
84
|
if (store.kind === 'auto') return store.path;
|
|
84
85
|
const scope = store.kind === 'agent-user' ? 'user'
|
|
85
86
|
: store.kind === 'agent-project' ? 'project' : 'local';
|
|
@@ -87,10 +88,11 @@ function storeSubtitle(store) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
function storeButton(store) {
|
|
90
|
-
const
|
|
91
|
+
const global = store.kind === 'global';
|
|
92
|
+
const health = global ? 'ok' : store.memoryCount === 0 ? 'none' : 'ok';
|
|
91
93
|
const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
|
|
92
94
|
return node('button', {
|
|
93
|
-
class: ui.storeItem({ active: store.id === state.storeId, empty: !store.hasMemoryDir }),
|
|
95
|
+
class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
|
|
94
96
|
onclick: () => openStore(store.id),
|
|
95
97
|
title: store.kind === 'auto' && store.resolvedBy === 'unresolved'
|
|
96
98
|
? 'Real path could not be resolved - showing the raw folder name'
|
|
@@ -100,7 +102,7 @@ function storeButton(store) {
|
|
|
100
102
|
node('span', { class: ui.dot(health) }),
|
|
101
103
|
node('span', { class: ui.storeName, text: store.label }),
|
|
102
104
|
off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
|
|
103
|
-
node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
|
|
105
|
+
global ? null : node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
|
|
104
106
|
]),
|
|
105
107
|
node('span', { class: ui.storePath, text: storeSubtitle(store) }),
|
|
106
108
|
]);
|
|
@@ -110,22 +112,21 @@ function renderStores() {
|
|
|
110
112
|
const list = el('project-list');
|
|
111
113
|
list.textContent = '';
|
|
112
114
|
|
|
113
|
-
const visible = state.stores.filter((s) => state.showAll || s.hasMemoryDir);
|
|
115
|
+
const visible = state.stores.filter((s) => s.kind === 'global' || state.showAll || s.hasMemoryDir);
|
|
114
116
|
if (!visible.length) {
|
|
115
117
|
list.append(node('p', { class: ui.note, text: 'No memory stores found.' }));
|
|
116
118
|
return;
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
const groups = [
|
|
122
|
+
['Global', visible.filter((s) => s.kind === 'global')],
|
|
120
123
|
['Projects', visible.filter((s) => s.kind === 'auto')],
|
|
121
|
-
['Subagents', visible.filter((s) => s.kind !== 'auto')],
|
|
124
|
+
['Subagents', visible.filter((s) => s.kind !== 'auto' && s.kind !== 'global')],
|
|
122
125
|
];
|
|
123
126
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
list.append(node('div', { class: ui.sidebarGroup, text: title }));
|
|
128
|
-
}
|
|
127
|
+
const filled = groups.filter(([, stores]) => stores.length);
|
|
128
|
+
for (const [title, stores] of filled) {
|
|
129
|
+
if (filled.length > 1) list.append(node('div', { class: ui.sidebarGroup, text: title }));
|
|
129
130
|
for (const store of stores) list.append(storeButton(store));
|
|
130
131
|
}
|
|
131
132
|
}
|
|
@@ -137,18 +138,21 @@ const TABS = [
|
|
|
137
138
|
{ id: 'prune', label: 'Prune' },
|
|
138
139
|
{ id: 'context', label: 'Context' },
|
|
139
140
|
{ id: 'health', label: 'Health' },
|
|
141
|
+
{ id: 'settings', label: 'Settings' },
|
|
140
142
|
{ id: 'trash', label: 'Trash' },
|
|
141
143
|
];
|
|
142
144
|
|
|
143
145
|
function renderTabs() {
|
|
144
146
|
const container = el('tabs');
|
|
145
147
|
container.textContent = '';
|
|
148
|
+
const global = state.store.kind === 'global';
|
|
146
149
|
const hasProjectDir = state.store.kind === 'auto'
|
|
147
150
|
? state.store.resolvedBy !== 'unresolved'
|
|
148
151
|
: Boolean(state.store.projectPath);
|
|
149
152
|
|
|
150
153
|
for (const tab of TABS) {
|
|
151
|
-
if (tab.id
|
|
154
|
+
if (global && tab.id !== 'context') continue;
|
|
155
|
+
if (!global && tab.id === 'context' && !hasProjectDir) continue;
|
|
152
156
|
let badge = null;
|
|
153
157
|
if (tab.id === 'memories') badge = String(state.store.memories.length);
|
|
154
158
|
if (tab.id === 'health' && state.store.health.issueCount) badge = String(state.store.health.issueCount);
|
|
@@ -389,6 +393,7 @@ const CONTEXT_PROBLEMS = {
|
|
|
389
393
|
'glob-budget': (p) => ['Rule has too many brace expansions', `${p.file} expands to ${p.expansions} patterns, past the 1,000 budget, so it is used unexpanded and its literal braces match no file`],
|
|
390
394
|
'long-claude-md': (p) => [`${p.lines} lines, over the 200-line guidance`, `${p.file} - long files cost context every session and reduce adherence`],
|
|
391
395
|
'agents-md-not-imported': (p) => ['AGENTS.md is not loaded', `${p.file} exists, but Claude Code reads CLAUDE.md. Import it with @AGENTS.md, or symlink it.`],
|
|
396
|
+
'unreferenced-user-file': (p) => ['Nothing in the chain reaches this file', `${p.file} sits next to CLAUDE.md and loads nothing. Import it with @${p.file.split('/').pop()}, or delete it.`],
|
|
392
397
|
};
|
|
393
398
|
|
|
394
399
|
async function renderContext(container) {
|
|
@@ -403,7 +408,8 @@ async function renderContext(container) {
|
|
|
403
408
|
if (state.tab !== 'context') return;
|
|
404
409
|
container.textContent = '';
|
|
405
410
|
|
|
406
|
-
|
|
411
|
+
const global = state.store.kind === 'global';
|
|
412
|
+
if (!data.projectDir && !global) {
|
|
407
413
|
return container.append(node('p', { class: ui.note, text: 'This store is not tied to a project directory, so there are no instruction files to resolve.' }));
|
|
408
414
|
}
|
|
409
415
|
|
|
@@ -446,13 +452,33 @@ async function renderContext(container) {
|
|
|
446
452
|
if (file.kind === 'managed-settings') tags.push(node('span', { class: ui.badge(), text: 'claudeMd setting' }));
|
|
447
453
|
if (file.conditional) tags.push(node('span', { class: ui.badge('warn'), text: 'only on a path match' }));
|
|
448
454
|
|
|
449
|
-
|
|
455
|
+
const body = node('div', { class: ui.contextBody, hidden: true });
|
|
456
|
+
const bodyProse = node('div', { class: ui.prose });
|
|
457
|
+
body.append(bodyProse);
|
|
458
|
+
const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
|
|
459
|
+
let rendered = false;
|
|
460
|
+
|
|
461
|
+
card.append(node('button', {
|
|
462
|
+
class: ui.contextRowButton,
|
|
463
|
+
'aria-expanded': 'false',
|
|
464
|
+
onclick: (event) => {
|
|
465
|
+
const open = body.hidden;
|
|
466
|
+
body.hidden = !open;
|
|
467
|
+
caret.textContent = open ? '\u25be' : '\u25b8';
|
|
468
|
+
event.currentTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
469
|
+
if (open && !rendered) {
|
|
470
|
+
rendered = true;
|
|
471
|
+
renderMarkdown(bodyProse, file.text);
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
}, [
|
|
450
475
|
node('div', { class: ui.contextMain }, [
|
|
451
476
|
node('div', { class: ui.contextTags }, tags),
|
|
452
|
-
node('div', { class: ui.contextFile,
|
|
477
|
+
node('div', { class: ui.contextFile }, [caret, document.createTextNode(file.file)]),
|
|
453
478
|
]),
|
|
454
479
|
node('div', { class: ui.contextSize, text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
|
|
455
480
|
]));
|
|
481
|
+
card.append(body);
|
|
456
482
|
}
|
|
457
483
|
container.append(card);
|
|
458
484
|
|
|
@@ -559,7 +585,11 @@ function renderIssue(item, memories) {
|
|
|
559
585
|
return issue(
|
|
560
586
|
`Not referenced anywhere in MEMORY.md: ${item.file}`,
|
|
561
587
|
`"${memory?.name || item.file}" exists on disk but nothing points to it, so Claude will not load it.`,
|
|
562
|
-
{
|
|
588
|
+
{
|
|
589
|
+
bad,
|
|
590
|
+
secondary: { label: 'Open', run: () => selectMemory(item.file) },
|
|
591
|
+
action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
|
|
592
|
+
},
|
|
563
593
|
);
|
|
564
594
|
}
|
|
565
595
|
|
|
@@ -568,7 +598,11 @@ function renderIssue(item, memories) {
|
|
|
568
598
|
return issue(
|
|
569
599
|
`Linked mid-sentence, not indexed: ${item.file}`,
|
|
570
600
|
`"${memory?.name || item.file}" is mentioned inside prose in MEMORY.md but has no index bullet of its own.`,
|
|
571
|
-
{
|
|
601
|
+
{
|
|
602
|
+
bad,
|
|
603
|
+
secondary: { label: 'Open', run: () => selectMemory(item.file) },
|
|
604
|
+
action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
|
|
605
|
+
},
|
|
572
606
|
);
|
|
573
607
|
}
|
|
574
608
|
|
|
@@ -605,6 +639,402 @@ function renderIssue(item, memories) {
|
|
|
605
639
|
return issue(item.kind, JSON.stringify(item));
|
|
606
640
|
}
|
|
607
641
|
|
|
642
|
+
const SETTINGS_PROBLEMS = {
|
|
643
|
+
unparseable: (p) => [
|
|
644
|
+
`Not valid JSON: ${p.scope} settings`,
|
|
645
|
+
`${p.file} — Claude Code cannot read this file either, so every value in it is being ignored. ${p.detail}`,
|
|
646
|
+
],
|
|
647
|
+
unreadable: (p) => [
|
|
648
|
+
`Cannot be read: ${p.scope} settings`,
|
|
649
|
+
`${p.file} — ${p.detail}`,
|
|
650
|
+
],
|
|
651
|
+
'not-object': (p) => [
|
|
652
|
+
`Not a settings object: ${p.scope} settings`,
|
|
653
|
+
`${p.file} — ${p.detail}`,
|
|
654
|
+
],
|
|
655
|
+
'invalid-auto-memory-directory': (p) => [
|
|
656
|
+
'autoMemoryDirectory cannot be used',
|
|
657
|
+
`${p.file} — ${p.detail}. Claude Code accepts only an absolute or ~/-prefixed path.`,
|
|
658
|
+
],
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const show = (value) => (value === undefined ? 'unset' : JSON.stringify(value));
|
|
662
|
+
|
|
663
|
+
async function renderSettings(container) {
|
|
664
|
+
container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
|
|
665
|
+
let data;
|
|
666
|
+
try {
|
|
667
|
+
data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
|
|
668
|
+
} catch (err) {
|
|
669
|
+
container.textContent = '';
|
|
670
|
+
return container.append(node('p', { class: ui.note, text: err.message }));
|
|
671
|
+
}
|
|
672
|
+
if (state.tab !== 'settings') return;
|
|
673
|
+
container.textContent = '';
|
|
674
|
+
|
|
675
|
+
container.append(node('p', { class: ui.noteTight, text: 'What Claude Code would read for this store, in precedence order: managed policy first, then project and local settings, then your user file. This tab is read-only — it reports what is configured, it never changes it.' }));
|
|
676
|
+
|
|
677
|
+
if (!data.projectDir) {
|
|
678
|
+
container.append(node('p', { class: ui.subWarn, text: 'This store has no resolved project directory, so its project and local settings layers could not be consulted. Values below come from managed policy and your user file only.' }));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (data.problems.length) {
|
|
682
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
|
|
683
|
+
for (const problem of data.problems) {
|
|
684
|
+
const describe = SETTINGS_PROBLEMS[problem.kind];
|
|
685
|
+
const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
|
|
686
|
+
container.append(node('div', { class: ui.issue(true) }, [
|
|
687
|
+
node('div', { class: ui.issueBody }, [
|
|
688
|
+
node('div', { class: ui.issueTitle, text: title }),
|
|
689
|
+
node('div', { class: ui.issueDetail, text: detail }),
|
|
690
|
+
]),
|
|
691
|
+
]));
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (data.env.overrides) {
|
|
696
|
+
container.append(node('div', { class: ui.sectionLabel, text: 'Environment override' }));
|
|
697
|
+
container.append(node('div', { class: ui.issue(false) }, [
|
|
698
|
+
node('div', { class: ui.issueBody }, [
|
|
699
|
+
node('div', { class: ui.issueTitle, text: `${data.env.name}=${data.env.value}` }),
|
|
700
|
+
node('div', { class: ui.issueDetail, text: `Set in this shell, so it outranks every settings file and forces ${data.env.overrides} off. The store will not grow while it is set.` }),
|
|
701
|
+
]),
|
|
702
|
+
]));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
for (const entry of data.keys) {
|
|
706
|
+
const card = node('div', { class: ui.card });
|
|
707
|
+
const head = node('div', { class: ui.settingsKeyHead }, [
|
|
708
|
+
node('span', { class: ui.settingsKeyName, text: entry.key }),
|
|
709
|
+
node('span', {
|
|
710
|
+
class: ui.settingsEffective,
|
|
711
|
+
text: entry.effective ? show(entry.effective.value) : `${show(entry.fallback)} (default)`,
|
|
712
|
+
}),
|
|
713
|
+
]);
|
|
714
|
+
if (entry.effective) {
|
|
715
|
+
head.append(node('span', { class: ui.scopeBadge(entry.effective.scope), text: entry.effective.scope }));
|
|
716
|
+
}
|
|
717
|
+
card.append(head);
|
|
718
|
+
card.append(node('p', { class: ui.noteTight, text: entry.detail }));
|
|
719
|
+
|
|
720
|
+
if (entry.normalized !== undefined && entry.effective && entry.normalized !== entry.effective.value) {
|
|
721
|
+
card.append(node('div', { class: ui.issue(false) }, [
|
|
722
|
+
node('div', { class: ui.issueBody }, [
|
|
723
|
+
node('div', { class: ui.issueTitle, text: `Configured ${show(entry.effective.value)}, but ${show(entry.normalized)} applies` }),
|
|
724
|
+
node('div', { class: ui.issueDetail, text: 'Claude Code ignores a value it cannot use and falls back to the default.' }),
|
|
725
|
+
]),
|
|
726
|
+
]));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (!entry.values.length) {
|
|
730
|
+
card.append(node('p', { class: ui.note, text: 'No settings file sets this, so the built-in default applies.' }));
|
|
731
|
+
} else {
|
|
732
|
+
for (const value of entry.values) {
|
|
733
|
+
card.append(node('div', { class: ui.settingsLayerRow }, [
|
|
734
|
+
node('span', { class: ui.scopeBadge(value.scope), text: value.scope }),
|
|
735
|
+
node('span', { class: ui.settingsLayerValue(value.wins), text: show(value.value) }),
|
|
736
|
+
node('span', { class: ui.settingsLayerFile, text: value.file }),
|
|
737
|
+
value.wins ? node('span', { class: ui.badge('ok'), text: 'wins' }) : null,
|
|
738
|
+
]));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
container.append(card);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Files consulted · ${data.layers.length}` }));
|
|
745
|
+
const files = node('div', { class: ui.card });
|
|
746
|
+
for (const layer of data.layers) {
|
|
747
|
+
files.append(node('div', { class: ui.settingsLayerRow }, [
|
|
748
|
+
node('span', { class: ui.scopeBadge(layer.scope), text: layer.scope }),
|
|
749
|
+
node('span', { class: ui.settingsLayerValue(layer.status === 'ok'), text: layer.status }),
|
|
750
|
+
node('span', { class: ui.settingsLayerFile, text: layer.file }),
|
|
751
|
+
]));
|
|
752
|
+
}
|
|
753
|
+
container.append(files);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const byteLength = (text) => new TextEncoder().encode(text).length;
|
|
757
|
+
|
|
758
|
+
function indexEntryAt(lineIndex) {
|
|
759
|
+
return state.store.index?.entries.find((entry) => entry.index === lineIndex) || null;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function sectionsAbove(limit) {
|
|
763
|
+
const seen = [];
|
|
764
|
+
for (const line of state.store.index?.lines || []) {
|
|
765
|
+
if (line.kind !== 'heading') continue;
|
|
766
|
+
if (limit !== null && line.index >= limit) continue;
|
|
767
|
+
if (line.section && !seen.includes(line.section)) seen.push(line.section);
|
|
768
|
+
}
|
|
769
|
+
return seen;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function runIndexEdit(action, body, message) {
|
|
773
|
+
try {
|
|
774
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/${action}`, {
|
|
775
|
+
method: 'POST',
|
|
776
|
+
body: JSON.stringify(body),
|
|
777
|
+
});
|
|
778
|
+
await openStore(state.storeId, { keepTab: true });
|
|
779
|
+
toast(message(result), { action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) } });
|
|
780
|
+
return result;
|
|
781
|
+
} catch (err) {
|
|
782
|
+
toast(err.message, { error: true });
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function openHookEditor(row, lineIndex) {
|
|
788
|
+
const entry = indexEntryAt(lineIndex);
|
|
789
|
+
if (!entry) return toast('MEMORY.md has changed - reload and try again', { error: true });
|
|
790
|
+
|
|
791
|
+
const stats = state.store.stats.index;
|
|
792
|
+
const editor = node('div', { class: ui.hookEditor });
|
|
793
|
+
const field = node('textarea', { class: ui.textArea, spellcheck: 'false' });
|
|
794
|
+
field.value = entry.hook;
|
|
795
|
+
|
|
796
|
+
const counter = node('span');
|
|
797
|
+
const projection = node('span');
|
|
798
|
+
const readout = node('div', { class: ui.charCount }, [counter, projection]);
|
|
799
|
+
|
|
800
|
+
const save = node('button', { class: ui.buttonPrimarySmall, text: 'Save' });
|
|
801
|
+
const cancel = node('button', { class: ui.buttonSmall, text: 'Cancel', onclick: () => renderTab() });
|
|
802
|
+
|
|
803
|
+
function update() {
|
|
804
|
+
const next = field.value.trim();
|
|
805
|
+
const delta = byteLength(next) - byteLength(entry.hook);
|
|
806
|
+
const projected = Math.max(stats.linePercent, ((stats.bytes + delta) / stats.byteLimit) * 100);
|
|
807
|
+
|
|
808
|
+
counter.textContent = `${next.length} characters, was ${entry.hook.length}`;
|
|
809
|
+
projection.textContent = `index ${Math.round(stats.worstPercent)}% → ${Math.round(projected)}%`;
|
|
810
|
+
readout.className = next.length > 200 ? ui.charCountOver : ui.charCount;
|
|
811
|
+
save.disabled = next === entry.hook;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
field.addEventListener('input', update);
|
|
815
|
+
save.onclick = () => runIndexEdit(
|
|
816
|
+
'index/hook',
|
|
817
|
+
{ lineIndex, expectedText: entry.text, hook: field.value },
|
|
818
|
+
() => 'Hook shortened',
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
editor.append(
|
|
822
|
+
node('div', { class: ui.willTitle, text: 'Hook' }),
|
|
823
|
+
field,
|
|
824
|
+
readout,
|
|
825
|
+
node('div', { class: ui.hookEditorFoot }, [cancel, save]),
|
|
826
|
+
);
|
|
827
|
+
row.textContent = '';
|
|
828
|
+
row.append(editor);
|
|
829
|
+
update();
|
|
830
|
+
field.focus();
|
|
831
|
+
field.select();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function openMoveDialog(entry) {
|
|
835
|
+
const current = indexEntryAt(entry.index);
|
|
836
|
+
if (!current) return toast('MEMORY.md has changed - reload and try again', { error: true });
|
|
837
|
+
|
|
838
|
+
const { cutoff } = state.store.stats.index;
|
|
839
|
+
const sections = sectionsAbove(cutoff ? cutoff.rawLine : null);
|
|
840
|
+
const picker = node('select', { class: ui.select });
|
|
841
|
+
picker.append(node('option', { value: '', text: 'Top of MEMORY.md' }));
|
|
842
|
+
for (const section of sections) picker.append(node('option', { value: section, text: `Start of "${section}"` }));
|
|
843
|
+
|
|
844
|
+
const body = node('div', { class: ui.modalBody }, [
|
|
845
|
+
node('p', { class: ui.noteTight, text: `"${current.title}" sits at line ${current.index + 1}, past the cutoff at line ${cutoff ? cutoff.rawLine + 1 : '-'}, so Claude never loads it. Moving it up puts it back inside the loaded part of the index - which pushes whatever is now last past the cutoff instead.` }),
|
|
846
|
+
node('div', { class: ui.willBlock }, [
|
|
847
|
+
node('div', { class: ui.willTitle, text: 'Will move' }),
|
|
848
|
+
node('div', { class: ui.willItem('keep'), text: current.text }),
|
|
849
|
+
]),
|
|
850
|
+
node('label', { class: ui.willTitle, text: 'Move to' }),
|
|
851
|
+
picker,
|
|
852
|
+
]);
|
|
853
|
+
|
|
854
|
+
const modal = node('div', { class: ui.modal }, [
|
|
855
|
+
node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Move this entry above the cutoff' })]),
|
|
856
|
+
body,
|
|
857
|
+
node('footer', { class: ui.modalFoot }, [
|
|
858
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
859
|
+
node('button', {
|
|
860
|
+
class: ui.button({ tone: 'primary' }),
|
|
861
|
+
text: 'Move',
|
|
862
|
+
onclick: async () => {
|
|
863
|
+
closeModal();
|
|
864
|
+
await runIndexEdit(
|
|
865
|
+
'index/move',
|
|
866
|
+
picker.value
|
|
867
|
+
? { lineIndex: current.index, expectedText: current.text, section: picker.value }
|
|
868
|
+
: { lineIndex: current.index, expectedText: current.text, top: true },
|
|
869
|
+
() => 'Entry moved',
|
|
870
|
+
);
|
|
871
|
+
},
|
|
872
|
+
}),
|
|
873
|
+
]),
|
|
874
|
+
]);
|
|
875
|
+
|
|
876
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
877
|
+
el('modal-root').append(backdrop);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function openAddEntryDialog(file) {
|
|
881
|
+
let preview;
|
|
882
|
+
try {
|
|
883
|
+
preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/index/add-preview`, {
|
|
884
|
+
method: 'POST',
|
|
885
|
+
body: JSON.stringify({ file }),
|
|
886
|
+
});
|
|
887
|
+
} catch (err) {
|
|
888
|
+
return toast(err.message, { error: true });
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const title = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
|
|
892
|
+
title.value = preview.name;
|
|
893
|
+
const hook = node('textarea', { class: ui.textArea, spellcheck: 'false' });
|
|
894
|
+
hook.value = preview.description;
|
|
895
|
+
|
|
896
|
+
const picker = node('select', { class: ui.select });
|
|
897
|
+
for (const section of preview.sections) picker.append(node('option', { value: section, text: section }));
|
|
898
|
+
picker.append(node('option', { value: '', text: 'End of MEMORY.md' }));
|
|
899
|
+
if (preview.sections.length) picker.value = preview.sections[preview.sections.length - 1];
|
|
900
|
+
|
|
901
|
+
const line = node('div', { class: ui.willItem('keep') });
|
|
902
|
+
const update = () => {
|
|
903
|
+
const label = title.value.trim().replace(/[[\]]/g, '') || preview.name;
|
|
904
|
+
const text = hook.value.trim();
|
|
905
|
+
line.textContent = text ? `- [${label}](${file}) — ${text}` : `- [${label}](${file})`;
|
|
906
|
+
};
|
|
907
|
+
title.addEventListener('input', update);
|
|
908
|
+
hook.addEventListener('input', update);
|
|
909
|
+
update();
|
|
910
|
+
|
|
911
|
+
const body = node('div', { class: ui.modalBody }, [
|
|
912
|
+
node('p', { class: ui.noteTight, text: preview.hasIndex
|
|
913
|
+
? 'MEMORY.md is loaded at the start of every session, so a memory with no bullet here is one Claude never sees. This adds one line and nothing else.'
|
|
914
|
+
: 'This project has no MEMORY.md yet. Adding this entry creates one.' }),
|
|
915
|
+
node('label', { class: ui.willTitle, text: 'Title' }),
|
|
916
|
+
title,
|
|
917
|
+
node('label', { class: ui.willTitle, text: 'Hook' }),
|
|
918
|
+
hook,
|
|
919
|
+
node('label', { class: ui.willTitle, text: 'Section' }),
|
|
920
|
+
picker,
|
|
921
|
+
node('div', { class: ui.willBlock }, [
|
|
922
|
+
node('div', { class: ui.willTitle, text: 'Will be added' }),
|
|
923
|
+
line,
|
|
924
|
+
]),
|
|
925
|
+
]);
|
|
926
|
+
|
|
927
|
+
const modal = node('div', { class: ui.modal }, [
|
|
928
|
+
node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Add ${file} to MEMORY.md` })]),
|
|
929
|
+
body,
|
|
930
|
+
node('footer', { class: ui.modalFoot }, [
|
|
931
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
932
|
+
node('button', {
|
|
933
|
+
class: ui.button({ tone: 'primary' }),
|
|
934
|
+
text: 'Add entry',
|
|
935
|
+
onclick: async () => {
|
|
936
|
+
closeModal();
|
|
937
|
+
await runIndexEdit(
|
|
938
|
+
'index/add',
|
|
939
|
+
{ file, section: picker.value || null, title: title.value, hook: hook.value },
|
|
940
|
+
() => 'Added to MEMORY.md',
|
|
941
|
+
);
|
|
942
|
+
},
|
|
943
|
+
}),
|
|
944
|
+
]),
|
|
945
|
+
]);
|
|
946
|
+
|
|
947
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
948
|
+
el('modal-root').append(backdrop);
|
|
949
|
+
title.focus();
|
|
950
|
+
title.select();
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function openMergeDialog(into, from) {
|
|
954
|
+
let preview;
|
|
955
|
+
try {
|
|
956
|
+
preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge-preview`, {
|
|
957
|
+
method: 'POST',
|
|
958
|
+
body: JSON.stringify({ into, from }),
|
|
959
|
+
});
|
|
960
|
+
} catch (err) {
|
|
961
|
+
return toast(err.message, { error: true });
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const heading = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
|
|
965
|
+
heading.value = preview.heading;
|
|
966
|
+
|
|
967
|
+
const body = node('div', { class: ui.modalBody });
|
|
968
|
+
body.append(node('p', { class: ui.noteTight, text: `Everything in "${preview.fromName}" moves into "${preview.intoName}" under a new heading, and "${preview.fromName}" goes to the trash. This rewrites prose, which nothing else in this app does - the whole operation is one undo.` }));
|
|
969
|
+
|
|
970
|
+
const changes = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will change' })]);
|
|
971
|
+
changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} + ${preview.bodyLines} line(s) from ${preview.from}` }));
|
|
972
|
+
for (const entry of preview.inbound) {
|
|
973
|
+
changes.append(node('div', { class: ui.willItem('keep'), text: `${entry.file} ${entry.targets.map((t) => `[[${t}]]`).join(' ')} → [[${preview.intoName}]]` }));
|
|
974
|
+
}
|
|
975
|
+
for (const link of preview.selfLinks) {
|
|
976
|
+
changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} [[${link}]] → plain text, to avoid a self-link` }));
|
|
977
|
+
}
|
|
978
|
+
body.append(changes);
|
|
979
|
+
|
|
980
|
+
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
|
|
981
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.from} → memory/.trash/` }));
|
|
982
|
+
for (const line of preview.indexLines) {
|
|
983
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
|
|
984
|
+
}
|
|
985
|
+
if (!preview.indexLines.length) {
|
|
986
|
+
removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for the source - nothing to remove there.' }));
|
|
987
|
+
}
|
|
988
|
+
body.append(removals);
|
|
989
|
+
|
|
990
|
+
if (preview.inlineRefs.length) {
|
|
991
|
+
const kept = node('div', { class: ui.willBlock }, [
|
|
992
|
+
node('div', { class: ui.willTitle, text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' }),
|
|
993
|
+
]);
|
|
994
|
+
for (const ref of preview.inlineRefs) {
|
|
995
|
+
kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
|
|
996
|
+
}
|
|
997
|
+
body.append(kept);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
body.append(node('label', { class: ui.willTitle, text: 'Heading for the merged section' }), heading);
|
|
1001
|
+
|
|
1002
|
+
const modal = node('div', { class: ui.modal }, [
|
|
1003
|
+
node('header', { class: ui.modalHead }, [
|
|
1004
|
+
node('h3', { class: ui.modalTitle, text: `Merge "${preview.fromName}" into "${preview.intoName}"?` }),
|
|
1005
|
+
]),
|
|
1006
|
+
body,
|
|
1007
|
+
node('footer', { class: ui.modalFoot }, [
|
|
1008
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
1009
|
+
node('button', {
|
|
1010
|
+
class: ui.button({ tone: 'primary' }),
|
|
1011
|
+
text: 'Merge',
|
|
1012
|
+
onclick: async () => {
|
|
1013
|
+
closeModal();
|
|
1014
|
+
try {
|
|
1015
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge`, {
|
|
1016
|
+
method: 'POST',
|
|
1017
|
+
body: JSON.stringify({ into, from, heading: heading.value }),
|
|
1018
|
+
});
|
|
1019
|
+
if (state.selected === from) state.selected = into;
|
|
1020
|
+
await openStore(state.storeId, { keepTab: true });
|
|
1021
|
+
toast(`Merged into ${preview.intoName}`, {
|
|
1022
|
+
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
1023
|
+
});
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
toast(err.message, { error: true });
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
}),
|
|
1029
|
+
]),
|
|
1030
|
+
]);
|
|
1031
|
+
|
|
1032
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
1033
|
+
el('modal-root').append(backdrop);
|
|
1034
|
+
heading.focus();
|
|
1035
|
+
heading.select();
|
|
1036
|
+
}
|
|
1037
|
+
|
|
608
1038
|
async function restoreFromTrash(id) {
|
|
609
1039
|
try {
|
|
610
1040
|
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/restore`, {
|
|
@@ -620,6 +1050,12 @@ async function restoreFromTrash(id) {
|
|
|
620
1050
|
}
|
|
621
1051
|
}
|
|
622
1052
|
|
|
1053
|
+
const INDEX_EDIT_LABELS = {
|
|
1054
|
+
hook: 'MEMORY.md hook shortened',
|
|
1055
|
+
move: 'MEMORY.md entry moved',
|
|
1056
|
+
add: 'MEMORY.md entry added',
|
|
1057
|
+
};
|
|
1058
|
+
|
|
623
1059
|
function renderTrash(container) {
|
|
624
1060
|
const trash = state.store.trash;
|
|
625
1061
|
if (!trash.length) {
|
|
@@ -630,7 +1066,11 @@ function renderTrash(container) {
|
|
|
630
1066
|
const when = String(record.deletedAt).replace('T', ' ').replace(/\..*$/, '');
|
|
631
1067
|
const detail = record.kind === 'wikilink'
|
|
632
1068
|
? `unlinked in ${record.sourceFile} · ${when}`
|
|
633
|
-
:
|
|
1069
|
+
: record.kind === 'index-edit'
|
|
1070
|
+
? `${INDEX_EDIT_LABELS[record.op] || 'MEMORY.md edited'} · ${when}`
|
|
1071
|
+
: record.kind === 'merge'
|
|
1072
|
+
? `merged into ${record.into} · ${when} · ${record.backups?.length || 0} file(s) rewritten`
|
|
1073
|
+
: `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
|
|
634
1074
|
container.append(node('div', { class: ui.issue(!record.present) }, [
|
|
635
1075
|
node('div', { class: ui.issueBody }, [
|
|
636
1076
|
node('div', { class: ui.issueTitle, text: record.label || record.id }),
|
|
@@ -950,6 +1390,7 @@ function renderBudget(container) {
|
|
|
950
1390
|
node('div', { class: ui.issueDetail, text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
|
|
951
1391
|
]),
|
|
952
1392
|
node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(entry.file) }),
|
|
1393
|
+
node('button', { class: ui.buttonPrimarySmall, text: 'Move up', onclick: () => openMoveDialog(entry) }),
|
|
953
1394
|
]));
|
|
954
1395
|
}
|
|
955
1396
|
if (dropped.length > 20) {
|
|
@@ -1048,13 +1489,18 @@ function renderPrune(container) {
|
|
|
1048
1489
|
container.append(node('div', { class: ui.sectionLabel, text: `Long index hooks · ${longHooks.length}` }));
|
|
1049
1490
|
container.append(node('p', { class: ui.noteTight, text: 'The hook is the text after the dash in MEMORY.md. It is loaded every session, so a 400-character hook costs more than the memory it points at. Shortening these is the cheapest win.' }));
|
|
1050
1491
|
for (const hook of longHooks.slice(0, 12)) {
|
|
1051
|
-
|
|
1492
|
+
const row = node('div', { class: ui.issue(false) });
|
|
1493
|
+
const editButton = node('button', { class: ui.buttonPrimarySmall, text: 'Edit hook' });
|
|
1494
|
+
row.append(
|
|
1052
1495
|
node('div', { class: ui.issueBody }, [
|
|
1053
1496
|
node('div', { class: ui.issueTitle, text: `${hook.hookLength} chars: ${hook.title}` }),
|
|
1054
1497
|
node('div', { class: ui.issueDetail, text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
|
|
1055
1498
|
]),
|
|
1056
1499
|
node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(hook.file) }),
|
|
1057
|
-
|
|
1500
|
+
editButton,
|
|
1501
|
+
);
|
|
1502
|
+
editButton.onclick = () => openHookEditor(row, hook.index);
|
|
1503
|
+
container.append(row);
|
|
1058
1504
|
}
|
|
1059
1505
|
}
|
|
1060
1506
|
|
|
@@ -1076,6 +1522,18 @@ function renderPrune(container) {
|
|
|
1076
1522
|
node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
|
|
1077
1523
|
])),
|
|
1078
1524
|
]),
|
|
1525
|
+
node('div', { class: ui.dupeActions }, [
|
|
1526
|
+
node('button', {
|
|
1527
|
+
class: ui.buttonSmall,
|
|
1528
|
+
text: `Merge into ${pair.a.name}`,
|
|
1529
|
+
onclick: () => openMergeDialog(pair.a.file, pair.b.file),
|
|
1530
|
+
}),
|
|
1531
|
+
node('button', {
|
|
1532
|
+
class: ui.buttonSmall,
|
|
1533
|
+
text: `Merge into ${pair.b.name}`,
|
|
1534
|
+
onclick: () => openMergeDialog(pair.b.file, pair.a.file),
|
|
1535
|
+
}),
|
|
1536
|
+
]),
|
|
1079
1537
|
]));
|
|
1080
1538
|
}
|
|
1081
1539
|
}
|
|
@@ -1250,6 +1708,7 @@ function renderTab() {
|
|
|
1250
1708
|
else if (state.tab === 'context') renderContext(container);
|
|
1251
1709
|
else if (state.tab === 'index') renderIndex(container);
|
|
1252
1710
|
else if (state.tab === 'health') renderHealth(container);
|
|
1711
|
+
else if (state.tab === 'settings') renderSettings(container);
|
|
1253
1712
|
else if (state.tab === 'trash') renderTrash(container);
|
|
1254
1713
|
else if (state.tab === 'graph') {
|
|
1255
1714
|
const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
|
|
@@ -1289,10 +1748,17 @@ async function reloadStores() {
|
|
|
1289
1748
|
function renderStoreHeader() {
|
|
1290
1749
|
const store = state.store;
|
|
1291
1750
|
el('project-title').textContent = store.label;
|
|
1751
|
+
el('delete-project').hidden = store.kind === 'global';
|
|
1292
1752
|
|
|
1293
1753
|
const sub = el('project-sub');
|
|
1294
1754
|
sub.textContent = '';
|
|
1295
1755
|
|
|
1756
|
+
if (store.kind === 'global') {
|
|
1757
|
+
sub.append(node('span', { text: store.dir }));
|
|
1758
|
+
sub.append(node('span', { class: ui.subNote, text: 'instructions every session loads, before any project is chosen' }));
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1296
1762
|
if (store.kind !== 'auto') {
|
|
1297
1763
|
const scope = store.kind === 'agent-user' ? 'user'
|
|
1298
1764
|
: store.kind === 'agent-project' ? 'project' : 'local';
|
|
@@ -1333,7 +1799,8 @@ async function openStore(id, { keepTab = false } = {}) {
|
|
|
1333
1799
|
if (id !== state.storeId) state.pruneSelection.clear();
|
|
1334
1800
|
state.storeId = id;
|
|
1335
1801
|
if (!keepTab) {
|
|
1336
|
-
state.
|
|
1802
|
+
const opening = state.stores.find((store) => store.id === id);
|
|
1803
|
+
state.tab = opening && opening.kind === 'global' ? 'context' : 'memories';
|
|
1337
1804
|
state.selected = null;
|
|
1338
1805
|
}
|
|
1339
1806
|
renderStores();
|