claude-memory-admin 1.5.2 → 1.6.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,32 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { state } from '/state.mjs';
4
+ import { paint } from '/bus.mjs';
5
+ import { renderSegmented } from '/views/segments.mjs';
6
+ import { renderMemories } from '/views/memories.mjs';
7
+ import { renderIndex } from '/views/index.mjs';
8
+ import { renderGraph } from '/graph.mjs';
9
+ import { selectMemory } from '/store.mjs';
10
+
11
+ function renderMemoryGraph(container) {
12
+ const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
13
+ container.append(wrap);
14
+ renderGraph(wrap, state.store.graph, {
15
+ selected: state.selected,
16
+ spread: state.spread,
17
+ onSelect: (file) => selectMemory(file),
18
+ onSpreadChange: (value) => {
19
+ state.spread = value;
20
+ localStorage.setItem('graphSpread', String(value));
21
+ paint('tab');
22
+ },
23
+ });
24
+ }
25
+
26
+ export function renderMemory(container) {
27
+ return renderSegmented(container, 'memory', {
28
+ list: renderMemories,
29
+ index: renderIndex,
30
+ graph: renderMemoryGraph,
31
+ });
32
+ }
@@ -2,6 +2,7 @@ import * as ui from '/ui.mjs';
2
2
  import { node } from '/dom.mjs';
3
3
  import { api } from '/api.mjs';
4
4
  import { state } from '/state.mjs';
5
+ import { isAt } from '/parts.mjs';
5
6
  import { paint } from '/bus.mjs';
6
7
  import { renderIssue } from '/views/issue.mjs';
7
8
 
@@ -23,7 +24,7 @@ async function fetchPathCheck() {
23
24
  }
24
25
  if (state.storeId !== id) return;
25
26
  state.pathCheck = next;
26
- if (state.tab === 'health') paint('tab');
27
+ if (isAt('cleanup')) paint('tab');
27
28
  }
28
29
 
