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.
package/public/ui.mjs CHANGED
@@ -100,7 +100,7 @@ export const sectionLabel = 'mt-5 mb-2 font-mono text-2xs font-semibold uppercas
100
100
  export const note = 'text-xs text-fg-muted';
101
101
  export const noteTight = 'mt-0 mb-2.5 text-xs text-fg-muted';
102
102
 
103
- const memoryItemBase = 'mb-1.5 block w-full cursor-pointer rounded-md border bg-surface px-3 py-2.5 text-left transition-colors';
103
+ const memoryItemBase = 'block min-w-0 flex-1 cursor-pointer rounded-md border bg-surface px-3 py-2.5 text-left transition-colors';
104
104
  const memoryItemState = {
105
105
  active: 'border-accent bg-accent-surface',
106
106
  idle: 'border-line hover:border-accent-line',
@@ -109,6 +109,9 @@ export const memoryItem = (active) => cx(memoryItemBase, active ? memoryItemStat
109
109
  export const memoryTop = 'mb-1 flex items-center gap-2';
110
110
  export const memoryName = 'flex-1 truncate text-sm font-medium text-fg';
111
111
  export const memoryDesc = 'line-clamp-2 text-xs text-fg-muted';
112
+ export const memoryFacts = 'mt-1.5 flex flex-wrap gap-3 font-mono text-2xs text-fg-muted';
113
+ export const memoryFact = (warn) => (warn ? 'text-warn' : 'text-fg-muted');
114
+ export const listItemRow = 'mb-1.5 flex items-start gap-2';
112
115
 
113
116
  export const card = 'rounded-lg border border-line bg-surface p-5 shadow-card';
114
117
  export const detailHead = 'mb-1 flex items-start gap-3';
@@ -243,9 +246,6 @@ export const issueBody = 'min-w-0 flex-1';
243
246
  export const issueTitle = 'text-sm font-medium text-fg';
244
247
  export const issueDetail = 'font-mono text-2xs break-words text-fg-muted';
245
248
 
246
- export const cutSummary = 'mb-4 rounded-lg border border-danger-line bg-danger-surface px-4 py-3.5';
247
- export const cutSummaryHead = 'mb-1 text-sm font-semibold text-danger';
248
-
249
249
  export const meter = 'mb-5 rounded-lg border border-line bg-surface px-5 py-4';
250
250
  export const meterTop = 'mb-2.5 flex items-baseline gap-2.5';
251
251
  export const meterValue = 'font-mono text-2xl font-semibold tracking-tightest text-fg';
@@ -257,19 +257,11 @@ export const meterNote = 'mt-2.5 text-xs text-fg-muted';
257
257
  export const meterFacts = 'mt-3 flex flex-wrap gap-4 border-t border-line pt-3 font-mono text-2xs text-fg-muted';
258
258
  export const meterFactValue = 'font-semibold text-fg';
259
259
 
260
- export const pruneBar = 'mb-2.5 flex flex-wrap items-center gap-2.5';
261
- export const pruneSpacer = 'flex-1';
260
+ export const listBar = 'mb-2.5 flex flex-wrap items-center gap-2.5';
261
+ export const listSpacer = 'flex-1';
262
+ export const segmentBar = 'mb-5 flex flex-wrap items-center gap-3';
262
263
  export const select = cx('cursor-pointer rounded-md border border-line bg-surface font-mono text-xs text-fg hover:border-line-strong focus:outline-none', controlPad.sm);
263
264
 
264
- const pruneRowBase = 'mb-1 flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2 transition-colors';
265
- const pruneRowState = { on: 'border-danger-line bg-danger-surface', off: 'border-line bg-surface hover:border-accent-line' };
266
- export const pruneRow = (on) => cx(pruneRowBase, on ? pruneRowState.on : pruneRowState.off);
267
- export const pruneMain = 'min-w-0 flex-1';
268
- export const pruneName = 'block text-sm font-medium text-fg';
269
- export const pruneDesc = 'block truncate text-xs text-fg-muted';
270
- export const pruneFacts = 'flex flex-none gap-3 font-mono text-2xs text-fg-muted';
271
- export const pruneFact = (warn) => (warn ? 'text-warn' : 'text-fg-muted');
272
-
273
265
  export const dupe = 'mb-2 rounded-lg border border-line bg-surface px-3.5 py-3';
274
266
  export const dupeHead = 'mb-2 flex items-baseline gap-2 font-mono text-2xs text-fg-muted';
275
267
  export const dupeScore = 'font-semibold text-warn';
@@ -319,7 +311,7 @@ export const cascadeDetail = 'block font-mono text-2xs break-words text-fg-muted
319
311
 
320
312
  export const pathInput = cx('my-1 mb-3 w-full rounded-md border border-line bg-surface-sunken font-mono text-xs text-fg focus:border-accent focus:outline-none', controlPad.lg);
321
313
  export const textArea = cx('my-1 mb-1.5 block min-h-20 w-full resize-y rounded-md border border-line bg-surface-sunken font-mono text-xs leading-relaxed text-fg focus:border-accent focus:outline-none', controlPad.lg);
322
- export const hookEditor = 'mb-1.5 rounded-md border border-accent-line bg-surface-sunken px-3 py-2.5';
314
+ export const hookEditor = 'mb-1.5 w-full rounded-md border border-accent-line bg-surface-sunken px-3 py-2.5';
323
315
  export const hookEditorFoot = 'flex justify-end gap-2';
324
316
  export const charCount = 'mb-3 flex items-baseline justify-between gap-3 font-mono text-2xs text-fg-subtle';
325
317
  export const charCountOver = 'mb-3 flex items-baseline justify-between gap-3 font-mono text-2xs text-warn';
@@ -0,0 +1,63 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { state } from '/state.mjs';
4
+ import { buildWorklist } from '/views/worklist.mjs';
5
+ import { renderCostIssue, COST_KINDS } from '/views/cost-issue.mjs';
6
+ import { renderIssue } from '/views/issue.mjs';
7
+ import { renderPathCheck } from '/views/path-check.mjs';
8
+
9
+ const SHOWN = 40;
10
+
11
+ function renderMeter(container) {
12
+ const { index } = state.store.stats;
13
+ const percent = Math.min(100, index.worstPercent);
14
+
15
+ const note = index.level === 'over'
16
+ ? 'Over the limit. Everything past the cutoff is dropped when the session loads it, so those entries are effectively invisible to Claude right now.'
17
+ : index.level === 'near'
18
+ ? 'Approaching the limit. Once MEMORY.md passes it, everything after the cutoff stops being loaded at all.'
19
+ : 'Comfortably inside the limit. MEMORY.md is loaded at the start of every session.';
20
+
21
+ container.append(node('div', { class: ui.meter }, [
22
+ node('div', { class: ui.meterTop }, [
23
+ node('span', { class: ui.meterValue, text: `${Math.round(index.worstPercent)}%` }),
24
+ node('span', { class: ui.meterUnit, text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
25
+ ]),
26
+ node('div', { class: ui.meterBar }, [
27
+ node('div', { class: ui.meterFill(index.level), style: `width:${percent.toFixed(1)}%` }),
28
+ ]),
29
+ node('p', { class: ui.meterNote, text: note }),
30
+ node('div', { class: ui.meterFacts }, [
31
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
32
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
33
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(index.entryCount) }), document.createTextNode(' index entries')]),
34
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
35
+ ]),
36
+ 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.' }),
37
+ ]));
38
+ }
39
+
40
+ export function renderCleanup(container) {
41
+ const { memories } = state.store;
42
+ renderMeter(container);
43
+
44
+ const items = buildWorklist(state.store);
45
+ if (!items.length) {
46
+ container.append(node('div', { class: ui.card }, [
47
+ node('p', { class: ui.okLine, text: '✓ Nothing to fix.' }),
48
+ node('p', { class: ui.noteTight, text: 'MEMORY.md is inside its load limit, every memory file is referenced by it, every pointer resolves, and every [[wikilink]] finds its target.' }),
49
+ ]));
50
+ } else {
51
+ container.append(node('div', { class: ui.sectionLabel, text: `Worst first · ${items.length}` }));
52
+ for (const item of items.slice(0, SHOWN)) {
53
+ const row = COST_KINDS.has(item.kind) ? renderCostIssue(item) : renderIssue(item, memories);
54
+ if (row) container.append(row);
55
+ }
56
+ if (items.length > SHOWN) {
57
+ container.append(node('p', { class: ui.note, text: `${items.length - SHOWN} more not listed. Fix some of the above and they move up.` }));
58
+ }
59
+ }
60
+
61
+ container.append(node('div', { class: ui.sectionLabel, text: 'Beyond the memory directory' }));
62
+ renderPathCheck(container);
63
+ }
@@ -3,6 +3,7 @@ import { node } from '/dom.mjs';
3
3
  import { renderMarkdown } from '/markdown.mjs';
