claude-memory-admin 1.0.0 → 1.1.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/app.mjs CHANGED
@@ -1,13 +1,11 @@
1
- import { marked } from '/vendor/marked.js';
2
- import DOMPurify from '/vendor/purify.js';
3
1
  import { renderGraph } from '/graph.mjs';
4
-
5
- marked.setOptions({ gfm: true, breaks: false });
2
+ import { renderMarkdown } from '/markdown.mjs';
3
+ import * as ui from '/ui.mjs';
6
4
 
7
5
  const state = {
8
- projects: [],
9
- slug: null,
10
- project: null,
6
+ stores: [],
7
+ storeId: null,
8
+ store: null,
11
9
  tab: 'memories',
12
10
  selected: null,
13
11
  showAll: false,
@@ -17,12 +15,11 @@ const state = {
17
15
  search: null,
18
16
  pruneSort: 'oldest',
19
17
  pruneSelection: new Set(),
18
+ indexView: localStorage.getItem('memoryIndexView') === 'source' ? 'source' : 'rendered',
20
19
  };
21
20
 
22
21
  const el = (id) => document.getElementById(id);
23
22
 
24
- /* ---------------------------------------------------------------- helpers */
25
-
26
23
  async function api(path, options) {
27
24
  const response = await fetch(path, {
28
25
  headers: { 'content-type': 'application/json' },
@@ -35,10 +32,11 @@ async function api(path, options) {
35
32
 
36
33
  function toast(message, { error = false, action } = {}) {
37
34
  const node = document.createElement('div');
38
- node.className = `toast${error ? ' err' : ''}`;
35
+ node.className = ui.toast(error);
39
36
  node.append(document.createTextNode(message));
40
37
  if (action) {
41
38
  const button = document.createElement('button');
39
+ button.className = ui.toastAction;
42
40
  button.textContent = action.label;
43
41
  button.onclick = () => { node.remove(); action.run(); };
44
42
  node.append(button);
@@ -61,96 +59,83 @@ function node(tag, props = {}, children = []) {
61
59
  return element;
62
60
  }
63
61
 
64
- /**
65
- * Render a memory body. Wikilinks are swapped for placeholders before markdown
66
- * runs, then rehydrated as buttons afterwards, so they survive sanitising and
67
- * can carry click handlers.
68
- */
69
- function renderBody(container, memory, project) {
62
+ function memoryLookup(project) {
70
63
  const byName = new Map();
71
- for (const other of project.memories) {
72
- byName.set(other.name, other.file);
73
- byName.set(other.stem, other.file);
64
+ for (const memory of project.memories) {
65
+ byName.set(memory.name, memory.file);
66
+ byName.set(memory.stem, memory.file);
74
67
  }
68
+ return byName;
69
+ }
75
70
 
76
- const tokens = [];
77
- const withPlaceholders = memory.body.replace(/\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, target, alias) => {
78
- const key = `%%WIKI${tokens.length}%%`;
79
- tokens.push({ target: target.trim(), alias: (alias || '').trim() });
80
- return key;
71
+ function openTarget(file) {
72
+ return file ? { open: () => selectMemory(file) } : null;
73
+ }
74
+
75
+ function renderBody(container, memory, project) {
76
+ const byName = memoryLookup(project);
77
+ renderMarkdown(container, memory.body, {
78
+ resolveWikilink: (target) => openTarget(byName.get(target)),
81
79
  });
80
+ }
82
81
 
83
- const html = DOMPurify.sanitize(marked.parse(withPlaceholders));
84
- container.innerHTML = html;
85
-
86
- if (!tokens.length) return;
87
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
88
- const texts = [];
89
- while (walker.nextNode()) texts.push(walker.currentNode);
90
-
91
- for (const textNode of texts) {
92
- if (!/%%WIKI\d+%%/.test(textNode.nodeValue)) continue;
93
- const fragment = document.createDocumentFragment();
94
- const parts = textNode.nodeValue.split(/(%%WIKI\d+%%)/);
95
- for (const part of parts) {
96
- const match = part.match(/^%%WIKI(\d+)%%$/);
97
- if (!match) {
98
- if (part) fragment.append(document.createTextNode(part));
99
- continue;
100
- }
101
- const token = tokens[Number(match[1])];
102
- const targetFile = byName.get(token.target);
103
- const button = node('button', {
104
- class: `wikilink${targetFile ? '' : ' dangling'}`,
105
- text: token.alias || token.target,
106
- title: targetFile ? `Go to ${token.target}` : `No memory named "${token.target}"`,
107
- });
108
- if (targetFile) button.onclick = () => selectMemory(targetFile);
109
- fragment.append(button);
110
- }
111
- textNode.replaceWith(fragment);
112
- }
82
+ function storeSubtitle(store) {
83
+ if (store.kind === 'auto') return store.path;
84
+ const scope = store.kind === 'agent-user' ? 'user'
85
+ : store.kind === 'agent-project' ? 'project' : 'local';
86
+ return `${scope} · ${store.sublabel}`;
113
87
  }
114
88
 
115
- /* --------------------------------------------------------------- sidebar */
89
+ function storeButton(store) {
90
+ const health = store.memoryCount === 0 ? 'none' : 'ok';
91
+ const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
92
+ return node('button', {
93
+ class: ui.storeItem({ active: store.id === state.storeId, empty: !store.hasMemoryDir }),
94
+ onclick: () => openStore(store.id),
95
+ title: store.kind === 'auto' && store.resolvedBy === 'unresolved'
96
+ ? 'Real path could not be resolved - showing the raw folder name'
97
+ : store.dir,
98
+ }, [
99
+ node('span', { class: ui.storeRow }, [
100
+ node('span', { class: ui.dot(health) }),
101
+ node('span', { class: ui.storeName, text: store.label }),
102
+ 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) : '-' }),
104
+ ]),
105
+ node('span', { class: ui.storePath, text: storeSubtitle(store) }),
106
+ ]);
107
+ }
116
108
 
117
- function renderProjects() {
109
+ function renderStores() {
118
110
  const list = el('project-list');
119
111
  list.textContent = '';
120
112
 
121
- const visible = state.projects.filter((p) => state.showAll || p.hasMemoryDir);
113
+ const visible = state.stores.filter((s) => state.showAll || s.hasMemoryDir);
122
114
  if (!visible.length) {
123
- list.append(node('p', { class: 'muted', text: 'No projects found.', style: 'padding:12px' }));
115
+ list.append(node('p', { class: ui.note, text: 'No memory stores found.' }));
124
116
  return;
125
117
  }
126
118
 
127
- for (const project of visible) {
128
- const health = project.memoryCount === 0 ? 'none' : 'ok';
129
- const button = node('button', {
130
- class: `project${project.slug === state.slug ? ' active' : ''}${project.hasMemoryDir ? '' : ' dim'}`,
131
- onclick: () => openProject(project.slug),
132
- title: project.resolvedBy === 'unresolved'
133
- ? 'Real path could not be resolved - showing the raw folder name'
134
- : project.path,
135
- }, [
136
- node('span', { class: 'project-row' }, [
137
- node('span', { class: `dot ${health}` }),
138
- node('span', { class: 'project-name', text: project.label }),
139
- node('span', { class: 'project-count', text: project.hasMemoryDir ? String(project.memoryCount) : '-' }),
140
- ]),
141
- node('span', { class: 'project-path', text: project.path }),
142
- ]);
143
- list.append(button);
119
+ const groups = [
120
+ ['Projects', visible.filter((s) => s.kind === 'auto')],
121
+ ['Subagents', visible.filter((s) => s.kind !== 'auto')],
122
+ ];
123
+
124
+ for (const [title, stores] of groups) {
125
+ if (!stores.length) continue;
126
+ if (groups.every(([, group]) => group.length) ) {
127
+ list.append(node('div', { class: ui.sidebarGroup, text: title }));
128
+ }
129
+ for (const store of stores) list.append(storeButton(store));
144
130
  }
145
131
  }
146
132
 
147
- /* ------------------------------------------------------------------ tabs */
148
-
149
133
  const TABS = [
150
134
  { id: 'memories', label: 'Memories' },
151
135
  { id: 'index', label: 'MEMORY.md' },
152
136
  { id: 'graph', label: 'Graph' },
153
137
  { id: 'prune', label: 'Prune' },
138
+ { id: 'context', label: 'Context' },
154
139
  { id: 'health', label: 'Health' },
155
140
  { id: 'trash', label: 'Trash' },
156
141
  ];
@@ -158,51 +143,55 @@ const TABS = [
158
143
  function renderTabs() {
159
144
  const container = el('tabs');
160
145
  container.textContent = '';
146
+ const hasProjectDir = state.store.kind === 'auto'
147
+ ? state.store.resolvedBy !== 'unresolved'
148
+ : Boolean(state.store.projectPath);
149
+
161
150
  for (const tab of TABS) {
151
+ if (tab.id === 'context' && !hasProjectDir) continue;
162
152
  let badge = null;
163
- if (tab.id === 'memories') badge = String(state.project.memories.length);
164
- if (tab.id === 'health' && state.project.health.issueCount) badge = String(state.project.health.issueCount);
165
- if (tab.id === 'trash' && state.project.trash.length) badge = String(state.project.trash.length);
166
- if (tab.id === 'prune' && state.project.stats.index.overTarget) badge = '!';
153
+ if (tab.id === 'memories') badge = String(state.store.memories.length);
154
+ if (tab.id === 'health' && state.store.health.issueCount) badge = String(state.store.health.issueCount);
155
+ if (tab.id === 'trash' && state.store.trash.length) badge = String(state.store.trash.length);
156
+ if (tab.id === 'prune' && state.store.stats.index.overTarget) badge = '!';
167
157
 
168
158
  container.append(node('button', {
169
- class: `tab${state.tab === tab.id ? ' active' : ''}`,
159
+ class: ui.tab(state.tab === tab.id),
170
160
  onclick: () => { state.tab = tab.id; renderTabs(); renderTab(); },
171
161
  }, [
172
162
  document.createTextNode(tab.label),
173
- badge ? node('span', { class: `badge${tab.id === 'health' || tab.id === 'prune' ? ' warn' : ''}`, text: badge }) : null,
163
+ badge ? node('span', { class: ui.tabBadge(tab.id === 'health' || tab.id === 'prune'), text: badge }) : null,
174
164
  ]));
175
165
  }
176
166
  }
177
167
 
178
- /* -------------------------------------------------------------- memories */
179
-
180
168
  function memoryButton(memory) {
169
+ const active = state.selected === memory.file;
181
170
  return node('button', {
182
- class: `mem${state.selected === memory.file ? ' active' : ''}`,
171
+ class: ui.memoryItem(active),
172
+ 'data-memory': memory.file,
173
+ 'data-active': active ? '' : null,
183
174
  onclick: () => selectMemory(memory.file),
184
175
  }, [
185
- node('span', { class: 'mem-top' }, [
186
- node('span', { class: 'mem-name', text: memory.name }),
187
- node('span', { class: `badge type-${memory.type}`, text: memory.type }),
176
+ node('span', { class: ui.memoryTop }, [
177
+ node('span', { class: ui.memoryName, text: memory.name }),
178
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
188
179
  memory.status !== 'indexed'
189
- ? node('span', { class: `badge status-${memory.status}`, text: memory.status })
180
+ ? node('span', { class: ui.badge('warn'), text: memory.status })
190
181
  : null,
191
182
  ]),
192
- memory.description ? node('span', { class: 'mem-desc', text: memory.description }) : null,
183
+ memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
193
184
  ]);
194
185
  }
195
186
 
196
187
  function renderMemories(container) {
197
- const project = state.project;
188
+ const project = state.store;
198
189
  if (!project.memories.length) {
199
- container.append(node('p', { class: 'muted', text: 'This project has no memory files yet.' }));
190
+ container.append(node('p', { class: ui.note, text: 'This project has no memory files yet.' }));
200
191
  return;
201
192
  }
202
193
 
203
194
  const left = node('div');
204
- // Group by the MEMORY.md heading each entry sits under, so the list mirrors
205
- // how the index is actually organised.
206
195
  const groups = new Map();
207
196
  for (const memory of project.memories) {
208
197
  const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
@@ -210,12 +199,12 @@ function renderMemories(container) {
210
199
  groups.get(key).push(memory);
211
200
  }
212
201
  for (const [section, memories] of groups) {
213
- left.append(node('div', { class: 'section-label', text: `${section} · ${memories.length}` }));
202
+ left.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
214
203
  for (const memory of memories) left.append(memoryButton(memory));
215
204
  }
216
205
 
217
- const right = node('div', { id: 'detail' });
218
- container.append(node('div', { class: 'split' }, [left, right]));
206
+ const right = node('div', { id: 'detail', class: ui.detailPane });
207
+ container.append(node('div', { class: ui.split }, [left, right]));
219
208
  renderDetail();
220
209
  }
221
210
 
@@ -224,30 +213,32 @@ function renderDetail() {
224
213
  if (!host) return;
225
214
  host.textContent = '';
226
215
 
227
- const memory = state.project.memories.find((m) => m.file === state.selected);
216
+ const memory = state.store.memories.find((m) => m.file === state.selected);
228
217
  if (!memory) {
229
- host.append(node('p', { class: 'muted', text: 'Select a memory to read it.' }));
218
+ host.append(node('p', { class: ui.note, text: 'Select a memory to read it.' }));
230
219
  return;
231
220
  }
232
221
 
233
- const card = node('div', { class: 'card' });
234
- card.append(node('div', { class: 'detail-head' }, [
235
- node('h3', { text: memory.name }),
236
- node('span', { class: `badge type-${memory.type}`, text: memory.type }),
222
+ const card = node('div', { class: ui.card });
223
+ card.append(node('div', { class: ui.detailHead }, [
224
+ node('h3', { class: ui.detailTitle, text: memory.name }),
225
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
237
226
  node('button', {
238
- class: 'btn danger small',
227
+ class: ui.buttonDangerSmall,
239
228
  text: 'Delete',
240
229
  onclick: () => openDeleteDialog(memory.file),
241
230
  }),
242
231
  ]));
243
232
 
244
- if (memory.description) card.append(node('p', { class: 'detail-desc', text: memory.description }));
233
+ if (memory.description) card.append(node('p', { class: ui.detailDesc, text: memory.description }));
245
234
 
246
- const meta = node('dl', { class: 'meta' });
235
+ const meta = node('dl', { class: ui.metaList });
247
236
  const rows = [
248
237
  ['file', memory.file],
249
238
  ['status', memory.status],
250
- ['modified', memory.modified ? memory.modified.replace('T', ' ').replace(/\..*$/, '') : '-'],
239
+ ['modified', memory.modified
240
+ ? `${memory.modified.replace('T', ' ').replace(/\..*$/, '')}${memory.modifiedFrom === 'mtime' ? ' (file mtime)' : ''}`
241
+ : '-'],
251
242
  ['size', `${(memory.bytes / 1024).toFixed(1)} KB`],
252
243
  ];
253
244
  if (!memory.nameMatchesFile) rows.push(['name', `${memory.name} (differs from filename)`]);
@@ -255,23 +246,23 @@ function renderDetail() {
255
246
  if (key !== 'type' && key !== 'modified') rows.push([key, value]);
256
247
  }
257
248
  for (const [key, value] of rows) {
258
- meta.append(node('dt', { text: key }), node('dd', { text: String(value) }));
249
+ meta.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: String(value) }));
259
250
  }
260
251
  card.append(meta);
261
252
 
262
- const body = node('div', { class: 'body' });
253
+ const body = node('div', { class: ui.prose });
263
254
  card.append(body);
264
- renderBody(body, memory, state.project);
255
+ renderBody(body, memory, state.store);
265
256
 
266
257
  const outbound = memory.outboundResolved || [];
267
258
  if (outbound.length || memory.inbound.length) {
268
- const links = node('div', { style: 'margin-top:20px;border-top:1px solid var(--border);padding-top:14px' });
259
+ const links = node('div', { class: ui.linkSection });
269
260
  if (outbound.length) {
270
- links.append(node('div', { class: 'section-label', text: 'Links out' }));
271
- const chips = node('div', { class: 'links' });
261
+ links.append(node('div', { class: ui.sectionLabel, text: 'Links out' }));
262
+ const chips = node('div', { class: ui.linkRow });
272
263
  for (const link of outbound) {
273
264
  chips.append(node('button', {
274
- class: `chip${link.file ? '' : ' dead'}`,
265
+ class: ui.chip(Boolean(link.file)),
275
266
  text: link.file ? link.target : `${link.target} (missing)`,
276
267
  onclick: link.file ? () => selectMemory(link.file) : null,
277
268
  }));
@@ -279,12 +270,12 @@ function renderDetail() {
279
270
  links.append(chips);
280
271
  }
281
272
  if (memory.inbound.length) {
282
- links.append(node('div', { class: 'section-label', text: 'Linked from' }));
283
- const chips = node('div', { class: 'links' });
273
+ links.append(node('div', { class: ui.sectionLabel, text: 'Linked from' }));
274
+ const chips = node('div', { class: ui.linkRow });
284
275
  for (const link of memory.inbound) {
285
- const source = state.project.memories.find((m) => m.file === link.from);
276
+ const source = state.store.memories.find((m) => m.file === link.from);
286
277
  chips.append(node('button', {
287
- class: 'chip',
278
+ class: ui.chip(true),
288
279
  text: source ? source.name : link.from,
289
280
  onclick: () => selectMemory(link.from),
290
281
  }));
@@ -297,58 +288,207 @@ function renderDetail() {
297
288
  host.append(card);
298
289
  }
299
290
 
300
- /* ----------------------------------------------------------- MEMORY.md */
291
+ const INDEX_VIEWS = [
292
+ { id: 'rendered', label: 'Rendered' },
293
+ { id: 'source', label: 'Source' },
294
+ ];
295
+
296
+ function setIndexView(view) {
297
+ state.indexView = view;
298
+ localStorage.setItem('memoryIndexView', view);
299
+ renderTab();
300
+ }
301
+
302
+ function indexViewPicker() {
303
+ return node('div', { class: ui.segmentGroup, role: 'tablist' }, INDEX_VIEWS.map((view) => node('button', {
304
+ class: ui.segment(state.indexView === view.id),
305
+ text: view.label,
306
+ role: 'tab',
307
+ 'aria-selected': String(state.indexView === view.id),
308
+ onclick: () => setIndexView(view.id),
309
+ })));
310
+ }
311
+
312
+ function cutMarker(cutoff) {
313
+ return node('div', { class: ui.cutLine }, [
314
+ node('span', { class: ui.cutLabel, text: `not loaded past here — ${cutoff.droppedLines} lines dropped on ${cutoff.by}` }),
315
+ ]);
316
+ }
301
317
 
302
318
  function renderIndex(container) {
303
- const project = state.project;
319
+ const project = state.store;
304
320
  if (!project.hasIndex) {
305
- container.append(node('p', { class: 'muted', text: 'This project has no MEMORY.md.' }));
321
+ container.append(node('p', { class: ui.note, text: 'This project has no MEMORY.md.' }));
306
322
  return;
307
323
  }
308
324
 
325
+ const card = node('div', { class: ui.card });
326
+ card.append(node('div', { class: ui.cardHeadRow }, [
327
+ node('p', { class: ui.note, text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }),
328
+ indexViewPicker(),
329
+ ]));
330
+
331
+ if (state.indexView === 'source') renderIndexSource(card, project);
332
+ else renderIndexRendered(card, project);
333
+
334
+ container.append(card);
335
+ }
336
+
337
+ function renderIndexRendered(card, project) {
338
+ const { cutoff } = project.stats.index;
339
+ const files = new Set(project.memories.map((memory) => memory.file));
340
+ const byName = memoryLookup(project);
341
+ const options = {
342
+ resolveWikilink: (target) => openTarget(byName.get(target)),
343
+ resolveHref: (href) => openTarget(files.has(href) ? href : null),
344
+ };
345
+
346
+ const lines = project.index.raw.split('\n');
347
+ const loaded = node('div', { class: ui.prose });
348
+ renderMarkdown(loaded, cutoff ? lines.slice(0, cutoff.rawLine).join('\n') : project.index.raw, options);
349
+ card.append(loaded);
350
+
351
+ if (!cutoff) return;
352
+ card.append(cutMarker(cutoff));
353
+ const dropped = node('div', { class: ui.proseDim });
354
+ renderMarkdown(dropped, lines.slice(cutoff.rawLine).join('\n'), options);
355
+ card.append(dropped);
356
+ }
357
+
358
+ function renderIndexSource(card, project) {
309
359
  const missing = new Set(project.health.danglingIndex.map((d) => d.index));
310
- const card = node('div', { class: 'card' });
311
- card.append(node('p', { class: 'muted', style: 'margin-top:0', text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }));
360
+ const { cutoff } = project.stats.index;
312
361
 
313
362
  for (const line of project.index.lines) {
314
- const row = node('div', {
315
- class: `index-line kind-${line.kind}${missing.has(line.index) ? ' dangling' : ''}`,
316
- }, [
317
- node('span', { class: 'ln', text: String(line.index + 1) }),
318
- node('span', { class: 'content', text: line.text || ' ' }),
319
- ]);
320
- if (line.kind === 'index' && !missing.has(line.index)) {
321
- row.querySelector('.content').onclick = () => selectMemory(line.file);
363
+ if (cutoff && line.index === cutoff.rawLine) card.append(cutMarker(cutoff));
364
+
365
+ const dangling = missing.has(line.index);
366
+ const dropped = Boolean(cutoff) && line.index >= cutoff.rawLine;
367
+ const clickable = line.kind === 'index' && !dangling;
368
+ const content = node('span', {
369
+ class: ui.indexLineText({ kind: line.kind, clickable, dropped }),
370
+ text: line.text || ' ',
371
+ });
372
+ if (clickable) content.onclick = () => selectMemory(line.file);
373
+
374
+ card.append(node('div', { class: ui.indexLine({ dangling, dropped }) }, [
375
+ node('span', { class: ui.indexLineNumber, text: String(line.index + 1) }),
376
+ content,
377
+ ]));
378
+ }
379
+ }
380
+
381
+ const SCOPE_ORDER = ['managed', 'user', 'project', 'local'];
382
+
383
+ const CONTEXT_PROBLEMS = {
384
+ missing: (p) => ['Import does not resolve', `${p.spec} from ${p.from} - nothing at ${p.file}`],
385
+ 'too-deep': (p) => ['Import chain is too deep', `${p.file} is a fifth hop; Claude Code follows four, so this never loads`],
386
+ cycle: (p) => ['Circular import', `${p.from} imports ${p.file}, which was already loaded`],
387
+ external: (p) => ['Import resolves outside the project', `${p.file} - Claude Code asks you to approve these once, and a declined one stays disabled silently`],
388
+ 'invalid-glob': (p) => [`Rule matches nothing: ${p.pattern}`, `${p.file} - ${p.reason}`],
389
+ '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
+ 'long-claude-md': (p) => [`${p.lines} lines, over the 200-line guidance`, `${p.file} - long files cost context every session and reduce adherence`],
391
+ '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.`],
392
+ };
393
+
394
+ async function renderContext(container) {
395
+ container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
396
+ let data;
397
+ try {
398
+ data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
399
+ } catch (err) {
400
+ container.textContent = '';
401
+ return container.append(node('p', { class: ui.note, text: err.message }));
402
+ }
403
+ if (state.tab !== 'context') return;
404
+ container.textContent = '';
405
+
406
+ if (!data.projectDir) {
407
+ 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
+ }
409
+
410
+ const { totals } = data;
411
+ container.append(node('div', { class: ui.meter }, [
412
+ node('div', { class: ui.meterTop }, [
413
+ node('span', { class: ui.meterValue, text: `~${totals.alwaysTokens.toLocaleString()}` }),
414
+ node('span', { class: ui.meterUnit, text: 'estimated tokens of instructions, every session' }),
415
+ ]),
416
+ node('div', { class: ui.meterFacts }, [
417
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.files) }), document.createTextNode(' files resolved')]),
418
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.alwaysLines) }), document.createTextNode(' lines always loaded')]),
419
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(totals.alwaysBytes / 1024).toFixed(1)} KB` }), document.createTextNode(' always loaded')]),
420
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.conditionalFiles) }), document.createTextNode(' path-scoped rules, loaded only on a match')]),
421
+ ]),
422
+ node('p', { class: ui.meterNote, text: 'Unlike MEMORY.md, none of this is truncated: CLAUDE.md files load in full however long they are. The 200-line figure is Claude Code’s guidance, not a cutoff, because long files cost context every session and are followed less reliably.' }),
423
+ ]));
424
+
425
+ if (data.problems.length) {
426
+ container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
427
+ for (const problem of data.problems) {
428
+ const describe = CONTEXT_PROBLEMS[problem.kind];
429
+ const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
430
+ const bad = problem.kind === 'missing' || problem.kind === 'invalid-glob' || problem.kind === 'glob-budget';
431
+ container.append(node('div', { class: ui.issue(bad) }, [
432
+ node('div', { class: ui.issueBody }, [
433
+ node('div', { class: ui.issueTitle, text: title }),
434
+ node('div', { class: ui.issueDetail, text: detail }),
435
+ ]),
436
+ ]));
322
437
  }
323
- card.append(row);
438
+ }
439
+
440
+ container.append(node('div', { class: ui.sectionLabel, text: `Loaded in this order · ${data.files.length}` }));
441
+ const card = node('div', { class: ui.card });
442
+ for (const file of data.files) {
443
+ const tags = [node('span', { class: ui.scopeBadge(file.scope), text: file.scope })];
444
+ if (file.kind === 'import') tags.push(node('span', { class: ui.badge(), text: `import · depth ${file.depth}` }));
445
+ if (file.kind === 'rule') tags.push(node('span', { class: ui.badge(), text: 'rule' }));
446
+ if (file.kind === 'managed-settings') tags.push(node('span', { class: ui.badge(), text: 'claudeMd setting' }));
447
+ if (file.conditional) tags.push(node('span', { class: ui.badge('warn'), text: 'only on a path match' }));
448
+
449
+ card.append(node('div', { class: ui.contextRow }, [
450
+ node('div', { class: ui.contextMain }, [
451
+ node('div', { class: ui.contextTags }, tags),
452
+ node('div', { class: ui.contextFile, text: file.file }),
453
+ ]),
454
+ node('div', { class: ui.contextSize, text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
455
+ ]));
324
456
  }
325
457
  container.append(card);
326
- }
327
458
 
328
- /* -------------------------------------------------------------- health */
459
+ if (data.excluded.length) {
460
+ container.append(node('div', { class: ui.sectionLabel, text: `Excluded by claudeMdExcludes · ${data.excluded.length}` }));
461
+ for (const file of data.excluded) {
462
+ container.append(node('div', { class: ui.issue(false) }, [
463
+ node('div', { class: ui.issueBody }, [node('div', { class: ui.issueDetail, text: file })]),
464
+ ]));
465
+ }
466
+ }
467
+
468
+ container.append(node('p', { class: ui.meterNote, text: 'This is re-derived from the documented resolution rules, not a report from Claude Code. Run /context in a session to see what it actually loaded.' }));
469
+ }
329
470
 
330
471
  function issue(title, detail, { bad = false, action, secondary } = {}) {
331
- return node('div', { class: `issue${bad ? ' bad' : ''}` }, [
332
- node('div', { class: 'issue-body' }, [
333
- node('div', { class: 'issue-title', text: title }),
334
- node('div', { class: 'issue-detail', text: detail }),
472
+ return node('div', { class: ui.issue(bad) }, [
473
+ node('div', { class: ui.issueBody }, [
474
+ node('div', { class: ui.issueTitle, text: title }),
475
+ node('div', { class: ui.issueDetail, text: detail }),
335
476
  ]),
336
- secondary ? node('button', { class: 'btn small', text: secondary.label, onclick: secondary.run }) : null,
337
- action ? node('button', { class: 'btn small', text: action.label, onclick: action.run }) : null,
477
+ secondary ? node('button', { class: ui.buttonSmall, text: secondary.label, onclick: secondary.run }) : null,
478
+ action ? node('button', { class: ui.buttonSmall, text: action.label, onclick: action.run }) : null,
338
479
  ]);
339
480
  }
340
481
 
341
482
  function renderHealth(container) {
342
- const { health, memories } = state.project;
483
+ const { health, memories } = state.store;
343
484
  if (!health.issues.length) {
344
- container.append(node('div', { class: 'card' }, [
345
- node('p', { style: 'margin:0', text: '\u2713 No consistency problems found.' }),
346
- node('p', { class: 'muted', style: 'margin:6px 0 0;font-size:13px', text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
485
+ container.append(node('div', { class: ui.card }, [
486
+ node('p', { class: ui.okLine, text: '\u2713 No consistency problems found.' }),
487
+ node('p', { class: ui.noteTight, text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
347
488
  ]));
348
489
  return;
349
490
  }
350
491
 
351
- // Rendered straight from health.issues so the tab and the badge always agree.
352
492
  for (const item of health.issues) {
353
493
  container.append(renderIssue(item, memories));
354
494
  }
@@ -369,12 +509,12 @@ function renderIssue(item, memories) {
369
509
  run: async () => {
370
510
  if (!confirm(`Remove line ${entry.index + 1} from MEMORY.md?\n\n${entry.text}`)) return;
371
511
  try {
372
- await api(`/api/projects/${encodeURIComponent(state.slug)}/index-line/delete`, {
512
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
373
513
  method: 'POST',
374
514
  body: JSON.stringify({ lineIndex: entry.index, expectedText: entry.text }),
375
515
  });
376
516
  toast('Pointer removed');
377
- await openProject(state.slug, { keepTab: true });
517
+ await openStore(state.storeId, { keepTab: true });
378
518
  } catch (err) {
379
519
  toast(err.message, { error: true });
380
520
  }
@@ -397,11 +537,11 @@ function renderIssue(item, memories) {
397
537
  run: async () => {
398
538
  if (!confirm(`Remove [[${link.target}]] from ${link.from}?\n\nThe link markup goes, the words stay:\n "see [[${link.target}]]" -> "see ${link.target}"\n\nA copy of the file is kept in the trash, so this can be undone.`)) return;
399
539
  try {
400
- const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/wikilink/remove`, {
540
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/wikilink/remove`, {
401
541
  method: 'POST',
402
542
  body: JSON.stringify({ file: link.from, target: link.target }),
403
543
  });
404
- await openProject(state.slug, { keepTab: true });
544
+ await openStore(state.storeId, { keepTab: true });
405
545
  toast(`Unlinked ${result.occurrences} reference(s)`, {
406
546
  action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
407
547
  });
@@ -467,11 +607,11 @@ function renderIssue(item, memories) {
467
607
 
468
608
  async function restoreFromTrash(id) {
469
609
  try {
470
- const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/restore`, {
610
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/restore`, {
471
611
  method: 'POST',
472
612
  body: JSON.stringify({ id }),
473
613
  });
474
- await openProject(state.slug, { keepTab: true });
614
+ await openStore(state.storeId, { keepTab: true });
475
615
  toast(result.indexRestored === 'appended'
476
616
  ? 'Restored, but MEMORY.md had changed - the index line was appended at the end'
477
617
  : 'Restored');
@@ -480,12 +620,10 @@ async function restoreFromTrash(id) {
480
620
  }
481
621
  }
482
622
 
483
- /* --------------------------------------------------------------- trash */
484
-
485
623
  function renderTrash(container) {
486
- const trash = state.project.trash;
624
+ const trash = state.store.trash;
487
625
  if (!trash.length) {
488
- container.append(node('p', { class: 'muted', text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
626
+ container.append(node('p', { class: ui.note, text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
489
627
  return;
490
628
  }
491
629
  for (const record of trash) {
@@ -493,20 +631,18 @@ function renderTrash(container) {
493
631
  const detail = record.kind === 'wikilink'
494
632
  ? `unlinked in ${record.sourceFile} · ${when}`
495
633
  : `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
496
- container.append(node('div', { class: `issue${record.present ? '' : ' bad'}` }, [
497
- node('div', { class: 'issue-body' }, [
498
- node('div', { class: 'issue-title', text: record.label || record.id }),
499
- node('div', { class: 'issue-detail', text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
634
+ container.append(node('div', { class: ui.issue(!record.present) }, [
635
+ node('div', { class: ui.issueBody }, [
636
+ node('div', { class: ui.issueTitle, text: record.label || record.id }),
637
+ node('div', { class: ui.issueDetail, text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
500
638
  ]),
501
639
  record.present
502
- ? node('button', { class: 'btn small primary', text: 'Restore', onclick: () => restoreFromTrash(record.id) })
640
+ ? node('button', { class: ui.buttonPrimarySmall, text: 'Restore', onclick: () => restoreFromTrash(record.id) })
503
641
  : null,
504
642
  ]));
505
643
  }
506
644
  }
507
645
 
508
- /* -------------------------------------------------------- delete dialog */
509
-
510
646
  function closeModal() {
511
647
  el('modal-root').textContent = '';
512
648
  }
@@ -514,7 +650,7 @@ function closeModal() {
514
650
  async function openDeleteDialog(file) {
515
651
  let preview;
516
652
  try {
517
- preview = await api(`/api/projects/${encodeURIComponent(state.slug)}/delete-preview`, {
653
+ preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete-preview`, {
518
654
  method: 'POST',
519
655
  body: JSON.stringify({ file }),
520
656
  });
@@ -522,53 +658,50 @@ async function openDeleteDialog(file) {
522
658
  return toast(err.message, { error: true });
523
659
  }
524
660
 
525
- const body = node('div', { class: 'modal-body' });
661
+ const body = node('div', { class: ui.modalBody });
526
662
 
527
- const removals = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: 'Will be removed' })]);
528
- removals.append(node('div', { class: 'will-item remove', text: `memory/${preview.file} → memory/.trash/` }));
663
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
664
+ removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.file} → memory/.trash/` }));
529
665
  for (const line of [...preview.indexLines, ...preview.continuations]) {
530
- removals.append(node('div', { class: 'will-item remove', text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
666
+ removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
531
667
  }
532
668
  if (!preview.indexLines.length && preview.hasIndex) {
533
- removals.append(node('div', { class: 'will-item', text: 'MEMORY.md has no index bullet for this file - nothing to unlink there.' }));
669
+ removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for this file - nothing to unlink there.' }));
534
670
  }
535
671
  body.append(removals);
536
672
 
537
673
  if (preview.inlineRefs.length) {
538
- const kept = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' })]);
674
+ const kept = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' })]);
539
675
  for (const ref of preview.inlineRefs) {
540
- kept.append(node('div', { class: 'will-item keep', text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
676
+ kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
541
677
  }
542
678
  body.append(kept);
543
679
  }
544
680
 
545
- // Memories that link here can be deleted in the same operation. Ticking one
546
- // is the common follow-up, so it lives in the dialog rather than forcing a
547
- // second pass through the list.
548
681
  const cascade = new Set();
549
682
  if (preview.inboundWikilinks.length) {
550
- const block = node('div', { class: 'will' });
551
- const head = node('div', { class: 'cascade-head' }, [
552
- node('div', { class: 'will-title', style: 'margin:0', text: `${preview.inboundWikilinks.length} memory(s) link here and will break` }),
553
- node('button', { class: 'link-btn', text: 'select all' }),
554
- ]);
555
- block.append(head);
683
+ const block = node('div', { class: ui.willBlock });
684
+ const selectAll = node('button', { class: ui.linkButton, text: 'select all' });
685
+ block.append(node('div', { class: ui.cascadeHead }, [
686
+ node('div', { class: ui.willTitle, text: `${preview.inboundWikilinks.length} memory(s) link here and will break` }),
687
+ selectAll,
688
+ ]));
556
689
 
557
- const list = node('div', { class: 'cascade' });
690
+ const list = node('div', { class: ui.cascadeList });
558
691
  const rows = [];
559
692
  for (const link of preview.inboundWikilinks) {
560
- const box = node('input', { type: 'checkbox' });
561
- const row = node('label', { class: 'cascade-row' }, [
693
+ const box = node('input', { type: 'checkbox', class: ui.checkbox });
694
+ const row = node('label', { class: ui.cascadeRow(false) }, [
562
695
  box,
563
- node('span', { class: 'cascade-text' }, [
564
- node('span', { class: 'cascade-name', text: link.fromName || link.from }),
565
- node('span', { class: 'cascade-detail', text: link.indexLine
696
+ node('span', { class: ui.cascadeText }, [
697
+ node('span', { class: ui.cascadeName, text: link.fromName || link.from }),
698
+ node('span', { class: ui.cascadeDetail, text: link.indexLine
566
699
  ? `${link.from} · MEMORY.md line ${link.indexLine.index + 1}`
567
700
  : `${link.from} · not in the index` }),
568
701
  ]),
569
702
  ]);
570
703
  box.addEventListener('change', () => {
571
- row.classList.toggle('on', box.checked);
704
+ row.className = ui.cascadeRow(box.checked);
572
705
  if (box.checked) cascade.add(link.from);
573
706
  else cascade.delete(link.from);
574
707
  updateButton();
@@ -577,110 +710,178 @@ async function openDeleteDialog(file) {
577
710
  list.append(row);
578
711
  }
579
712
  block.append(list);
580
- block.append(node('p', { class: 'muted', style: 'margin:6px 0 0;font-size:12px', text: 'Tick any you also want deleted - they go to the trash together and restore as one step.' }));
713
+ block.append(node('p', { class: ui.noteTight, text: 'Tick any you also want deleted - they go to the trash together and restore as one step.' }));
581
714
  body.append(block);
582
715
 
583
- head.querySelector('.link-btn').onclick = (event) => {
716
+ selectAll.onclick = (event) => {
584
717
  event.preventDefault();
585
718
  const turnOn = rows.some((r) => !r.box.checked);
586
719
  for (const { box } of rows) {
587
720
  if (box.checked !== turnOn) { box.checked = turnOn; box.dispatchEvent(new Event('change')); }
588
721
  }
589
- event.target.textContent = turnOn ? 'select none' : 'select all';
722
+ selectAll.textContent = turnOn ? 'select none' : 'select all';
590
723
  };
591
724
  }
592
725
 
593
- body.append(node('p', { class: 'muted', style: 'margin:0;font-size:12.5px', text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
726
+ body.append(node('p', { class: ui.noteTight, text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
727
+
728
+ const confirmButton = node('button', {
729
+ class: ui.button({ tone: 'danger' }),
730
+ text: 'Delete',
731
+ onclick: async () => {
732
+ closeModal();
733
+ try {
734
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
735
+ method: 'POST',
736
+ body: JSON.stringify({ file, alsoDelete: [...cascade] }),
737
+ });
738
+ if (state.selected === file || cascade.has(state.selected)) state.selected = null;
739
+ await openStore(state.storeId, { keepTab: true });
740
+ const count = result.record.files.length;
741
+ toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
742
+ action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
743
+ });
744
+ } catch (err) {
745
+ toast(err.message, { error: true });
746
+ }
747
+ },
748
+ });
594
749
 
595
- const modal = node('div', { class: 'modal' }, [
596
- node('header', {}, [
597
- node('h3', { text: `Delete "${preview.name || preview.file}"?` }),
598
- node('p', { class: 'muted', style: 'margin:0;font-size:13px', text: preview.description || '' }),
750
+ const modal = node('div', { class: ui.modal }, [
751
+ node('header', { class: ui.modalHead }, [
752
+ node('h3', { class: ui.modalTitle, text: `Delete "${preview.name || preview.file}"?` }),
753
+ node('p', { class: ui.note, text: preview.description || '' }),
599
754
  ]),
600
755
  body,
601
- node('footer', {}, [
602
- node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
756
+ node('footer', { class: ui.modalFoot }, [
757
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
758
+ confirmButton,
759
+ ]),
760
+ ]);
761
+
762
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
763
+ el('modal-root').append(backdrop);
764
+ updateButton();
765
+
766
+ function updateButton() {
767
+ confirmButton.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
768
+ }
769
+ }
770
+
771
+ function openRememberPathDialog() {
772
+ const store = state.store;
773
+ const input = node('input', {
774
+ type: 'text',
775
+ class: ui.pathInput,
776
+ value: store.guess || '',
777
+ placeholder: '/Users/you/repos/the-project',
778
+ spellcheck: 'false',
779
+ });
780
+
781
+ const body = node('div', { class: ui.modalBody }, [
782
+ node('p', { class: ui.note, text: `The folder on disk is "${store.slug}", and its name is a lossy encoding of a path. No session transcript remains to recover the real one from, so tell it once and it will be used from now on.` }),
783
+ input,
784
+ node('p', { class: ui.noteTight, text: 'Saved to ~/.claude-memory-admin/paths.json. That file records slugs and folder paths only, never memory content, and is the one thing this app writes outside a memory/ directory.' }),
785
+ ]);
786
+
787
+ const modal = node('div', { class: ui.modal }, [
788
+ node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Remember this project\u2019s path' })]),
789
+ body,
790
+ node('footer', { class: ui.modalFoot }, [
791
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
603
792
  node('button', {
604
- class: 'btn danger',
605
- text: 'Delete',
793
+ class: ui.button({ tone: 'primary' }),
794
+ text: 'Remember',
606
795
  onclick: async () => {
607
- closeModal();
796
+ const value = input.value.trim();
797
+ if (!value) return;
608
798
  try {
609
- const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/delete`, {
799
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/path/remember`, {
610
800
  method: 'POST',
611
- body: JSON.stringify({ file, alsoDelete: [...cascade] }),
612
- });
613
- if (state.selected === file || cascade.has(state.selected)) state.selected = null;
614
- await openProject(state.slug, { keepTab: true });
615
- const count = result.record.files.length;
616
- toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
617
- action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
801
+ body: JSON.stringify({ path: value }),
618
802
  });
619
803
  } catch (err) {
620
- toast(err.message, { error: true });
804
+ return toast(err.message, { error: true });
621
805
  }
806
+ closeModal();
807
+ await reloadStores();
808
+ await openStore(state.storeId, { keepTab: true });
809
+ toast(`Remembered ${value}`);
622
810
  },
623
811
  }),
624
812
  ]),
625
813
  ]);
626
814
 
627
- const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
815
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
628
816
  el('modal-root').append(backdrop);
629
- updateButton();
817
+ input.focus();
818
+ input.select();
819
+ }
630
820
 
631
- function updateButton() {
632
- const button = modal.querySelector('footer .btn.danger');
633
- if (button) button.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
821
+ async function forgetProjectPath() {
822
+ try {
823
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/path/forget`, { method: 'POST' });
824
+ } catch (err) {
825
+ return toast(err.message, { error: true });
634
826
  }
827
+ await reloadStores();
828
+ await openStore(state.storeId, { keepTab: true });
829
+ toast('Forgot the remembered path');
635
830
  }
636
831
 
637
- async function openProjectDeleteDialog() {
832
+ async function openStoreDeleteDialog() {
638
833
  let preview;
639
834
  try {
640
- preview = await api(`/api/projects/${encodeURIComponent(state.slug)}/project/delete-preview`, { method: 'POST' });
835
+ preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/project/delete-preview`, { method: 'POST' });
641
836
  } catch (err) {
642
837
  return toast(err.message, { error: true });
643
838
  }
644
839
 
645
840
  if (!preview.files.length && !preview.hasIndex) {
646
- return toast('This project has no memory to delete');
841
+ return toast('This store has no memory to delete');
647
842
  }
648
843
 
649
- const body = node('div', { class: 'modal-body' });
650
- const removals = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: 'Will be removed' })]);
844
+ const body = node('div', { class: ui.modalBody });
845
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
651
846
  if (preview.hasIndex) {
652
- removals.append(node('div', { class: 'will-item remove', text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
847
+ removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
653
848
  }
654
849
  for (const entry of preview.files) {
655
- removals.append(node('div', { class: 'will-item remove', text: `${entry.file} → memory/.trash/` }));
850
+ removals.append(node('div', { class: ui.willItem('remove'), text: `${entry.file} → memory/.trash/` }));
656
851
  }
657
852
  body.append(removals);
658
853
 
659
- body.append(node('div', { class: 'will' }, [
660
- node('div', { class: 'will-title', text: 'Will be kept' }),
661
- node('div', { class: 'will-item keep', text: 'Session transcripts (*.jsonl) and the project folder itself' }),
854
+ body.append(node('div', { class: ui.willBlock }, [
855
+ node('div', { class: ui.willTitle, text: 'Will be kept' }),
856
+ node('div', { class: ui.willItem('keep'), text: state.store.kind === 'auto'
857
+ ? 'Session transcripts (*.jsonl) and the project folder itself'
858
+ : 'The agent-memory folder itself, and every other agent\u2019s memory' }),
662
859
  ]));
663
860
 
664
- body.append(node('p', { class: 'muted', style: 'margin:0;font-size:12.5px', text: 'Everything moves to memory/.trash/ as one restore point, so the whole project can be put back in a single step from the Trash tab.' }));
861
+ body.append(node('p', { class: ui.noteTight, text: 'Everything moves to .trash/ inside the store as one restore point, so it can be put back in a single step from the Trash tab.' }));
862
+
863
+ if (state.store.kind === 'agent-project') {
864
+ body.append(node('p', { class: ui.subWarn, text: 'This store is checked into the repository. Deleting from it changes tracked files, and will show up in git status.' }));
865
+ }
665
866
 
666
- const modal = node('div', { class: 'modal' }, [
667
- node('header', {}, [
668
- node('h3', { text: `Delete all memory for ${state.project.label}?` }),
669
- node('p', { class: 'muted', style: 'margin:0;font-size:13px', text: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}` }),
867
+ const modal = node('div', { class: ui.modal }, [
868
+ node('header', { class: ui.modalHead }, [
869
+ node('h3', { class: ui.modalTitle, text: `Delete all memory for ${state.store.label}?` }),
870
+ node('p', { class: ui.note, text: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}` }),
670
871
  ]),
671
872
  body,
672
- node('footer', {}, [
673
- node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
873
+ node('footer', { class: ui.modalFoot }, [
874
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
674
875
  node('button', {
675
- class: 'btn danger',
876
+ class: ui.button({ tone: 'danger' }),
676
877
  text: `Delete ${preview.files.length + (preview.hasIndex ? 1 : 0)} files`,
677
878
  onclick: async () => {
678
879
  closeModal();
679
880
  try {
680
- const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/project/delete`, { method: 'POST' });
881
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/project/delete`, { method: 'POST' });
681
882
  state.selected = null;
682
- await openProject(state.slug, { keepTab: true });
683
- toast(`Cleared ${state.project.label}`, {
883
+ await openStore(state.storeId, { keepTab: true });
884
+ toast(`Cleared ${state.store.label}`, {
684
885
  action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
685
886
  });
686
887
  } catch (err) {
@@ -691,13 +892,10 @@ async function openProjectDeleteDialog() {
691
892
  ]),
692
893
  ]);
693
894
 
694
- const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
895
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
695
896
  el('modal-root').append(backdrop);
696
897
  }
697
898
 
698
-
699
- /* --------------------------------------------------------------- prune */
700
-
701
899
  function highlight(text, terms) {
702
900
  const wrapper = node('span');
703
901
  if (!terms.length) { wrapper.textContent = text; return wrapper; }
@@ -705,7 +903,7 @@ function highlight(text, terms) {
705
903
  let last = 0;
706
904
  for (const match of text.matchAll(pattern)) {
707
905
  if (match.index > last) wrapper.append(document.createTextNode(text.slice(last, match.index)));
708
- wrapper.append(node('mark', { text: match[0] }));
906
+ wrapper.append(node('mark', { class: ui.resultMark, text: match[0] }));
709
907
  last = match.index + match[0].length;
710
908
  }
711
909
  if (last < text.length) wrapper.append(document.createTextNode(text.slice(last)));
@@ -728,9 +926,9 @@ const PRUNE_SORTS = {
728
926
  };
729
927
 
730
928
  function renderBudget(container) {
731
- const { index } = state.project.stats;
929
+ const { index } = state.store.stats;
732
930
  const percent = Math.min(100, index.worstPercent);
733
- const level = index.level === 'over' ? 'way-over' : index.level === 'near' ? 'over' : '';
931
+ const level = index.level;
734
932
 
735
933
  const note = index.level === 'over'
736
934
  ? 'Over the limit. Everything past the cutoff is dropped when the session loads it, so those entries are effectively invisible to Claude right now.'
@@ -738,73 +936,94 @@ function renderBudget(container) {
738
936
  ? 'Approaching the limit. Once MEMORY.md passes it, everything after the cutoff stops being loaded at all.'
739
937
  : 'Comfortably inside the limit. MEMORY.md is loaded at the start of every session.';
740
938
 
741
- container.append(node('div', { class: 'meter' }, [
742
- node('div', { class: 'meter-top' }, [
743
- node('span', { class: 'meter-value', text: `${Math.round(index.worstPercent)}%` }),
744
- node('span', { class: 'meter-unit', text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
939
+ if (index.cutoff) {
940
+ const dropped = index.cutoff.droppedEntries;
941
+ const box = node('div', { class: ui.cutSummary });
942
+ box.append(node('div', { class: ui.cutSummaryHead, text: dropped.length
943
+ ? `${dropped.length} ${dropped.length === 1 ? 'memory is' : 'memories are'} past the cutoff and not loaded`
944
+ : `${index.cutoff.droppedLines} lines are past the cutoff and not loaded` }));
945
+ 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.` }));
946
+ for (const entry of dropped.slice(0, 20)) {
947
+ box.append(node('div', { class: ui.issue(true) }, [
948
+ node('div', { class: ui.issueBody }, [
949
+ node('div', { class: ui.issueTitle, text: entry.title || entry.file }),
950
+ node('div', { class: ui.issueDetail, text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
951
+ ]),
952
+ node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(entry.file) }),
953
+ ]));
954
+ }
955
+ if (dropped.length > 20) {
956
+ box.append(node('p', { class: ui.note, text: `${dropped.length - 20} more not listed.` }));
957
+ }
958
+ container.append(box);
959
+ }
960
+
961
+ container.append(node('div', { class: ui.meter }, [
962
+ node('div', { class: ui.meterTop }, [
963
+ node('span', { class: ui.meterValue, text: `${Math.round(index.worstPercent)}%` }),
964
+ node('span', { class: ui.meterUnit, text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
745
965
  ]),
746
- node('div', { class: 'meter-bar' }, [
747
- node('div', { class: `meter-fill ${level}`, style: `width:${percent.toFixed(1)}%` }),
966
+ node('div', { class: ui.meterBar }, [
967
+ node('div', { class: ui.meterFill(level), style: `width:${percent.toFixed(1)}%` }),
748
968
  ]),
749
- node('p', { class: 'meter-note', text: note }),
750
- node('div', { class: 'meter-facts' }, [
751
- node('span', {}, [node('b', { text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
752
- node('span', {}, [node('b', { text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
753
- node('span', {}, [node('b', { text: String(index.entryCount) }), document.createTextNode(' index entries')]),
754
- node('span', {}, [node('b', { text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
969
+ node('p', { class: ui.meterNote, text: note }),
970
+ node('div', { class: ui.meterFacts }, [
971
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
972
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
973
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(index.entryCount) }), document.createTextNode(' index entries')]),
974
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
755
975
  ]),
756
- node('p', { class: 'meter-note', style: 'margin-top:10px', 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.' }),
976
+ 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.' }),
757
977
  ]));
758
978
  }
759
979
 
760
980
  function renderPrune(container) {
761
- const project = state.project;
981
+ const project = state.store;
762
982
  renderBudget(container);
763
983
 
764
984
  if (!project.memories.length) {
765
- container.append(node('p', { class: 'muted', text: 'Nothing to prune - this project has no memories.' }));
985
+ container.append(node('p', { class: ui.note, text: 'Nothing to prune - this project has no memories.' }));
766
986
  return;
767
987
  }
768
988
 
769
- // --- bulk selection list ---
770
989
  const selected = state.pruneSelection;
771
- const bar = node('div', { class: 'prune-bar' });
772
- const sort = node('select');
990
+ const bar = node('div', { class: ui.pruneBar });
991
+ const sort = node('select', { class: ui.select });
773
992
  for (const [key, config] of Object.entries(PRUNE_SORTS)) {
774
993
  sort.append(node('option', { value: key, text: config.label, selected: key === state.pruneSort ? '' : null }));
775
994
  }
776
995
  sort.value = state.pruneSort;
777
996
  sort.onchange = () => { state.pruneSort = sort.value; renderTab(); };
778
997
 
779
- const count = node('span', { class: 'muted', style: 'font-size:12.5px' });
780
- const deleteButton = node('button', { class: 'btn danger small' });
781
- const clearButton = node('button', { class: 'btn small', text: 'Clear selection' });
998
+ const count = node('span', { class: ui.note });
999
+ const deleteButton = node('button', { class: ui.buttonDangerSmall });
1000
+ const clearButton = node('button', { class: ui.buttonSmall, text: 'Clear selection' });
782
1001
 
783
- bar.append(node('span', { class: 'section-label', style: 'margin:0', text: 'Prune' }), sort, node('span', { class: 'spacer' }), count, clearButton, deleteButton);
1002
+ bar.append(node('span', { class: ui.sectionLabel, text: 'Prune' }), sort, node('span', { class: ui.pruneSpacer }), count, clearButton, deleteButton);
784
1003
  container.append(bar);
785
1004
 
786
1005
  const list = node('div');
787
1006
  const ordered = [...project.memories].sort(PRUNE_SORTS[state.pruneSort].compare);
788
1007
  const rows = [];
789
1008
  for (const memory of ordered) {
790
- const box = node('input', { type: 'checkbox' });
1009
+ const box = node('input', { type: 'checkbox', class: ui.checkbox });
791
1010
  box.checked = selected.has(memory.file);
792
- const row = node('label', { class: `prune-row${box.checked ? ' on' : ''}` }, [
1011
+ const row = node('label', { class: ui.pruneRow(box.checked) }, [
793
1012
  box,
794
- node('span', { class: 'prune-main' }, [
795
- node('span', { class: 'prune-name', text: memory.name }),
796
- node('span', { class: 'prune-desc', text: memory.description || memory.file }),
1013
+ node('span', { class: ui.pruneMain }, [
1014
+ node('span', { class: ui.pruneName, text: memory.name }),
1015
+ node('span', { class: ui.pruneDesc, text: memory.description || memory.file }),
797
1016
  ]),
798
- node('span', { class: 'prune-facts' }, [
799
- node('span', { class: (memory.ageDays ?? 0) > 180 ? 'warn' : '', text: formatAge(memory.ageDays) }),
1017
+ node('span', { class: ui.pruneFacts }, [
1018
+ node('span', { class: ui.pruneFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
800
1019
  node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
801
- node('span', { class: memory.inbound.length ? '' : 'warn', text: `${memory.inbound.length} in` }),
802
- node('span', { class: memory.status === 'indexed' ? '' : 'warn', text: memory.status }),
1020
+ node('span', { class: ui.pruneFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
1021
+ node('span', { class: ui.pruneFact(memory.status !== 'indexed'), text: memory.status }),
803
1022
  ]),
804
1023
  ]);
805
1024
  box.addEventListener('change', () => {
806
1025
  if (box.checked) selected.add(memory.file); else selected.delete(memory.file);
807
- row.classList.toggle('on', box.checked);
1026
+ row.className = ui.pruneRow(box.checked);
808
1027
  updateBar();
809
1028
  });
810
1029
  rows.push({ box, memory });
@@ -816,7 +1035,6 @@ function renderPrune(container) {
816
1035
  count.textContent = selected.size ? `${selected.size} selected` : '';
817
1036
  deleteButton.textContent = `Delete ${selected.size || ''}`.trim();
818
1037
  deleteButton.disabled = selected.size === 0;
819
- deleteButton.style.opacity = selected.size ? '1' : '.45';
820
1038
  }
821
1039
  clearButton.onclick = () => {
822
1040
  selected.clear();
@@ -825,39 +1043,37 @@ function renderPrune(container) {
825
1043
  deleteButton.onclick = () => openBulkDeleteDialog([...selected]);
826
1044
  updateBar();
827
1045
 
828
- // --- long hooks ---
829
1046
  const { longHooks } = project.stats.index;
830
1047
  if (longHooks.length) {
831
- container.append(node('div', { class: 'section-label', text: `Long index hooks · ${longHooks.length}` }));
832
- container.append(node('p', { class: 'muted', style: 'margin:0 0 10px;font-size:12.5px', 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.' }));
1048
+ container.append(node('div', { class: ui.sectionLabel, text: `Long index hooks · ${longHooks.length}` }));
1049
+ 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.' }));
833
1050
  for (const hook of longHooks.slice(0, 12)) {
834
- container.append(node('div', { class: 'issue' }, [
835
- node('div', { class: 'issue-body' }, [
836
- node('div', { class: 'issue-title', text: `${hook.hookLength} chars: ${hook.title}` }),
837
- node('div', { class: 'issue-detail', text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
1051
+ container.append(node('div', { class: ui.issue(false) }, [
1052
+ node('div', { class: ui.issueBody }, [
1053
+ node('div', { class: ui.issueTitle, text: `${hook.hookLength} chars: ${hook.title}` }),
1054
+ node('div', { class: ui.issueDetail, text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
838
1055
  ]),
839
- node('button', { class: 'btn small', text: 'Open', onclick: () => selectMemory(hook.file) }),
1056
+ node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(hook.file) }),
840
1057
  ]));
841
1058
  }
842
1059
  }
843
1060
 
844
- // --- possible overlap ---
845
1061
  if (project.duplicates.length) {
846
- container.append(node('div', { class: 'section-label', text: `Possible overlap · ${project.duplicates.length}` }));
847
- container.append(node('p', { class: 'muted', style: 'margin:0 0 10px;font-size:12.5px', 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.' }));
1062
+ container.append(node('div', { class: ui.sectionLabel, text: `Possible overlap · ${project.duplicates.length}` }));
1063
+ 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.' }));
848
1064
  for (const pair of project.duplicates) {
849
- container.append(node('div', { class: 'dupe' }, [
850
- node('div', { class: 'dupe-head' }, [
851
- node('span', { class: 'dupe-score', text: `${pair.score}%` }),
1065
+ container.append(node('div', { class: ui.dupe }, [
1066
+ node('div', { class: ui.dupeHead }, [
1067
+ node('span', { class: ui.dupeScore, text: `${pair.score}%` }),
852
1068
  node('span', { text: `shared: ${pair.shared.join(', ')}` }),
853
1069
  ]),
854
- node('div', { class: 'dupe-pair' }, [
1070
+ node('div', { class: ui.dupePair }, [
855
1071
  ...[pair.a, pair.b].map((side) => node('div', {
856
- class: 'dupe-side',
1072
+ class: ui.dupeSide,
857
1073
  onclick: () => selectMemory(side.file),
858
1074
  }, [
859
- node('div', { class: 'n', text: side.name }),
860
- node('div', { class: 'd', text: side.description || side.file }),
1075
+ node('div', { class: ui.dupeSideName, text: side.name }),
1076
+ node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
861
1077
  ])),
862
1078
  ]),
863
1079
  ]));
@@ -867,16 +1083,15 @@ function renderPrune(container) {
867
1083
 
868
1084
  async function openBulkDeleteDialog(files) {
869
1085
  if (!files.length) return;
870
- const memories = files.map((file) => state.project.memories.find((m) => m.file === file)).filter(Boolean);
1086
+ const memories = files.map((file) => state.store.memories.find((m) => m.file === file)).filter(Boolean);
871
1087
 
872
- const body = node('div', { class: 'modal-body' });
873
- const removals = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: `${memories.length} memories will be trashed` })]);
1088
+ const body = node('div', { class: ui.modalBody });
1089
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${memories.length} memories will be trashed` })]);
874
1090
  for (const memory of memories) {
875
- removals.append(node('div', { class: 'will-item remove', text: `${memory.file}${memory.entry ? ` · MEMORY.md line ${memory.entry.index + 1}` : ' · not in the index'}` }));
1091
+ removals.append(node('div', { class: ui.willItem('remove'), text: `${memory.file}${memory.entry ? ` · MEMORY.md line ${memory.entry.index + 1}` : ' · not in the index'}` }));
876
1092
  }
877
1093
  body.append(removals);
878
1094
 
879
- // Links from memories that are NOT being deleted are the ones that break.
880
1095
  const doomed = new Set(files);
881
1096
  const breaking = [];
882
1097
  for (const memory of memories) {
@@ -885,31 +1100,31 @@ async function openBulkDeleteDialog(files) {
885
1100
  }
886
1101
  }
887
1102
  if (breaking.length) {
888
- const block = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: `${breaking.length} link(s) from memories you are keeping will break` })]);
889
- for (const link of breaking) block.append(node('div', { class: 'will-item keep', text: `${link.from} → [[${link.target}]]` }));
1103
+ const block = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${breaking.length} link(s) from memories you are keeping will break` })]);
1104
+ for (const link of breaking) block.append(node('div', { class: ui.willItem('keep'), text: `${link.from} → [[${link.target}]]` }));
890
1105
  body.append(block);
891
1106
  }
892
1107
 
893
- body.append(node('p', { class: 'muted', style: 'margin:0;font-size:12.5px', text: 'All of them go to memory/.trash/ as one restore point.' }));
1108
+ body.append(node('p', { class: ui.noteTight, text: 'All of them go to memory/.trash/ as one restore point.' }));
894
1109
 
895
- const modal = node('div', { class: 'modal' }, [
896
- node('header', {}, [node('h3', { text: `Delete ${memories.length} memories?` })]),
1110
+ const modal = node('div', { class: ui.modal }, [
1111
+ node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Delete ${memories.length} memories?` })]),
897
1112
  body,
898
- node('footer', {}, [
899
- node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
1113
+ node('footer', { class: ui.modalFoot }, [
1114
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
900
1115
  node('button', {
901
- class: 'btn danger',
1116
+ class: ui.button({ tone: 'danger' }),
902
1117
  text: `Delete ${memories.length}`,
903
1118
  onclick: async () => {
904
1119
  closeModal();
905
1120
  try {
906
- const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/delete-many`, {
1121
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete-many`, {
907
1122
  method: 'POST',
908
1123
  body: JSON.stringify({ files, label: `${memories.length} pruned memories` }),
909
1124
  });
910
1125
  state.pruneSelection.clear();
911
1126
  if (doomed.has(state.selected)) state.selected = null;
912
- await openProject(state.slug, { keepTab: true });
1127
+ await openStore(state.storeId, { keepTab: true });
913
1128
  toast(`Deleted ${memories.length} memories`, {
914
1129
  action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
915
1130
  });
@@ -920,12 +1135,10 @@ async function openBulkDeleteDialog(files) {
920
1135
  }),
921
1136
  ]),
922
1137
  ]);
923
- const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1138
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
924
1139
  el('modal-root').append(backdrop);
925
1140
  }
926
1141
 
927
- /* -------------------------------------------------------------- search */
928
-
929
1142
  let searchTimer = null;
930
1143
 
931
1144
  function scheduleSearch(value) {
@@ -958,43 +1171,45 @@ function renderSearch() {
958
1171
  ? `${data.total} result${data.total === 1 ? '' : 's'} for "${state.query.trim()}"`
959
1172
  : `No results for "${state.query.trim()}"`;
960
1173
 
961
- if (!data.total && !data.projects.length) {
962
- host.append(node('p', { class: 'muted', text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
1174
+ if (!data.total && !data.stores.length) {
1175
+ host.append(node('p', { class: ui.note, text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
963
1176
  return;
964
1177
  }
965
1178
 
966
- for (const group of data.projects) {
967
- const block = node('div', { class: 'result-group' });
968
- block.append(node('div', { class: 'section-label', text: `${group.label} · ${group.results.length}` }));
1179
+ for (const group of data.stores) {
1180
+ const block = node('div', { class: ui.resultGroup });
1181
+ block.append(node('div', { class: ui.sectionLabel, text: group.sublabel
1182
+ ? `${group.label} · ${group.sublabel} · ${group.results.length}`
1183
+ : `${group.label} · ${group.results.length}` }));
969
1184
 
970
1185
  for (const result of group.results) {
971
1186
  const button = node('button', {
972
- class: 'result',
973
- onclick: () => openFromSearch(group.slug, result.file),
1187
+ class: ui.result,
1188
+ onclick: () => openFromSearch(group.id, result.file),
974
1189
  });
975
- const top = node('div', { class: 'result-top' });
976
- const name = node('span', { class: 'result-name' });
1190
+ const top = node('div', { class: ui.resultTop });
1191
+ const name = node('span', { class: ui.resultName });
977
1192
  name.append(highlight(result.name, data.terms));
978
- top.append(name, node('span', { class: `badge type-${result.type}`, text: result.type }));
979
- if (result.status !== 'indexed') top.append(node('span', { class: `badge status-${result.status}`, text: result.status }));
1193
+ top.append(name, node('span', { class: ui.typeBadge(result.type), text: result.type }));
1194
+ if (result.status !== 'indexed') top.append(node('span', { class: ui.badge('warn'), text: result.status }));
980
1195
  button.append(top);
981
1196
 
982
1197
  if (result.snippet) {
983
- const snippet = node('div', { class: 'result-snippet' });
1198
+ const snippet = node('div', { class: ui.resultSnippet });
984
1199
  snippet.append(highlight(result.snippet.text, data.terms));
985
1200
  button.append(snippet);
986
1201
  }
987
- button.append(node('div', { class: 'result-where', text: `${result.file} · matched in ${result.fields.join(', ')}` }));
1202
+ button.append(node('div', { class: ui.resultWhere, text: `${result.file} · matched in ${result.fields.join(', ')}` }));
988
1203
  block.append(button);
989
1204
  }
990
1205
 
991
1206
  for (const hit of group.indexHits) {
992
1207
  const button = node('button', {
993
- class: 'result',
994
- onclick: () => openFromSearch(group.slug, hit.file, 'index'),
1208
+ class: ui.result,
1209
+ onclick: () => openFromSearch(group.id, hit.file, 'index'),
995
1210
  });
996
- button.append(node('div', { class: 'result-where', text: `MEMORY.md line ${hit.index + 1}` }));
997
- const line = node('div', { class: 'result-snippet' });
1211
+ button.append(node('div', { class: ui.resultWhere, text: `MEMORY.md line ${hit.index + 1}` }));
1212
+ const line = node('div', { class: ui.resultSnippet });
998
1213
  line.append(highlight(hit.text.trim(), data.terms));
999
1214
  button.append(line);
1000
1215
  block.append(button);
@@ -1003,9 +1218,9 @@ function renderSearch() {
1003
1218
  }
1004
1219
  }
1005
1220
 
1006
- async function openFromSearch(slug, file, tab = 'memories') {
1221
+ async function openFromSearch(id, file, tab = 'memories') {
1007
1222
  clearSearch();
1008
- if (slug !== state.slug) await openProject(slug);
1223
+ if (id !== state.storeId) await openStore(id);
1009
1224
  state.tab = tab;
1010
1225
  if (file) state.selected = file;
1011
1226
  renderTabs();
@@ -1022,25 +1237,24 @@ function clearSearch() {
1022
1237
  function renderView() {
1023
1238
  const searching = state.search !== null;
1024
1239
  el('search-view').hidden = !searching;
1025
- el('project-view').hidden = searching || !state.project;
1026
- el('empty-state').hidden = searching || Boolean(state.project);
1240
+ el('project-view').hidden = searching || !state.store;
1241
+ el('empty-state').hidden = searching || Boolean(state.store);
1027
1242
  if (searching) renderSearch();
1028
1243
  }
1029
1244
 
1030
- /* ------------------------------------------------------------ rendering */
1031
-
1032
1245
  function renderTab() {
1033
1246
  const container = el('tab-content');
1034
1247
  container.textContent = '';
1035
1248
  if (state.tab === 'memories') renderMemories(container);
1036
1249
  else if (state.tab === 'prune') renderPrune(container);
1250
+ else if (state.tab === 'context') renderContext(container);
1037
1251
  else if (state.tab === 'index') renderIndex(container);
1038
1252
  else if (state.tab === 'health') renderHealth(container);
1039
1253
  else if (state.tab === 'trash') renderTrash(container);
1040
1254
  else if (state.tab === 'graph') {
1041
- const wrap = node('div', { id: 'graph-wrap' });
1255
+ const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
1042
1256
  container.append(wrap);
1043
- renderGraph(wrap, state.project.graph, {
1257
+ renderGraph(wrap, state.store.graph, {
1044
1258
  selected: state.selected,
1045
1259
  spread: state.spread,
1046
1260
  onSelect: (file) => selectMemory(file),
@@ -1062,37 +1276,83 @@ function selectMemory(file) {
1062
1276
  } else {
1063
1277
  renderTab();
1064
1278
  }
1065
- document.querySelector('.mem.active')?.scrollIntoView({ block: 'nearest' });
1279
+ document.querySelector('[data-memory][data-active]')?.scrollIntoView({ block: 'nearest' });
1280
+ }
1281
+
1282
+ async function reloadStores() {
1283
+ const data = await api('/api/stores');
1284
+ state.stores = data.stores;
1285
+ renderStores();
1286
+ return data;
1287
+ }
1288
+
1289
+ function renderStoreHeader() {
1290
+ const store = state.store;
1291
+ el('project-title').textContent = store.label;
1292
+
1293
+ const sub = el('project-sub');
1294
+ sub.textContent = '';
1295
+
1296
+ if (store.kind !== 'auto') {
1297
+ const scope = store.kind === 'agent-user' ? 'user'
1298
+ : store.kind === 'agent-project' ? 'project' : 'local';
1299
+ sub.append(node('span', { text: store.dir }));
1300
+ sub.append(node('span', { class: ui.subNote, text: `subagent memory, ${scope} scope` }));
1301
+ if (store.kind === 'agent-project') {
1302
+ sub.append(node('span', { class: ui.subWarn, text: 'checked into the repository - changes here show up in git status' }));
1303
+ }
1304
+ return;
1305
+ }
1306
+
1307
+ sub.append(node('span', { text: store.resolvedBy === 'unresolved'
1308
+ ? `${store.path} (real path unresolved)`
1309
+ : store.path }));
1310
+
1311
+ const others = (store.workingDirs || []).filter((d) => d !== store.path);
1312
+ if (others.length) {
1313
+ sub.append(node('span', { class: ui.subNote, text: `also used from ${others.join(', ')}` }));
1314
+ }
1315
+ if (store.resolvedBy === 'unresolved') {
1316
+ sub.append(node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }));
1317
+ } else if (store.resolvedBy === 'remembered') {
1318
+ sub.append(node('span', { class: ui.subNote }, [
1319
+ document.createTextNode('path remembered, not recovered from a transcript '),
1320
+ node('button', { class: ui.linkButton, text: 'Forget', onclick: forgetProjectPath }),
1321
+ ]));
1322
+ }
1323
+
1324
+ const auto = store.autoMemory;
1325
+ if (auto && auto.known && !auto.enabled) {
1326
+ sub.append(node('span', { class: ui.subWarn, text: auto.scope === 'env'
1327
+ ? 'auto memory is off (CLAUDE_CODE_DISABLE_AUTO_MEMORY) - this store will not grow'
1328
+ : `auto memory is off (${auto.setBy}) - this store will not grow` }));
1329
+ }
1066
1330
  }
1067
1331
 
1068
- async function openProject(slug, { keepTab = false } = {}) {
1069
- if (slug !== state.slug) state.pruneSelection.clear();
1070
- state.slug = slug;
1332
+ async function openStore(id, { keepTab = false } = {}) {
1333
+ if (id !== state.storeId) state.pruneSelection.clear();
1334
+ state.storeId = id;
1071
1335
  if (!keepTab) {
1072
1336
  state.tab = 'memories';
1073
1337
  state.selected = null;
1074
1338
  }
1075
- renderProjects();
1339
+ renderStores();
1076
1340
 
1077
1341
  try {
1078
- state.project = await api(`/api/projects/${encodeURIComponent(slug)}`);
1342
+ state.store = await api(`/api/stores/${encodeURIComponent(id)}`);
1079
1343
  } catch (err) {
1080
1344
  return toast(err.message, { error: true });
1081
1345
  }
1082
1346
 
1083
- if (state.selected && !state.project.memories.some((m) => m.file === state.selected)) {
1347
+ if (state.selected && !state.store.memories.some((m) => m.file === state.selected)) {
1084
1348
  state.selected = null;
1085
1349
  }
1086
1350
 
1087
- el('project-title').textContent = state.project.label;
1088
- el('project-sub').textContent = state.project.resolvedBy === 'unresolved'
1089
- ? `${state.project.path} (real path unresolved)`
1090
- : state.project.path;
1351
+ renderStoreHeader();
1091
1352
 
1092
- // Refresh the sidebar count in case a delete changed it.
1093
- const listed = state.projects.find((p) => p.slug === slug);
1094
- if (listed) listed.memoryCount = state.project.memories.length;
1095
- renderProjects();
1353
+ const listed = state.stores.find((store) => store.id === id);
1354
+ if (listed) listed.memoryCount = state.store.memories.length;
1355
+ renderStores();
1096
1356
 
1097
1357
  renderView();
1098
1358
  renderTabs();
@@ -1100,7 +1360,8 @@ async function openProject(slug, { keepTab = false } = {}) {
1100
1360
  }
1101
1361
 
1102
1362
  function applyCollapsed() {
1103
- el('app').classList.toggle('collapsed', state.collapsed);
1363
+ el('app').className = state.collapsed ? ui.shellCollapsed : ui.shell;
1364
+ el('sidebar').className = state.collapsed ? ui.sidebarHidden : ui.sidebar;
1104
1365
  el('expand').hidden = !state.collapsed;
1105
1366
  localStorage.setItem('sidebarCollapsed', state.collapsed ? '1' : '0');
1106
1367
  }
@@ -1108,22 +1369,27 @@ function applyCollapsed() {
1108
1369
  function setCollapsed(value) {
1109
1370
  state.collapsed = value;
1110
1371
  applyCollapsed();
1111
- // The graph sizes itself to the container, so it has to be redrawn once the
1112
- // pane has actually changed width.
1113
- if (state.tab === 'graph' && state.project) requestAnimationFrame(renderTab);
1372
+ if (state.tab === 'graph' && state.store) requestAnimationFrame(renderTab);
1373
+ }
1374
+
1375
+ function applyStyles() {
1376
+ for (const element of document.querySelectorAll('[data-ui]')) {
1377
+ element.className = ui[element.dataset.ui] ?? '';
1378
+ }
1114
1379
  }
1115
1380
 
1116
1381
  async function init() {
1382
+ applyStyles();
1117
1383
  el('show-all').addEventListener('change', (event) => {
1118
1384
  state.showAll = event.target.checked;
1119
- renderProjects();
1385
+ renderStores();
1120
1386
  });
1121
1387
 
1122
1388
  el('search').addEventListener('input', (event) => scheduleSearch(event.target.value));
1123
1389
  el('search-clear').addEventListener('click', clearSearch);
1124
1390
  el('collapse').addEventListener('click', () => setCollapsed(true));
1125
1391
  el('expand').addEventListener('click', () => setCollapsed(false));
1126
- el('delete-project').addEventListener('click', openProjectDeleteDialog);
1392
+ el('delete-project').addEventListener('click', openStoreDeleteDialog);
1127
1393
  document.addEventListener('keydown', (event) => {
1128
1394
  const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName);
1129
1395
  if (event.key === '/' && !typing) {
@@ -1142,12 +1408,17 @@ async function init() {
1142
1408
  applyCollapsed();
1143
1409
 
1144
1410
  try {
1145
- const data = await api('/api/projects');
1146
- state.projects = data.projects;
1411
+ const data = await reloadStores();
1147
1412
  el('root-path').textContent = data.root;
1148
- renderProjects();
1149
- const first = state.projects.find((p) => p.memoryCount > 0);
1150
- if (first) openProject(first.slug);
1413
+ if (data.rootSource && data.rootSource !== 'default') {
1414
+ el('root-path').title = data.rootFile
1415
+ ? `autoMemoryDirectory set in ${data.rootFile}`
1416
+ : 'store chosen with --root';
1417
+ el('root-path').append(node('span', { class: ui.rootSource, text: data.rootSource }));
1418
+ }
1419
+ if (data.rootWarning) toast(data.rootWarning, { error: true });
1420
+ const first = state.stores.find((store) => store.memoryCount > 0);
1421
+ if (first) openStore(first.id);
1151
1422
  } catch (err) {
1152
1423
  toast(err.message, { error: true });
1153
1424
  }