claude-memory-admin 1.5.0 → 1.5.2

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,126 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { el, node } from '/dom.mjs';
3
+ import { state, worstSeverity } from '/state.mjs';
4
+ import { paint } from '/bus.mjs';
5
+ import { openRememberPathDialog, forgetProjectPath } from '/dialogs/remember-path.mjs';
6
+
7
+ const TABS = [
8
+ { id: 'memories', label: 'Memories' },
9
+ { id: 'index', label: 'MEMORY.md' },
10
+ { id: 'graph', label: 'Graph' },
11
+ { id: 'prune', label: 'Prune' },
12
+ { id: 'context', label: 'Context' },
13
+ { id: 'sessions', label: 'Sessions' },
14
+ { id: 'health', label: 'Health' },
15
+ { id: 'settings', label: 'Settings' },
16
+ { id: 'trash', label: 'Trash' },
17
+ ];
18
+
19
+ export function renderTabs() {
20
+ const container = el('tabs');
21
+ container.textContent = '';
22
+ const global = state.store.kind === 'global';
23
+ const hasProjectDir = state.store.kind === 'auto'
24
+ ? state.store.resolvedBy !== 'unresolved'
25
+ : Boolean(state.store.projectPath);
26
+
27
+ for (const tab of TABS) {
28
+ if (global && tab.id !== 'context') continue;
29
+ if (!global && tab.id === 'context' && !hasProjectDir) continue;
30
+ if (tab.id === 'sessions' && !state.store.sessions?.count) continue;
31
+ let badge = null;
32
+ let tone = 'neutral';
33
+ if (tab.id === 'memories') badge = String(state.store.memories.length);
34
+ if (tab.id === 'trash' && state.store.trash.length) badge = String(state.store.trash.length);
35
+ if (tab.id === 'health' && state.store.health.issueCount) {
36
+ badge = String(state.store.health.issueCount);
37
+ tone = state.store.health.severity;
38
+ }
39
+ if (tab.id === 'prune' && state.store.stats.index.level !== 'ok') {
40
+ badge = '!';
41
+ tone = state.store.stats.index.level === 'over' ? 'bad' : 'warn';
42
+ }
43
+ if (tab.id === 'sessions' && state.store.sessions) {
44
+ badge = String(state.store.sessions.count);
45
+ if (state.store.sessions.expiringCount) tone = 'warn';
46
+ }
47
+ if (tab.id === 'context' && state.aux.instructions?.problems.length) {
48
+ badge = String(state.aux.instructions.problems.length);
49
+ tone = worstSeverity(state.aux.instructions.problems);
50
+ }
51
+ if (tab.id === 'settings' && state.aux.settings?.problems.length) {
52
+ badge = String(state.aux.settings.problems.length);
53
+ tone = worstSeverity(state.aux.settings.problems);
54
+ }
55
+
56
+ container.append(node('button', {
57
+ class: ui.tab(state.tab === tab.id),
58
+ onclick: () => { state.tab = tab.id; paint('tabs', 'tab'); },
59
+ }, [
60
+ document.createTextNode(tab.label),
61
+ badge ? node('span', { class: ui.tabBadge(tone), text: badge }) : null,
62
+ ]));
63
+ }
64
+ }
65
+
66
+ export function renderStoreHeader() {
67
+ const store = state.store;
68
+ el('project-title').textContent = store.label;
69
+ el('delete-project').hidden = store.kind === 'global';
70
+
71
+ const sub = el('project-sub');
72
+ sub.textContent = '';
73
+
74
+ if (store.kind === 'global') {
75
+ sub.append(node('span', { text: store.dir }));
76
+ sub.append(node('span', { class: ui.subNote, text: 'instructions every session loads, before any project is chosen' }));
77
+ return;
78
+ }
79
+
80
+ if (store.kind !== 'auto') {
81
+ const scope = store.kind === 'agent-user' ? 'user'
82
+ : store.kind === 'agent-project' ? 'project' : 'local';
83
+ sub.append(node('span', { text: store.dir }));
84
+ sub.append(node('span', { class: ui.subNote, text: `subagent memory, ${scope} scope` }));
85
+ if (store.kind === 'agent-project') {
86
+ sub.append(node('span', { class: ui.subWarn, text: 'checked into the repository - changes here show up in git status' }));
87
+ }
88
+ return;
89
+ }
90
+
91
+ sub.append(node('span', { text: store.resolvedBy === 'unresolved'
92
+ ? `${store.path} (real path unresolved)`
93
+ : store.path }));
94
+
95
+ const others = (store.workingDirs || []).filter((d) => d !== store.path);
96
+ if (others.length) {
97
+ sub.append(node('span', { class: ui.subNote, text: `also used from ${others.join(', ')}` }));
98
+ }
99
+ if (store.resolvedBy === 'unresolved') {
100
+ sub.append(node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }));
101
+ } else if (store.resolvedBy === 'remembered') {
102
+ sub.append(node('span', { class: ui.subNote }, [
103
+ document.createTextNode('path remembered, not recovered from a transcript '),
104
+ node('button', { class: ui.linkButton, text: 'Forget', onclick: forgetProjectPath }),
105
+ ]));
106
+ }
107
+
108
+ const evidence = store.sessions?.evidenceExpiresInDays;
109
+ if (!store.sessions?.remembered
110
+ && (store.resolvedBy === 'transcript' || store.resolvedBy === 'repo-root')
111
+ && typeof evidence === 'number' && evidence <= 14) {
112
+ sub.append(node('span', { class: ui.subWarn }, [
113
+ document.createTextNode(evidence === 0
114
+ ? 'the last transcript proving this path is due to be swept - the store keeps its memories and loses its name '
115
+ : `the last transcript proving this path is swept in ${evidence} days, and nothing else records it `),
116
+ node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }),
117
+ ]));
118
+ }
119
+
120
+ const auto = store.autoMemory;
121
+ if (auto && auto.known && !auto.enabled) {
122
+ sub.append(node('span', { class: ui.subWarn, text: auto.scope === 'env'
123
+ ? 'auto memory is off (CLAUDE_CODE_DISABLE_AUTO_MEMORY) - this store will not grow'
124
+ : `auto memory is off (${auto.setBy}) - this store will not grow` }));
125
+ }
126
+ }
@@ -0,0 +1,21 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { state } from '/state.mjs';
4
+ import { renderIssue } from '/views/issue.mjs';
5
+ import { renderPathCheck } from '/views/path-check.mjs';
6
+
7
+ export function renderHealth(container) {
8
+ const { health, memories } = state.store;
9
+
10
+ if (!health.issues.length) {
11
+ container.append(node('div', { class: ui.card }, [
12
+ node('p', { class: ui.okLine, text: '\u2713 No consistency problems found.' }),
13
+ node('p', { class: ui.noteTight, text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
14
+ ]));
15
+ } else {
16
+ for (const item of health.issues) container.append(renderIssue(item, memories));
17
+ }
18
+
19
+ container.append(node('div', { class: ui.sectionLabel, text: 'Beyond the memory directory' }));
20
+ renderPathCheck(container);
21
+ }
@@ -0,0 +1,122 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { renderMarkdown } from '/markdown.mjs';
4
+ import { api, toast } from '/api.mjs';
5
+ import { state } from '/state.mjs';
6
+ import { paint } from '/bus.mjs';
7
+ import { openStore, selectMemory, restoreFromTrash } from '/store.mjs';
8
+ import { cutMarker } from '/parts.mjs';
9
+ import { memoryLookup, openTarget } from '/views/memories.mjs';
10
+
11
+ const INDEX_VIEWS = [
12
+ { id: 'rendered', label: 'Rendered' },
13
+ { id: 'source', label: 'Source' },
14
+ ];
15
+
16
+ function setIndexView(view) {
17
+ state.indexView = view;
18
+ localStorage.setItem('memoryIndexView', view);
19
+ paint('tab');
20
+ }
21
+
22
+ function indexViewPicker() {
23
+ return node('div', { class: ui.segmentGroup, role: 'tablist' }, INDEX_VIEWS.map((view) => node('button', {
24
+ class: ui.segment(state.indexView === view.id),
25
+ text: view.label,
26
+ role: 'tab',
27
+ 'aria-selected': String(state.indexView === view.id),
28
+ onclick: () => setIndexView(view.id),
29
+ })));
30
+ }
31
+
32
+ export function renderIndex(container) {
33
+ const project = state.store;
34
+ if (!project.hasIndex) {
35
+ container.append(node('p', { class: ui.note, text: 'This project has no MEMORY.md.' }));
36
+ return;
37
+ }
38
+
39
+ const card = node('div', { class: ui.card });
40
+ card.append(node('div', { class: ui.cardHeadRow }, [
41
+ node('p', { class: ui.note, text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }),
42
+ indexViewPicker(),
43
+ ]));
44
+
45
+ if (state.indexView === 'source') renderIndexSource(card, project);
46
+ else renderIndexRendered(card, project);
47
+
48
+ container.append(card);
49
+ }
50
+
51
+ function renderIndexRendered(card, project) {
52
+ const { cutoff } = project.stats.index;
53
+ const files = new Set(project.memories.map((memory) => memory.file));
54
+ const byName = memoryLookup(project);
55
+ const options = {
56
+ resolveWikilink: (target) => openTarget(byName.get(target)),
57
+ resolveHref: (href) => openTarget(files.has(href) ? href : null),
58
+ };
59
+
60
+ const lines = project.index.raw.split('\n');
61
+ const loaded = node('div', { class: ui.prose });
62
+ renderMarkdown(loaded, cutoff ? lines.slice(0, cutoff.rawLine).join('\n') : project.index.raw, options);
63
+ card.append(loaded);
64
+
65
+ if (!cutoff) return;
66
+ card.append(cutMarker(cutoff));
67
+ const dropped = node('div', { class: ui.proseDim });
68
+ renderMarkdown(dropped, lines.slice(cutoff.rawLine).join('\n'), options);
69
+ card.append(dropped);
70
+ }
71
+
72
+ function renderIndexSource(card, project) {
73
+ const missing = new Set(project.health.danglingIndex.map((d) => d.index));
74
+ const { cutoff } = project.stats.index;
75
+
76
+ for (const line of project.index.lines) {
77
+ if (cutoff && line.index === cutoff.rawLine) card.append(cutMarker(cutoff));
78
+
79
+ const dangling = missing.has(line.index);
80
+ const dropped = Boolean(cutoff) && line.index >= cutoff.rawLine;
81
+ const clickable = line.kind === 'index' && !dangling;
82
+ const content = node('span', {
83
+ class: ui.indexLineText({ kind: line.kind, clickable, dropped }),
84
+ text: line.text || ' ',
85
+ });
86
+ if (clickable) content.onclick = () => selectMemory(line.file);
87
+
88
+ card.append(node('div', { class: ui.indexLine({ dangling, dropped }) }, [
89
+ node('span', { class: ui.indexLineNumber, text: String(line.index + 1) }),
90
+ content,
91
+ ]));
92
+ }
93
+ }
94
+
95
+ export function indexEntryAt(lineIndex) {
96
+ return state.store.index?.entries.find((entry) => entry.index === lineIndex) || null;
97
+ }
98
+
99
+ export function sectionsAbove(limit) {
100
+ const seen = [];
101
+ for (const line of state.store.index?.lines || []) {
102
+ if (line.kind !== 'heading') continue;
103
+ if (limit !== null && line.index >= limit) continue;
104
+ if (line.section && !seen.includes(line.section)) seen.push(line.section);
105
+ }
106
+ return seen;
107
+ }
108
+
109
+ export async function runIndexEdit(action, body, message) {
110
+ try {
111
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/${action}`, {
112
+ method: 'POST',
113
+ body: JSON.stringify(body),
114
+ });
115
+ await openStore(state.storeId, { keepTab: true });
116
+ toast(message(result), { action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) } });
117
+ return result;
118
+ } catch (err) {
119
+ toast(err.message, { error: true });
120
+ return null;
121
+ }
122
+ }
@@ -0,0 +1,270 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { confirmDialog } from '/dialog.mjs';
4
+ import { api, toast } from '/api.mjs';
5
+ import { state } from '/state.mjs';
6
+ import { issue, goToTab } from '/parts.mjs';
7
+ import { openStore, selectMemory, restoreFromTrash } from '/store.mjs';
8
+ import { openDeleteDialog } from '/dialogs/delete.mjs';
9
+ import { openAddEntryDialog } from '/dialogs/add-entry.mjs';
10
+
11
+ async function removeIndexLine(lineIndex, expectedText, done) {
12
+ const go = await confirmDialog({
13
+ title: `Remove line ${lineIndex + 1} from MEMORY.md?`,
14
+ confirmLabel: 'Remove',
15
+ tone: 'danger',
16
+ body: [
17
+ node('div', { class: ui.willBlock }, [
18
+ node('div', { class: ui.willTitle, text: 'Will be removed' }),
19
+ node('div', { class: ui.willItem('remove'), text: expectedText }),
20
+ ]),
21
+ node('p', { class: ui.noteTight, text: 'Only this line goes. The memory file it points at is left on disk.' }),
22
+ ],
23
+ });
24
+ if (!go) return;
25
+ try {
26
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
27
+ method: 'POST',
28
+ body: JSON.stringify({ lineIndex, expectedText }),
29
+ });
30
+ toast(done);
31
+ await openStore(state.storeId, { keepTab: true });
32
+ } catch (err) {
33
+ toast(err.message, { error: true });
34
+ }
35
+ }
36
+
37
+ export function renderIssue(item, memories) {
38
+ const bad = item.severity === 'bad';
39
+
40
+ if (item.kind === 'dangling-index') {
41
+ const { entry } = item;
42
+ return issue(
43
+ `MEMORY.md points at a file that does not exist: ${entry.file}`,
44
+ `line ${entry.index + 1}: ${entry.text}`,
45
+ {
46
+ bad,
47
+ action: {
48
+ label: 'Remove pointer',
49
+ run: () => removeIndexLine(entry.index, entry.text, 'Pointer removed'),
50
+ },
51
+ },
52
+ );
53
+ }
54
+
55
+ if (item.kind === 'dangling-wikilink') {
56
+ const { link } = item;
57
+ return issue(
58
+ `Broken [[${link.target}]]`,
59
+ `referenced from ${link.from} - no memory has that name`,
60
+ {
61
+ bad,
62
+ secondary: { label: 'Open source', run: () => selectMemory(link.from) },
63
+ action: {
64
+ label: 'Remove link',
65
+ run: async () => {
66
+ const go = await confirmDialog({
67
+ title: `Remove [[${link.target}]] from ${link.from}?`,
68
+ confirmLabel: 'Remove link',
69
+ tone: 'danger',
70
+ body: [
71
+ node('p', { class: ui.noteTight, text: 'The link markup goes, the words stay.' }),
72
+ node('div', { class: ui.willBlock }, [
73
+ node('div', { class: ui.willTitle, text: 'Will change' }),
74
+ node('div', { class: ui.willItem('remove'), text: `see [[${link.target}]]` }),
75
+ node('div', { class: ui.willItem('keep'), text: `see ${link.target}` }),
76
+ ]),
77
+ node('p', { class: ui.noteTight, text: 'A copy of the file is kept in the trash, so this can be undone.' }),
78
+ ],
79
+ });
80
+ if (!go) return;
81
+ try {
82
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/wikilink/remove`, {
83
+ method: 'POST',
84
+ body: JSON.stringify({ file: link.from, target: link.target }),
85
+ });
86
+ await openStore(state.storeId, { keepTab: true });
87
+ toast(`Unlinked ${result.occurrences} reference(s)`, {
88
+ action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
89
+ });
90
+ } catch (err) {
91
+ toast(err.message, { error: true });
92
+ }
93
+ },
94
+ },
95
+ },
96
+ );
97
+ }
98
+
99
+ if (item.kind === 'orphan') {
100
+ const memory = memories.find((m) => m.file === item.file);
101
+ return issue(
102
+ `Not referenced anywhere in MEMORY.md: ${item.file}`,
103
+ `"${memory?.name || item.file}" exists on disk but nothing points to it, so Claude will not load it.`,
104
+ {
105
+ bad,
106
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
107
+ action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
108
+ },
109
+ );
110
+ }
111
+
112
+ if (item.kind === 'referenced-only') {
113
+ const memory = memories.find((m) => m.file === item.file);
114
+ return issue(
115
+ `Linked mid-sentence, not indexed: ${item.file}`,
116
+ `"${memory?.name || item.file}" is mentioned inside prose in MEMORY.md but has no index bullet of its own.`,
117
+ {
118
+ bad,
119
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
120
+ action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
121
+ },
122
+ );
123
+ }
124
+
125
+ if (item.kind === 'name-mismatch') {
126
+ return issue(
127
+ `Frontmatter name differs from filename: ${item.mismatch.file}`,
128
+ `name: "${item.mismatch.name}" - [[wikilinks]] must use this name, not the filename.`,
129
+ { bad, action: { label: 'Open', run: () => selectMemory(item.mismatch.file) } },
130
+ );
131
+ }
132
+
133
+ if (item.kind === 'missing-frontmatter') {
134
+ return issue(
135
+ `No YAML frontmatter: ${item.file}`,
136
+ 'Type and description cannot be read from this file.',
137
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
138
+ );
139
+ }
140
+
141
+ if (item.kind === 'long-hooks') {
142
+ return issue(
143
+ `${item.count} index hook${item.count === 1 ? '' : 's'} over 200 characters`,
144
+ `longest is ${item.longest.hookLength} chars on "${item.longest.title}" - hooks load every session, so these are the cheapest thing to trim.`,
145
+ {
146
+ bad,
147
+ action: { label: 'Open Prune', run: () => goToTab('prune') },
148
+ },
149
+ );
150
+ }
151
+
152
+ if (item.kind === 'duplicate-name') {
153
+ return issue(
154
+ `Two memories claim the name "${item.name}"`,
155
+ `${item.files.join(', ')} - every [[${item.name}]] resolves to ${item.reachable}, so the other one cannot be linked at all.`,
156
+ {
157
+ bad,
158
+ secondary: { label: 'Open first', run: () => selectMemory(item.files[0]) },
159
+ action: { label: 'Open second', run: () => selectMemory(item.files[1]) },
160
+ },
161
+ );
162
+ }
163
+
164
+ if (item.kind === 'duplicate-index-entry') {
165
+ return issue(
166
+ `Listed twice in MEMORY.md: ${item.file}`,
167
+ `lines ${item.lines.map((line) => line + 1).join(' and ')} - one memory, two index lines, both loaded every session.`,
168
+ {
169
+ bad,
170
+ action: {
171
+ label: 'Remove duplicate',
172
+ run: () => removeIndexLine(item.removable.index, item.removable.text, 'Duplicate removed'),
173
+ },
174
+ },
175
+ );
176
+ }
177
+
178
+ if (item.kind === 'missing-description') {
179
+ return issue(
180
+ `No description: ${item.file}`,
181
+ 'The description is what a memory is recalled by, so this one is found on its name alone.',
182
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
183
+ );
184
+ }
185
+
186
+ if (item.kind === 'unknown-type') {
187
+ return issue(
188
+ `Unrecognised type: ${item.file}`,
189
+ `type: "${item.type}" - expected one of user, feedback, project, reference.`,
190
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
191
+ );
192
+ }
193
+
194
+ if (item.kind === 'empty-body') {
195
+ const memory = memories.find((m) => m.file === item.file);
196
+ return issue(
197
+ `Almost nothing in it: ${item.file}`,
198
+ `"${memory?.name || item.file}" has ${item.chars} characters of body - it costs an index line every session and says nothing.`,
199
+ {
200
+ bad,
201
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
202
+ action: { label: 'Delete', run: () => openDeleteDialog(item.file) },
203
+ },
204
+ );
205
+ }
206
+
207
+ if (item.kind === 'hook-repeats-description') {
208
+ return issue(
209
+ `Hook repeats the description: ${item.file}`,
210
+ `MEMORY.md line ${item.index + 1} - ${item.hookLength} characters restating what the file's own description already says. Only the hook loads every session.`,
211
+ {
212
+ bad,
213
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
214
+ action: { label: 'Open Prune', run: () => goToTab('prune') },
215
+ },
216
+ );
217
+ }
218
+
219
+ if (item.kind === 'empty-section') {
220
+ return issue(
221
+ `Empty section in MEMORY.md: "${item.section}"`,
222
+ `line ${item.index + 1} - the heading has no entries under it, and headings count against the 200-line budget too.`,
223
+ { bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
224
+ );
225
+ }
226
+
227
+ if (item.kind === 'stale-path') {
228
+ return issue(
229
+ `No file matches ${item.token}`,
230
+ `named by "${item.name || item.file}"`,
231
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
232
+ );
233
+ }
234
+
235
+ if (item.kind === 'index-continuation') {
236
+ return issue(
237
+ `${item.count} index ${item.count === 1 ? 'entry spills' : 'entries spill'} onto extra lines`,
238
+ `${item.extraLines} extra line${item.extraLines === 1 ? '' : 's'} of the 200-line budget - the guidance is one line per entry, with the detail in the topic file.`,
239
+ { bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
240
+ );
241
+ }
242
+
243
+ if (item.kind === 'provenance-expired') {
244
+ return issue(
245
+ `Provenance gone: ${item.name}`,
246
+ `the session it was written in (${item.sessionId}) has been swept, so why this memory exists can no longer be traced. Nothing recovers it - judge the memory on what it says.`,
247
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
248
+ );
249
+ }
250
+
251
+ if (item.kind === 'path-evidence-expiring') {
252
+ return issue(
253
+ item.days === 0
254
+ ? 'The last transcript naming this project is due to be swept'
255
+ : `The last transcript naming this project is swept in ${item.days} days`,
256
+ `${item.path} is recovered from the session transcripts, and Claude Code drops those after ${item.retentionDays} days while never touching memory/. Once the last one goes this store shows its raw folder name instead. Remember path records it now.`,
257
+ { bad, action: { label: 'Remember path\u2026', run: openRememberPathDialog } },
258
+ );
259
+ }
260
+
261
+ if (item.kind === 'no-memory-despite-sessions') {
262
+ return issue(
263
+ `${item.sessionCount} ${item.sessionCount === 1 ? 'session' : 'sessions'}, and no memory written`,
264
+ `auto memory is on for this project, but nothing has been saved in the ${item.retentionDays} days of transcripts kept here. Either nothing was worth remembering, or memory is not being written.`,
265
+ { bad },
266
+ );
267
+ }
268
+
269
+ return issue(item.kind, JSON.stringify(item));
270
+ }