29
30
  function startPathCheck({ remember = true } = {}) {
@@ -3,7 +3,7 @@ import { el, node } from '/dom.mjs';
3
3
  import { api, toast } from '/api.mjs';
4
4
  import { state } from '/state.mjs';
5
5
  import { paint } from '/bus.mjs';
6
- import { highlight } from '/parts.mjs';
6
+ import { highlight, goTo } from '/parts.mjs';
7
7
  import { openStore } from '/store.mjs';
8
8
 
9
9
  let searchTimer = null;
@@ -52,7 +52,7 @@ export function renderSearch() {
52
52
  for (const result of group.results) {
53
53
  const button = node('button', {
54
54
  class: ui.result,
55
- onclick: () => openFromSearch(group.id, result.file),
55
+ onclick: () => openFromSearch(group.id, result.file, 'list'),
56
56
  });
57
57
  const top = node('div', { class: ui.resultTop });
58
58
  const name = node('span', { class: ui.resultName });
@@ -85,12 +85,11 @@ export function renderSearch() {
85
85
  }
86
86
  }
87
87
 
88
- async function openFromSearch(id, file, tab = 'memories') {
88
+ async function openFromSearch(id, file, segment = 'list') {
89
89
  clearSearch();
90
90
  if (id !== state.storeId) await openStore(id);
91
- state.tab = tab;
92
91
  if (file) state.selected = file;
93
- paint('tabs', 'tab');
92
+ goTo('memory', segment);
94
93
  }
95
94
 
96
95
  export function clearSearch() {
@@ -0,0 +1,37 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { state } from '/state.mjs';
4
+ import { goTo } from '/parts.mjs';
5
+ import { segmentsFor } from '/views/header.mjs';
6
+
7
+ export function segmentBar(tab) {
8
+ const segments = segmentsFor(tab);
9
+ if (segments.length < 2) return null;
10
+ return node('div', { class: ui.segmentBar }, [
11
+ node('div', { class: ui.segmentGroup, role: 'tablist' }, segments.map((seg) => node('button', {
12
+ class: ui.segment(state.segment[tab] === seg.id),
13
+ role: 'tab',
14
+ 'aria-selected': String(state.segment[tab] === seg.id),
15
+ onclick: () => goTo(tab, seg.id),
16
+ }, [
17
+ document.createTextNode(seg.label),
18
+ seg.badge ? node('span', { class: ui.tabBadge(seg.tone) }, [document.createTextNode(seg.badge)]) : null,
19
+ ]))),
20
+ ]);
21
+ }
22
+
23
+ export function activeSegment(tab) {
24
+ const segments = segmentsFor(tab);
25
+ if (!segments.length) return null;
26
+ const current = state.segment[tab];
27
+ return segments.some((seg) => seg.id === current) ? current : segments[0].id;
28
+ }
29
+
30
+ export function renderSegmented(container, tab, views) {
31
+ const bar = segmentBar(tab);
32
+ if (bar) container.append(bar);
33
+ const host = node('div');
34
+ container.append(host);
35
+ const segment = activeSegment(tab);
36
+ return views[segment] ? views[segment](host) : null;
37
+ }
@@ -2,7 +2,7 @@ import * as ui from '/ui.mjs';
2
2
  import { node } from '/dom.mjs';
3
3
  import { api } from '/api.mjs';
4
4
  import { state } from '/state.mjs';
5
- import { sizeLabel, sessionDate } from '/parts.mjs';
5
+ import { sizeLabel, sessionDate, isAt } from '/parts.mjs';
6
6
  import { selectMemory } from '/store.mjs';
7
7
  import { paint } from '/bus.mjs';
8
8
 
@@ -170,7 +170,7 @@ export async function renderSessions(container) {
170
170
  container.textContent = '';
171
171
  return container.append(node('p', { class: ui.note, text: err.message }));
172
172
  }
173
- if (state.tab !== 'sessions') return;
173
+ if (!isAt('environment', 'sessions')) return;
174
174
  state.aux.sessions = data;
175
175
  }
176
176
  container.textContent = '';
@@ -2,6 +2,7 @@ import * as ui from '/ui.mjs';
2
2
  import { node } from '/dom.mjs';
3
3
  import { api } from '/api.mjs';
4
4
  import { state } from '/state.mjs';
5
+ import { isAt } from '/parts.mjs';
5
6
 
6
7
  const SETTINGS_PROBLEMS = {
7
8
  unparseable: (p) => [
@@ -34,7 +35,7 @@ export async function renderSettings(container) {
34
35
  container.textContent = '';
35
36
  return container.append(node('p', { class: ui.note, text: err.message }));
36
37
  }
37
- if (state.tab !== 'settings') return;
38
+ if (!isAt('environment', 'settings')) return;
38
39
  }
39
40
  container.textContent = '';
40
41
 
@@ -13,9 +13,9 @@ function storeSubtitle(store) {
13
13
 
14
14
  function issueTitle(store) {
15
15
  const parts = [];
16
- if (store.issueCount) parts.push(`${store.issueCount} in Health`);
17
- if (store.context) parts.push(`${store.context} in Context`);
18
- if (store.settings) parts.push(`${store.settings} in Settings`);
16
+ if (store.issueCount) parts.push(`${store.issueCount} to fix in Cleanup`);
17
+ const environment = (store.context || 0) + (store.settings || 0);
18
+ if (environment) parts.push(`${environment} in Environment`);
19
19
  if (store.kind === 'auto' && store.resolvedBy === 'unresolved') {
20
20
  parts.push('Real path could not be resolved - showing the raw folder name');
21
21
  }
@@ -0,0 +1,33 @@
1
+ const COST_KINDS = new Set(['long-hook', 'hook-repeats-description', 'empty-section', 'index-continuation']);
2
+
3
+ function rankOf(kind, severity) {
4
+ if (kind === 'past-cutoff') return 0;
5
+ if (severity === 'bad') return 1;
6
+ if (COST_KINDS.has(kind)) return 2;
7
+ if (kind === 'overlap') return 4;
8
+ return 3;
9
+ }
10
+
11
+ export function buildWorklist(store) {
12
+ if (!store || !store.stats) return [];
13
+ const items = [];
14
+ const { index } = store.stats;
15
+
16
+ for (const entry of index.cutoff?.droppedEntries ?? []) {
17
+ items.push({ kind: 'past-cutoff', severity: 'bad', entry });
18
+ }
19
+ for (const hook of index.longHooks) {
20
+ items.push({ kind: 'long-hook', severity: 'warn', hook });
21
+ }
22
+ for (const item of store.health.issues) {
23
+ if (item.kind === 'long-hooks') continue;
24
+ items.push(item);
25
+ }
26
+ for (const pair of store.duplicates) {
27
+ items.push({ kind: 'overlap', severity: 'warn', pair });
28
+ }
29
+
30
+ return items
31
+ .map((item) => ({ ...item, rank: rankOf(item.kind, item.severity) }))
32
+ .sort((a, b) => a.rank - b.rank);
33
+ }
@@ -1,21 +0,0 @@
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
- }
@@ -1,191 +0,0 @@
1
- import * as ui from '/ui.mjs';
2
- import { node } from '/dom.mjs';
3
- import { state } from '/state.mjs';
4
- import { paint } from '/bus.mjs';
5
- import { formatAge } from '/parts.mjs';
6
- import { selectMemory } from '/store.mjs';
7
- import { openBulkDeleteDialog } from '/dialogs/bulk-delete.mjs';
8
- import { openMergeDialog } from '/dialogs/merge.mjs';
9
- import { openMoveDialog } from '/dialogs/move.mjs';
10
- import { openHookEditor } from '/dialogs/hook-editor.mjs';
11
-
12
- const PRUNE_SORTS = {
13
- oldest: { label: 'oldest first', compare: (a, b) => (b.ageDays ?? -1) - (a.ageDays ?? -1) },
14
- largest: { label: 'largest first', compare: (a, b) => b.bytes - a.bytes },
15
- unlinked: { label: 'least linked', compare: (a, b) => a.inbound.length - b.inbound.length || (b.ageDays ?? 0) - (a.ageDays ?? 0) },
16
- name: { label: 'by name', compare: (a, b) => a.name.localeCompare(b.name) },
17
- };
18
-
19
- function renderBudget(container) {
20
- const { index } = state.store.stats;
21
- const percent = Math.min(100, index.worstPercent);
22
- const level = index.level;
23
-
24
- const note = index.level === 'over'
25
- ? 'Over the limit. Everything past the cutoff is dropped when the session loads it, so those entries are effectively invisible to Claude right now.'
26
- : index.level === 'near'
27
- ? 'Approaching the limit. Once MEMORY.md passes it, everything after the cutoff stops being loaded at all.'
28
- : 'Comfortably inside the limit. MEMORY.md is loaded at the start of every session.';
29
-
30
- if (index.cutoff) {
31
- const dropped = index.cutoff.droppedEntries;
32
- const box = node('div', { class: ui.cutSummary });
33
- box.append(node('div', { class: ui.cutSummaryHead, text: dropped.length
34
- ? `${dropped.length} ${dropped.length === 1 ? 'memory is' : 'memories are'} past the cutoff and not loaded`
35
- : `${index.cutoff.droppedLines} lines are past the cutoff and not loaded` }));
36
- box.append(node('p', { class: ui.note, text: `MEMORY.md stops loading at line ${index.cutoff.rawLine + 1}, bounded by ${index.cutoff.by}. Claude cannot see anything below it.` }));
37
- for (const entry of dropped.slice(0, 20)) {
38
- box.append(node('div', { class: ui.issue(true) }, [
39
- node('div', { class: ui.issueBody }, [
40
- node('div', { class: ui.issueTitle, text: entry.title || entry.file }),
41
- node('div', { class: ui.issueDetail, text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
42
- ]),
43
- node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(entry.file) }),
44
- node('button', { class: ui.buttonPrimarySmall, text: 'Move up', onclick: () => openMoveDialog(entry) }),
45
- ]));
46
- }
47
- if (dropped.length > 20) {
48
- box.append(node('p', { class: ui.note, text: `${dropped.length - 20} more not listed.` }));
49
- }
50
- container.append(box);
51
- }
52
-
53
- container.append(node('div', { class: ui.meter }, [
54
- node('div', { class: ui.meterTop }, [
55
- node('span', { class: ui.meterValue, text: `${Math.round(index.worstPercent)}%` }),
56
- node('span', { class: ui.meterUnit, text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
57
- ]),
58
- node('div', { class: ui.meterBar }, [
59
- node('div', { class: ui.meterFill(level), style: `width:${percent.toFixed(1)}%` }),
60
- ]),
61
- node('p', { class: ui.meterNote, text: note }),
62
- node('div', { class: ui.meterFacts }, [
63
- node('span', {}, [node('b', { class: ui.meterFactValue, text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
64
- node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
65
- node('span', {}, [node('b', { class: ui.meterFactValue, text: String(index.entryCount) }), document.createTextNode(' index entries')]),
66
- node('span', {}, [node('b', { class: ui.meterFactValue, text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
67
- ]),
68
- node('p', { class: ui.meterNote, text: 'Claude Code loads the first 200 lines or 25KB of MEMORY.md, whichever comes first, and drops the rest. Topic files are only read when Claude follows a link, so detail belongs in them and MEMORY.md should stay one line per entry.' }),
69
- ]));
70
- }
71
-
72
- export function renderPrune(container) {
73
- const project = state.store;
74
- renderBudget(container);
75
-
76
- if (!project.memories.length) {
77
- container.append(node('p', { class: ui.note, text: 'Nothing to prune - this project has no memories.' }));
78
- return;
79
- }
80
-
81
- const selected = state.pruneSelection;
82
- const bar = node('div', { class: ui.pruneBar });
83
- const sort = node('select', { class: ui.select });
84
- for (const [key, config] of Object.entries(PRUNE_SORTS)) {
85
- sort.append(node('option', { value: key, text: config.label, selected: key === state.pruneSort ? '' : null }));
86
- }
87
- sort.value = state.pruneSort;
88
- sort.onchange = () => { state.pruneSort = sort.value; paint('tab'); };
89
-
90
- const count = node('span', { class: ui.note });
91
- const deleteButton = node('button', { class: ui.buttonDangerSmall });
92
- const clearButton = node('button', { class: ui.buttonSmall, text: 'Clear selection' });
93
-
94
- bar.append(node('span', { class: ui.sectionLabel, text: 'Prune' }), sort, node('span', { class: ui.pruneSpacer }), count, clearButton, deleteButton);
95
- container.append(bar);
96
-
97
- const list = node('div');
98
- const ordered = [...project.memories].sort(PRUNE_SORTS[state.pruneSort].compare);
99
- const rows = [];
100
- for (const memory of ordered) {
101
- const box = node('input', { type: 'checkbox', class: ui.checkbox });
102
- box.checked = selected.has(memory.file);
103
- const row = node('label', { class: ui.pruneRow(box.checked) }, [
104
- box,
105
- node('span', { class: ui.pruneMain }, [
106
- node('span', { class: ui.pruneName, text: memory.name }),
107
- node('span', { class: ui.pruneDesc, text: memory.description || memory.file }),
108
- ]),
109
- node('span', { class: ui.pruneFacts }, [
110
- node('span', { class: ui.pruneFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
111
- node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
112
- node('span', { class: ui.pruneFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
113
- node('span', { class: ui.pruneFact(memory.status !== 'indexed'), text: memory.status }),
114
- ]),
115
- ]);
116
- box.addEventListener('change', () => {
117
- if (box.checked) selected.add(memory.file); else selected.delete(memory.file);
118
- row.className = ui.pruneRow(box.checked);
119
- updateBar();
120
- });
121
- rows.push({ box, memory });
122
- list.append(row);
123
- }
124
- container.append(list);
125
-
126
- function updateBar() {
127
- count.textContent = selected.size ? `${selected.size} selected` : '';
128
- deleteButton.textContent = `Delete ${selected.size || ''}`.trim();
129
- deleteButton.disabled = selected.size === 0;
130
- }
131
- clearButton.onclick = () => {
132
- selected.clear();
133
- paint('tab');
134
- };
135
- deleteButton.onclick = () => openBulkDeleteDialog([...selected]);
136
- updateBar();
137
-
138
- const { longHooks } = project.stats.index;
139
- if (longHooks.length) {
140
- container.append(node('div', { class: ui.sectionLabel, text: `Long index hooks · ${longHooks.length}` }));
141
- 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.' }));
142
- for (const hook of longHooks.slice(0, 12)) {
143
- const row = node('div', { class: ui.issue(false) });
144
- const editButton = node('button', { class: ui.buttonPrimarySmall, text: 'Edit hook' });
145
- row.append(
146
- node('div', { class: ui.issueBody }, [
147
- node('div', { class: ui.issueTitle, text: `${hook.hookLength} chars: ${hook.title}` }),
148
- node('div', { class: ui.issueDetail, text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
149
- ]),
150
- node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(hook.file) }),
151
- editButton,
152
- );
153
- editButton.onclick = () => openHookEditor(row, hook.index);
154
- container.append(row);
155
- }
156
- }
157
-
158
- if (project.duplicates.length) {
159
- container.append(node('div', { class: ui.sectionLabel, text: `Possible overlap · ${project.duplicates.length}` }));
160
- container.append(node('p', { class: ui.noteTight, text: 'Ranked by how much rare vocabulary these pairs share. This is a hint, not a verdict - open both and decide whether one covers the other.' }));
161
- for (const pair of project.duplicates) {
162
- container.append(node('div', { class: ui.dupe }, [
163
- node('div', { class: ui.dupeHead }, [
164
- node('span', { class: ui.dupeScore, text: `${pair.score}%` }),
165
- node('span', { text: `shared: ${pair.shared.join(', ')}` }),
166
- ]),
167
- node('div', { class: ui.dupePair }, [
168
- ...[pair.a, pair.b].map((side) => node('div', {
169
- class: ui.dupeSide,
170
- onclick: () => selectMemory(side.file),
171
- }, [
172
- node('div', { class: ui.dupeSideName, text: side.name }),
173
- node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
174
- ])),
175
- ]),
176
- node('div', { class: ui.dupeActions }, [
177
- node('button', {
178
- class: ui.buttonSmall,
179
- text: `Merge into ${pair.a.name}`,
180
- onclick: () => openMergeDialog(pair.a.file, pair.b.file),
181
- }),
182
- node('button', {
183
- class: ui.buttonSmall,
184
- text: `Merge into ${pair.b.name}`,
185
- onclick: () => openMergeDialog(pair.b.file, pair.a.file),
186
- }),
187
- ]),
188
- ]));
189
- }
190
- }
191
- }
@@ -1,37 +0,0 @@
1
- import * as ui from '/ui.mjs';
2
- import { node } from '/dom.mjs';
3
- import { state } from '/state.mjs';
4
- import { restoreFromTrash } from '/store.mjs';
5
-
6
- const INDEX_EDIT_LABELS = {
7
- hook: 'MEMORY.md hook shortened',
8
- move: 'MEMORY.md entry moved',
9
- add: 'MEMORY.md entry added',
10
- };
11
-
12
- export function renderTrash(container) {
13
- const trash = state.store.trash;
14
- if (!trash.length) {
15
- container.append(node('p', { class: ui.note, text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
16
- return;
17
- }
18
- for (const record of trash) {
19
- const when = String(record.deletedAt).replace('T', ' ').replace(/\..*$/, '');
20
- const detail = record.kind === 'wikilink'
21
- ? `unlinked in ${record.sourceFile} · ${when}`
22
- : record.kind === 'index-edit'
23
- ? `${INDEX_EDIT_LABELS[record.op] || 'MEMORY.md edited'} · ${when}`
24
- : record.kind === 'merge'
25
- ? `merged into ${record.into} · ${when} · ${record.backups?.length || 0} file(s) rewritten`
26
- : `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
27
- container.append(node('div', { class: ui.issue(!record.present) }, [
28
- node('div', { class: ui.issueBody }, [
29
- node('div', { class: ui.issueTitle, text: record.label || record.id }),
30
- node('div', { class: ui.issueDetail, text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
31
- ]),
32
- record.present
33
- ? node('button', { class: ui.buttonPrimarySmall, text: 'Restore', onclick: () => restoreFromTrash(record.id) })
34
- : null,
35
- ]));
36
- }
37
- }