4
4
  import { api } from '/api.mjs';
5
5
  import { state } from '/state.mjs';
6
+ import { isAt } from '/parts.mjs';
6
7
 
7
8
  const SCOPE_ORDER = ['managed', 'user', 'project', 'local'];
8
9
 
@@ -30,7 +31,7 @@ export async function renderContext(container) {
30
31
  container.textContent = '';
31
32
  return container.append(node('p', { class: ui.note, text: err.message }));
32
33
  }
33
- if (state.tab !== 'context') return;
34
+ if (!isAt('environment', 'instructions')) return;
34
35
  }
35
36
  container.textContent = '';
36
37
 
@@ -0,0 +1,70 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { issue } from '/parts.mjs';
4
+ import { selectMemory } from '/store.mjs';
5
+ import { openMergeDialog } from '/dialogs/merge.mjs';
6
+ import { openMoveDialog } from '/dialogs/move.mjs';
7
+ import { openHookEditor } from '/dialogs/hook-editor.mjs';
8
+
9
+ export function renderCostIssue(item) {
10
+ if (item.kind === 'past-cutoff') {
11
+ const { entry } = item;
12
+ return issue(
13
+ `Past the cutoff, not loaded: ${entry.title || entry.file}`,
14
+ `MEMORY.md line ${entry.index + 1} · ${entry.file} - the file is on disk, but Claude never sees this entry.`,
15
+ {
16
+ bad: true,
17
+ secondary: { label: 'Open', run: () => selectMemory(entry.file) },
18
+ action: { label: 'Move up', run: () => openMoveDialog(entry) },
19
+ },
20
+ );
21
+ }
22
+
23
+ if (item.kind === 'long-hook') {
24
+ const { hook } = item;
25
+ const row = issue(
26
+ `${hook.hookLength}-character hook: ${hook.title}`,
27
+ `MEMORY.md line ${hook.index + 1} · ${hook.file} - the hook loads every session, so shortening it is the cheapest win available.`,
28
+ { secondary: { label: 'Open', run: () => selectMemory(hook.file) } },
29
+ );
30
+ row.append(node('button', {
31
+ class: ui.buttonPrimarySmall,
32
+ text: 'Edit hook',
33
+ onclick: () => openHookEditor(row, hook.index),
34
+ }));
35
+ return row;
36
+ }
37
+
38
+ if (item.kind === 'overlap') {
39
+ const { pair } = item;
40
+ return node('div', { class: ui.dupe }, [
41
+ node('div', { class: ui.dupeHead }, [
42
+ node('span', { class: ui.dupeScore, text: `${pair.score}%` }),
43
+ node('span', { text: `shared: ${pair.shared.join(', ')}` }),
44
+ ]),
45
+ node('div', { class: ui.dupePair }, [pair.a, pair.b].map((side) => node('div', {
46
+ class: ui.dupeSide,
47
+ onclick: () => selectMemory(side.file),
48
+ }, [
49
+ node('div', { class: ui.dupeSideName, text: side.name }),
50
+ node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
51
+ ]))),
52
+ node('div', { class: ui.dupeActions }, [
53
+ node('button', {
54
+ class: ui.buttonSmall,
55
+ text: `Merge into ${pair.a.name}`,
56
+ onclick: () => openMergeDialog(pair.a.file, pair.b.file),
57
+ }),
58
+ node('button', {
59
+ class: ui.buttonSmall,
60
+ text: `Merge into ${pair.b.name}`,
61
+ onclick: () => openMergeDialog(pair.b.file, pair.a.file),
62
+ }),
63
+ ]),
64
+ ]);
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ export const COST_KINDS = new Set(['past-cutoff', 'long-hook', 'overlap']);
@@ -0,0 +1,12 @@
1
+ import { renderSegmented } from '/views/segments.mjs';
2
+ import { renderContext } from '/views/context.mjs';
3
+ import { renderSettings } from '/views/settings.mjs';
4
+ import { renderSessions } from '/views/sessions.mjs';
5
+
6
+ export function renderEnvironment(container) {
7
+ return renderSegmented(container, 'environment', {
8
+ instructions: renderContext,
9
+ settings: renderSettings,
10
+ sessions: renderSessions,
11
+ });
12
+ }
@@ -2,62 +2,119 @@ import * as ui from '/ui.mjs';
2
2
  import { el, node } from '/dom.mjs';
3
3
  import { state, worstSeverity } from '/state.mjs';
4
4
  import { paint } from '/bus.mjs';
5
+ import { buildWorklist } from '/views/worklist.mjs';
5
6
  import { openRememberPathDialog, forgetProjectPath } from '/dialogs/remember-path.mjs';
6
7
 
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' },
8
+ const NAVIGATION = [
9
+ {
10
+ id: 'memory',
11
+ label: 'Memory',
12
+ segments: [
13
+ { id: 'list', label: 'List' },
14
+ { id: 'index', label: 'MEMORY.md' },
15
+ { id: 'graph', label: 'Graph' },
16
+ ],
17
+ },
18
+ { id: 'cleanup', label: 'Cleanup', segments: [] },
19
+ {
20
+ id: 'environment',
21
+ label: 'Environment',
22
+ segments: [
23
+ { id: 'instructions', label: 'Instructions' },
24
+ { id: 'settings', label: 'Settings' },
25
+ { id: 'sessions', label: 'Sessions' },
26
+ ],
27
+ },
17
28
  ];
18
29
 
30
+ const hasProjectDir = (store) => (store.kind === 'auto'
31
+ ? store.resolvedBy !== 'unresolved'
32
+ : Boolean(store.projectPath));
33
+
34
+ function segmentVisible(tab, segment, store) {
35
+ const global = store.kind === 'global';
36
+ if (tab === 'memory') {
37
+ if (global) return false;
38
+ return segment === 'index' ? Boolean(store.hasIndex) : true;
39
+ }
40
+ if (segment === 'instructions') return global || hasProjectDir(store);
41
+ if (global) return false;
42
+ if (segment === 'sessions') return Boolean(store.sessions?.count);
43
+ return true;
44
+ }
45
+
46
+ function segmentBadge(id) {
47
+ if (id === 'instructions') {
48
+ const problems = state.aux.instructions?.problems || [];
49
+ return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
50
+ }
51
+ if (id === 'settings') {
52
+ const problems = state.aux.settings?.problems || [];
53
+ return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
54
+ }
55
+ if (id === 'sessions') {
56
+ const sessions = state.store.sessions;
57
+ return sessions ? { badge: String(sessions.count), tone: sessions.expiringCount ? 'warn' : 'neutral' } : {};
58
+ }
59
+ return {};
60
+ }
61
+
62
+ export function segmentsFor(tab) {
63
+ const entry = NAVIGATION.find((item) => item.id === tab);
64
+ if (!entry || !state.store) return [];
65
+ return entry.segments
66
+ .filter((segment) => segmentVisible(tab, segment.id, state.store))
67
+ .map((segment) => ({ ...segment, ...segmentBadge(segment.id) }));
68
+ }
69
+
70
+ function tabVisible(tab, store) {
71
+ if (tab === 'cleanup') return store.kind !== 'global';
72
+ return segmentsFor(tab).length > 0;
73
+ }
74
+
75
+ function tabBadge(tab, store) {
76
+ if (tab === 'memory') return { badge: String(store.memories.length), tone: 'neutral' };
77
+
78
+ if (tab === 'cleanup') {
79
+ const count = buildWorklist(store).length;
80
+ if (!count) return {};
81
+ const level = store.stats ? store.stats.index.level : 'ok';
82
+ const bad = level === 'over' || store.health.severity === 'bad';
83
+ return { badge: String(count), tone: bad ? 'bad' : 'warn' };
84
+ }
85
+
86
+ const problems = [
87
+ ...(state.aux.instructions?.problems || []),
88
+ ...(state.aux.settings?.problems || []),
89
+ ];
90
+ return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
91
+ }
92
+
93
+ function undoControl() {
94
+ const count = state.store.trash?.length || 0;
95
+ const button = el('undo');
96
+ button.hidden = state.store.kind === 'global' || !count;
97
+ button.textContent = '';
98
+ button.append(
99
+ document.createTextNode('\u21ba Undo'),
100
+ node('span', { class: ui.tabBadge('neutral'), text: String(count) }),
101
+ );
102
+ }
103
+
19
104
  export function renderTabs() {
20
105
  const container = el('tabs');
21
106
  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
- }
107
+ undoControl();
108
+
109
+ for (const entry of NAVIGATION) {
110
+ if (!tabVisible(entry.id, state.store)) continue;
111
+ const { badge, tone } = tabBadge(entry.id, state.store);
55
112
 
56
113
  container.append(node('button', {
57
- class: ui.tab(state.tab === tab.id),
58
- onclick: () => { state.tab = tab.id; paint('tabs', 'tab'); },
114
+ class: ui.tab(state.tab === entry.id),
115
+ onclick: () => { state.tab = entry.id; paint('tabs', 'tab'); },
59
116
  }, [
60
- document.createTextNode(tab.label),
117
+ document.createTextNode(entry.label),
61
118
  badge ? node('span', { class: ui.tabBadge(tone), text: badge }) : null,
62
119
  ]));
63
120
  }
@@ -3,10 +3,11 @@ import { node } from '/dom.mjs';
3
3
  import { confirmDialog } from '/dialog.mjs';
4
4
  import { api, toast } from '/api.mjs';
5
5
  import { state } from '/state.mjs';
6
- import { issue, goToTab } from '/parts.mjs';
6
+ import { issue, goTo } from '/parts.mjs';
7
7
  import { openStore, selectMemory, restoreFromTrash } from '/store.mjs';
8
8
  import { openDeleteDialog } from '/dialogs/delete.mjs';
9
9
  import { openAddEntryDialog } from '/dialogs/add-entry.mjs';
10
+ import { openHookEditor } from '/dialogs/hook-editor.mjs';
10
11
 
11
12
  async function removeIndexLine(lineIndex, expectedText, done) {
12
13
  const go = await confirmDialog({
@@ -138,17 +139,6 @@ export function renderIssue(item, memories) {
138
139
  );
139
140
  }
140
141
 
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
142
  if (item.kind === 'duplicate-name') {
153
143
  return issue(
154
144
  `Two memories claim the name "${item.name}"`,
@@ -205,22 +195,24 @@ export function renderIssue(item, memories) {
205
195
  }
206
196
 
207
197
  if (item.kind === 'hook-repeats-description') {
208
- return issue(
198
+ const row = issue(
209
199
  `Hook repeats the description: ${item.file}`,
210
200
  `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
- },
201
+ { bad, secondary: { label: 'Open', run: () => selectMemory(item.file) } },
216
202
  );
203
+ row.append(node('button', {
204
+ class: ui.buttonPrimarySmall,
205
+ text: 'Edit hook',
206
+ onclick: () => openHookEditor(row, item.index),
207
+ }));
208
+ return row;
217
209
  }
218
210
 
219
211
  if (item.kind === 'empty-section') {
220
212
  return issue(
221
213
  `Empty section in MEMORY.md: "${item.section}"`,
222
214
  `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') } },
215
+ { bad, action: { label: 'Open MEMORY.md', run: () => goTo('memory', 'index') } },
224
216
  );
225
217
  }
226
218
 
@@ -236,7 +228,7 @@ export function renderIssue(item, memories) {
236
228
  return issue(
237
229
  `${item.count} index ${item.count === 1 ? 'entry spills' : 'entries spill'} onto extra lines`,
238
230
  `${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') } },
231
+ { bad, action: { label: 'Open MEMORY.md', run: () => goTo('memory', 'index') } },
240
232
  );
241
233
  }
242
234
 
@@ -3,8 +3,9 @@ import { el, node } from '/dom.mjs';
3
3
  import { renderMarkdown } from '/markdown.mjs';
4
4
  import { state } from '/state.mjs';
5
5
  import { selectMemory, loadSessionsForProvenance } from '/store.mjs';
6
- import { goToTab, sessionDate } from '/parts.mjs';
6
+ import { goTo, sessionDate } from '/parts.mjs';
7
7
  import { openDeleteDialog } from '/dialogs/delete.mjs';
8
+ import { renderMemoryList } from '/views/memory-list.mjs';
8
9
 
9
10
  export function memoryLookup(project) {
10
11
  const byName = new Map();
@@ -26,25 +27,6 @@ export function renderBody(container, memory, project) {
26
27
  });
27
28
  }
28
29
 
29
- function memoryButton(memory) {
30
- const active = state.selected === memory.file;
31
- return node('button', {
32
- class: ui.memoryItem(active),
33
- 'data-memory': memory.file,
34
- 'data-active': active ? '' : null,
35
- onclick: () => selectMemory(memory.file),
36
- }, [
37
- node('span', { class: ui.memoryTop }, [
38
- node('span', { class: ui.memoryName, text: memory.name }),
39
- node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
40
- memory.status !== 'indexed'
41
- ? node('span', { class: ui.badge('warn'), text: memory.status })
42
- : null,
43
- ]),
44
- memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
45
- ]);
46
- }
47
-
48
30
  export function renderMemories(container) {
49
31
  const project = state.store;
50
32
  if (!project.memories.length) {
@@ -53,16 +35,7 @@ export function renderMemories(container) {
53
35
  }
54
36
 
55
37
  const left = node('div');
56
- const groups = new Map();
57
- for (const memory of project.memories) {
58
- const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
59
- if (!groups.has(key)) groups.set(key, []);
60
- groups.get(key).push(memory);
61
- }
62
- for (const [section, memories] of groups) {
63
- left.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
64
- for (const memory of memories) left.append(memoryButton(memory));
65
- }
38
+ renderMemoryList(left);
66
39
 
67
40
  const right = node('div', { id: 'detail', class: ui.detailPane });
68
41
  container.append(node('div', { class: ui.split }, [left, right]));
@@ -165,8 +138,8 @@ function provenanceLine(memory) {
165
138
  row.append(node('button', {
166
139
  class: ui.provenanceLink(true),
167
140
  text: label,
168
- title: 'Open the Sessions tab',
169
- onclick: () => goToTab('sessions'),
141
+ title: 'Open Environment \u203a Sessions',
142
+ onclick: () => goTo('environment', 'sessions'),
170
143
  }));
171
144
  if (known?.gitBranch) row.append(node('span', { text: `on ${known.gitBranch}` }));
172
145
  if (origin.modified) row.append(node('span', { text: sessionDate(origin.modified).slice(0, 10) }));
@@ -0,0 +1,121 @@
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
+
9
+ const SORTS = {
10
+ section: { label: 'by section', compare: null },
11
+ oldest: { label: 'oldest first', compare: (a, b) => (b.ageDays ?? -1) - (a.ageDays ?? -1) },
12
+ largest: { label: 'largest first', compare: (a, b) => b.bytes - a.bytes },
13
+ unlinked: { label: 'least linked', compare: (a, b) => a.inbound.length - b.inbound.length || (b.ageDays ?? 0) - (a.ageDays ?? 0) },
14
+ name: { label: 'by name', compare: (a, b) => a.name.localeCompare(b.name) },
15
+ };
16
+
17
+ function memoryButton(memory) {
18
+ const active = state.selected === memory.file;
19
+ return node('button', {
20
+ class: ui.memoryItem(active),
21
+ 'data-memory': memory.file,
22
+ 'data-active': active ? '' : null,
23
+ onclick: () => selectMemory(memory.file),
24
+ }, [
25
+ node('span', { class: ui.memoryTop }, [
26
+ node('span', { class: ui.memoryName, text: memory.name }),
27
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
28
+ memory.status !== 'indexed'
29
+ ? node('span', { class: ui.badge('warn'), text: memory.status })
30
+ : null,
31
+ ]),
32
+ memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
33
+ node('span', { class: ui.memoryFacts }, [
34
+ node('span', { class: ui.memoryFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
35
+ node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
36
+ node('span', { class: ui.memoryFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
37
+ ]),
38
+ ]);
39
+ }
40
+
41
+ function memoryRow(memory, onToggle) {
42
+ const row = node('div', { class: ui.listItemRow });
43
+ if (state.selecting) {
44
+ const box = node('input', { type: 'checkbox', class: ui.checkbox, 'aria-label': `Select ${memory.name}` });
45
+ box.checked = state.listSelection.has(memory.file);
46
+ box.addEventListener('change', () => {
47
+ if (box.checked) state.listSelection.add(memory.file);
48
+ else state.listSelection.delete(memory.file);
49
+ onToggle();
50
+ });
51
+ row.append(box);
52
+ }
53
+ row.append(memoryButton(memory));
54
+ return row;
55
+ }
56
+
57
+ function setSort(value) {
58
+ state.listSort = value;
59
+ localStorage.setItem('memoryListSort', value);
60
+ paint('tab');
61
+ }
62
+
63
+ function toolbar() {
64
+ const bar = node('div', { class: ui.listBar });
65
+ const sort = node('select', { class: ui.select, 'aria-label': 'Sort memories' });
66
+ for (const [key, config] of Object.entries(SORTS)) {
67
+ sort.append(node('option', { value: key, text: config.label }));
68
+ }
69
+ sort.value = state.listSort;
70
+ sort.onchange = () => setSort(sort.value);
71
+
72
+ const count = node('span', { class: ui.note });
73
+ const deleteButton = node('button', { class: ui.buttonDangerSmall });
74
+ const selectButton = node('button', {
75
+ class: ui.buttonSmall,
76
+ text: state.selecting ? 'Done' : 'Select',
77
+ onclick: () => {
78
+ state.selecting = !state.selecting;
79
+ if (!state.selecting) state.listSelection.clear();
80
+ paint('tab');
81
+ },
82
+ });
83
+
84
+ bar.append(sort, node('span', { class: ui.listSpacer }), count, selectButton);
85
+ if (state.selecting) bar.append(deleteButton);
86
+ deleteButton.onclick = () => openBulkDeleteDialog([...state.listSelection]);
87
+
88
+ const update = () => {
89
+ const size = state.listSelection.size;
90
+ count.textContent = size ? `${size} selected` : '';
91
+ deleteButton.textContent = `Delete ${size || ''}`.trim();
92
+ deleteButton.disabled = size === 0;
93
+ };
94
+ update();
95
+ return { bar, update };
96
+ }
97
+
98
+ export function renderMemoryList(container) {
99
+ const project = state.store;
100
+ const { bar, update } = toolbar();
101
+ container.append(bar);
102
+
103
+ const compare = SORTS[state.listSort].compare;
104
+ if (!compare) {
105
+ const groups = new Map();
106
+ for (const memory of project.memories) {
107
+ const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
108
+ if (!groups.has(key)) groups.set(key, []);
109
+ groups.get(key).push(memory);
110
+ }
111
+ for (const [section, memories] of groups) {
112
+ container.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
113
+ for (const memory of memories) container.append(memoryRow(memory, update));
114
+ }
115
+ return;
116
+ }
117
+
118
+ for (const memory of [...project.memories].sort(compare)) {
119
+ container.append(memoryRow(memory, update));
120
+ }
121
+ }