claude-memory-admin 1.0.0 → 1.0.1

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
@@ -5,9 +5,9 @@ import { renderGraph } from '/graph.mjs';
5
5
  marked.setOptions({ gfm: true, breaks: false });
6
6
 
7
7
  const state = {
8
- projects: [],
9
- slug: null,
10
- project: null,
8
+ stores: [],
9
+ storeId: null,
10
+ store: null,
11
11
  tab: 'memories',
12
12
  selected: null,
13
13
  showAll: false,
@@ -114,33 +114,59 @@ function renderBody(container, memory, project) {
114
114
 
115
115
  /* --------------------------------------------------------------- sidebar */
116
116
 
117
- function renderProjects() {
117
+ /** The second line under a store's name: where it lives, in its own terms. */
118
+ function storeSubtitle(store) {
119
+ if (store.kind === 'auto') return store.path;
120
+ const scope = store.kind === 'agent-user' ? 'user'
121
+ : store.kind === 'agent-project' ? 'project' : 'local';
122
+ return `${scope} · ${store.sublabel}`;
123
+ }
124
+
125
+ function storeButton(store) {
126
+ const health = store.memoryCount === 0 ? 'none' : 'ok';
127
+ const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
128
+ return node('button', {
129
+ class: `project${store.id === state.storeId ? ' active' : ''}${store.hasMemoryDir ? '' : ' dim'}`,
130
+ onclick: () => openStore(store.id),
131
+ title: store.kind === 'auto' && store.resolvedBy === 'unresolved'
132
+ ? 'Real path could not be resolved - showing the raw folder name'
133
+ : store.dir,
134
+ }, [
135
+ node('span', { class: 'project-row' }, [
136
+ node('span', { class: `dot ${health}` }),
137
+ node('span', { class: 'project-name', text: store.label }),
138
+ off ? node('span', { class: 'off-marker', text: 'off', title: 'Auto memory is disabled for this project' }) : null,
139
+ node('span', { class: 'project-count', text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
140
+ ]),
141
+ node('span', { class: 'project-path', text: storeSubtitle(store) }),
142
+ ]);
143
+ }
144
+
145
+ function renderStores() {
118
146
  const list = el('project-list');
119
147
  list.textContent = '';
120
148
 
121
- const visible = state.projects.filter((p) => state.showAll || p.hasMemoryDir);
149
+ const visible = state.stores.filter((s) => state.showAll || s.hasMemoryDir);
122
150
  if (!visible.length) {
123
- list.append(node('p', { class: 'muted', text: 'No projects found.', style: 'padding:12px' }));
151
+ list.append(node('p', { class: 'muted', text: 'No memory stores found.', style: 'padding:12px' }));
124
152
  return;
125
153
  }
126
154
 
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);
155
+ // Subagent memory is a different thing from a project's auto memory - a
156
+ // different scope, written by a different agent - so it gets its own group
157
+ // rather than being mixed into the project list.
158
+ const groups = [
159
+ ['Projects', visible.filter((s) => s.kind === 'auto')],
160
+ ['Subagents', visible.filter((s) => s.kind !== 'auto')],
161
+ ];
162
+
163
+ for (const [title, stores] of groups) {
164
+ if (!stores.length) continue;
165
+ // With nothing to tell apart, a single heading is just noise.
166
+ if (groups.every(([, group]) => group.length) ) {
167
+ list.append(node('div', { class: 'sidebar-group', text: title }));
168
+ }
169
+ for (const store of stores) list.append(storeButton(store));
144
170
  }
145
171
  }
146
172
 
@@ -151,6 +177,7 @@ const TABS = [
151
177
  { id: 'index', label: 'MEMORY.md' },
152
178
  { id: 'graph', label: 'Graph' },
153
179
  { id: 'prune', label: 'Prune' },
180
+ { id: 'context', label: 'Context' },
154
181
  { id: 'health', label: 'Health' },
155
182
  { id: 'trash', label: 'Trash' },
156
183
  ];
@@ -158,12 +185,17 @@ const TABS = [
158
185
  function renderTabs() {
159
186
  const container = el('tabs');
160
187
  container.textContent = '';
188
+ const hasProjectDir = state.store.kind === 'auto'
189
+ ? state.store.resolvedBy !== 'unresolved'
190
+ : Boolean(state.store.projectPath);
191
+
161
192
  for (const tab of TABS) {
193
+ if (tab.id === 'context' && !hasProjectDir) continue;
162
194
  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 = '!';
195
+ if (tab.id === 'memories') badge = String(state.store.memories.length);
196
+ if (tab.id === 'health' && state.store.health.issueCount) badge = String(state.store.health.issueCount);
197
+ if (tab.id === 'trash' && state.store.trash.length) badge = String(state.store.trash.length);
198
+ if (tab.id === 'prune' && state.store.stats.index.overTarget) badge = '!';
167
199
 
168
200
  container.append(node('button', {
169
201
  class: `tab${state.tab === tab.id ? ' active' : ''}`,
@@ -194,7 +226,7 @@ function memoryButton(memory) {
194
226
  }
195
227
 
196
228
  function renderMemories(container) {
197
- const project = state.project;
229
+ const project = state.store;
198
230
  if (!project.memories.length) {
199
231
  container.append(node('p', { class: 'muted', text: 'This project has no memory files yet.' }));
200
232
  return;
@@ -224,7 +256,7 @@ function renderDetail() {
224
256
  if (!host) return;
225
257
  host.textContent = '';
226
258
 
227
- const memory = state.project.memories.find((m) => m.file === state.selected);
259
+ const memory = state.store.memories.find((m) => m.file === state.selected);
228
260
  if (!memory) {
229
261
  host.append(node('p', { class: 'muted', text: 'Select a memory to read it.' }));
230
262
  return;
@@ -247,7 +279,11 @@ function renderDetail() {
247
279
  const rows = [
248
280
  ['file', memory.file],
249
281
  ['status', memory.status],
250
- ['modified', memory.modified ? memory.modified.replace('T', ' ').replace(/\..*$/, '') : '-'],
282
+ ['modified', memory.modified
283
+ // mtime is reset by any copy or restore, so a date that is only a file
284
+ // timestamp is weaker evidence than one Claude Code actually stamped.
285
+ ? `${memory.modified.replace('T', ' ').replace(/\..*$/, '')}${memory.modifiedFrom === 'mtime' ? ' (file mtime)' : ''}`
286
+ : '-'],
251
287
  ['size', `${(memory.bytes / 1024).toFixed(1)} KB`],
252
288
  ];
253
289
  if (!memory.nameMatchesFile) rows.push(['name', `${memory.name} (differs from filename)`]);
@@ -261,7 +297,7 @@ function renderDetail() {
261
297
 
262
298
  const body = node('div', { class: 'body' });
263
299
  card.append(body);
264
- renderBody(body, memory, state.project);
300
+ renderBody(body, memory, state.store);
265
301
 
266
302
  const outbound = memory.outboundResolved || [];
267
303
  if (outbound.length || memory.inbound.length) {
@@ -282,7 +318,7 @@ function renderDetail() {
282
318
  links.append(node('div', { class: 'section-label', text: 'Linked from' }));
283
319
  const chips = node('div', { class: 'links' });
284
320
  for (const link of memory.inbound) {
285
- const source = state.project.memories.find((m) => m.file === link.from);
321
+ const source = state.store.memories.find((m) => m.file === link.from);
286
322
  chips.append(node('button', {
287
323
  class: 'chip',
288
324
  text: source ? source.name : link.from,
@@ -300,19 +336,27 @@ function renderDetail() {
300
336
  /* ----------------------------------------------------------- MEMORY.md */
301
337
 
302
338
  function renderIndex(container) {
303
- const project = state.project;
339
+ const project = state.store;
304
340
  if (!project.hasIndex) {
305
341
  container.append(node('p', { class: 'muted', text: 'This project has no MEMORY.md.' }));
306
342
  return;
307
343
  }
308
344
 
309
345
  const missing = new Set(project.health.danglingIndex.map((d) => d.index));
346
+ const { cutoff } = project.stats.index;
310
347
  const card = node('div', { class: 'card' });
311
348
  card.append(node('p', { class: 'muted', style: 'margin-top:0', text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }));
312
349
 
313
350
  for (const line of project.index.lines) {
351
+ // The cutoff is a raw line number, so it lands in the right place even
352
+ // though frontmatter and comments are stripped before the limit is measured.
353
+ if (cutoff && line.index === cutoff.rawLine) {
354
+ card.append(node('div', { class: 'cut-line' }, [
355
+ node('span', { class: 'cut-label', text: `not loaded past here — ${cutoff.droppedLines} lines dropped on ${cutoff.by}` }),
356
+ ]));
357
+ }
314
358
  const row = node('div', {
315
- class: `index-line kind-${line.kind}${missing.has(line.index) ? ' dangling' : ''}`,
359
+ class: `index-line kind-${line.kind}${missing.has(line.index) ? ' dangling' : ''}${cutoff && line.index >= cutoff.rawLine ? ' beyond-cut' : ''}`,
316
360
  }, [
317
361
  node('span', { class: 'ln', text: String(line.index + 1) }),
318
362
  node('span', { class: 'content', text: line.text || ' ' }),
@@ -325,6 +369,100 @@ function renderIndex(container) {
325
369
  container.append(card);
326
370
  }
327
371
 
372
+
373
+ /* ------------------------------------------------------------- context */
374
+
375
+ const SCOPE_ORDER = ['managed', 'user', 'project', 'local'];
376
+
377
+ const CONTEXT_PROBLEMS = {
378
+ missing: (p) => ['Import does not resolve', `${p.spec} from ${p.from} - nothing at ${p.file}`],
379
+ 'too-deep': (p) => ['Import chain is too deep', `${p.file} is a fifth hop; Claude Code follows four, so this never loads`],
380
+ cycle: (p) => ['Circular import', `${p.from} imports ${p.file}, which was already loaded`],
381
+ external: (p) => ['Import resolves outside the project', `${p.file} - Claude Code asks you to approve these once, and a declined one stays disabled silently`],
382
+ 'invalid-glob': (p) => [`Rule matches nothing: ${p.pattern}`, `${p.file} - ${p.reason}`],
383
+ '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`],
384
+ 'long-claude-md': (p) => [`${p.lines} lines, over the 200-line guidance`, `${p.file} - long files cost context every session and reduce adherence`],
385
+ '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.`],
386
+ };
387
+
388
+ async function renderContext(container) {
389
+ container.append(node('p', { class: 'muted', text: 'Reading instruction files…' }));
390
+ let data;
391
+ try {
392
+ data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
393
+ } catch (err) {
394
+ container.textContent = '';
395
+ return container.append(node('p', { class: 'muted', text: err.message }));
396
+ }
397
+ // The tab may have been switched while the request was in flight.
398
+ if (state.tab !== 'context') return;
399
+ container.textContent = '';
400
+
401
+ if (!data.projectDir) {
402
+ return container.append(node('p', { class: 'muted', text: 'This store is not tied to a project directory, so there are no instruction files to resolve.' }));
403
+ }
404
+
405
+ const { totals } = data;
406
+ container.append(node('div', { class: 'meter' }, [
407
+ node('div', { class: 'meter-top' }, [
408
+ node('span', { class: 'meter-value', text: `~${totals.alwaysTokens.toLocaleString()}` }),
409
+ node('span', { class: 'meter-unit', text: 'estimated tokens of instructions, every session' }),
410
+ ]),
411
+ node('div', { class: 'meter-facts' }, [
412
+ node('span', {}, [node('b', { text: String(totals.files) }), document.createTextNode(' files resolved')]),
413
+ node('span', {}, [node('b', { text: String(totals.alwaysLines) }), document.createTextNode(' lines always loaded')]),
414
+ node('span', {}, [node('b', { text: `${(totals.alwaysBytes / 1024).toFixed(1)} KB` }), document.createTextNode(' always loaded')]),
415
+ node('span', {}, [node('b', { text: String(totals.conditionalFiles) }), document.createTextNode(' path-scoped rules, loaded only on a match')]),
416
+ ]),
417
+ node('p', { class: 'meter-note', 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.' }),
418
+ ]));
419
+
420
+ if (data.problems.length) {
421
+ container.append(node('div', { class: 'section-label', text: `Problems · ${data.problems.length}` }));
422
+ for (const problem of data.problems) {
423
+ const describe = CONTEXT_PROBLEMS[problem.kind];
424
+ const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
425
+ const bad = problem.kind === 'missing' || problem.kind === 'invalid-glob' || problem.kind === 'glob-budget';
426
+ container.append(node('div', { class: `issue${bad ? ' bad' : ''}` }, [
427
+ node('div', { class: 'issue-body' }, [
428
+ node('div', { class: 'issue-title', text: title }),
429
+ node('div', { class: 'issue-detail', text: detail }),
430
+ ]),
431
+ ]));
432
+ }
433
+ }
434
+
435
+ container.append(node('div', { class: 'section-label', text: `Loaded in this order · ${data.files.length}` }));
436
+ const card = node('div', { class: 'card' });
437
+ for (const file of data.files) {
438
+ const tags = [node('span', { class: `badge scope-${file.scope}`, text: file.scope })];
439
+ if (file.kind === 'import') tags.push(node('span', { class: 'badge', text: `import · depth ${file.depth}` }));
440
+ if (file.kind === 'rule') tags.push(node('span', { class: 'badge', text: 'rule' }));
441
+ if (file.kind === 'managed-settings') tags.push(node('span', { class: 'badge', text: 'claudeMd setting' }));
442
+ if (file.conditional) tags.push(node('span', { class: 'badge status-referenced', text: 'only on a path match' }));
443
+
444
+ card.append(node('div', { class: 'ctx-row' }, [
445
+ node('div', { class: 'ctx-main' }, [
446
+ node('div', { class: 'ctx-tags' }, tags),
447
+ node('div', { class: 'ctx-file', text: file.file }),
448
+ ]),
449
+ node('div', { class: 'ctx-size', text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
450
+ ]));
451
+ }
452
+ container.append(card);
453
+
454
+ if (data.excluded.length) {
455
+ container.append(node('div', { class: 'section-label', text: `Excluded by claudeMdExcludes · ${data.excluded.length}` }));
456
+ for (const file of data.excluded) {
457
+ container.append(node('div', { class: 'issue' }, [
458
+ node('div', { class: 'issue-body' }, [node('div', { class: 'issue-detail', text: file })]),
459
+ ]));
460
+ }
461
+ }
462
+
463
+ container.append(node('p', { class: 'meter-note', 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.' }));
464
+ }
465
+
328
466
  /* -------------------------------------------------------------- health */
329
467
 
330
468
  function issue(title, detail, { bad = false, action, secondary } = {}) {
@@ -339,7 +477,7 @@ function issue(title, detail, { bad = false, action, secondary } = {}) {
339
477
  }
340
478
 
341
479
  function renderHealth(container) {
342
- const { health, memories } = state.project;
480
+ const { health, memories } = state.store;
343
481
  if (!health.issues.length) {
344
482
  container.append(node('div', { class: 'card' }, [
345
483
  node('p', { style: 'margin:0', text: '\u2713 No consistency problems found.' }),
@@ -369,12 +507,12 @@ function renderIssue(item, memories) {
369
507
  run: async () => {
370
508
  if (!confirm(`Remove line ${entry.index + 1} from MEMORY.md?\n\n${entry.text}`)) return;
371
509
  try {
372
- await api(`/api/projects/${encodeURIComponent(state.slug)}/index-line/delete`, {
510
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
373
511
  method: 'POST',
374
512
  body: JSON.stringify({ lineIndex: entry.index, expectedText: entry.text }),
375
513
  });
376
514
  toast('Pointer removed');
377
- await openProject(state.slug, { keepTab: true });
515
+ await openStore(state.storeId, { keepTab: true });
378
516
  } catch (err) {
379
517
  toast(err.message, { error: true });
380
518
  }
@@ -397,11 +535,11 @@ function renderIssue(item, memories) {
397
535
  run: async () => {
398
536
  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
537
  try {
400
- const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/wikilink/remove`, {
538
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/wikilink/remove`, {
401
539
  method: 'POST',
402
540
  body: JSON.stringify({ file: link.from, target: link.target }),
403
541
  });
404
- await openProject(state.slug, { keepTab: true });
542
+ await openStore(state.storeId, { keepTab: true });
405
543
  toast(`Unlinked ${result.occurrences} reference(s)`, {
406
544
  action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
407
545
  });
@@ -467,11 +605,11 @@ function renderIssue(item, memories) {
467
605
 
468
606
  async function restoreFromTrash(id) {
469
607
  try {
470
- const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/restore`, {
608
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/restore`, {
471
609
  method: 'POST',
472
610
  body: JSON.stringify({ id }),
473
611
  });
474
- await openProject(state.slug, { keepTab: true });
612
+ await openStore(state.storeId, { keepTab: true });
475
613
  toast(result.indexRestored === 'appended'
476
614
  ? 'Restored, but MEMORY.md had changed - the index line was appended at the end'
477
615
  : 'Restored');
@@ -483,7 +621,7 @@ async function restoreFromTrash(id) {
483
621
  /* --------------------------------------------------------------- trash */
484
622
 
485
623
  function renderTrash(container) {
486
- const trash = state.project.trash;
624
+ const trash = state.store.trash;
487
625
  if (!trash.length) {
488
626
  container.append(node('p', { class: 'muted', text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
489
627
  return;
@@ -514,7 +652,7 @@ function closeModal() {
514
652
  async function openDeleteDialog(file) {
515
653
  let preview;
516
654
  try {
517
- preview = await api(`/api/projects/${encodeURIComponent(state.slug)}/delete-preview`, {
655
+ preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete-preview`, {
518
656
  method: 'POST',
519
657
  body: JSON.stringify({ file }),
520
658
  });
@@ -606,12 +744,12 @@ async function openDeleteDialog(file) {
606
744
  onclick: async () => {
607
745
  closeModal();
608
746
  try {
609
- const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/delete`, {
747
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
610
748
  method: 'POST',
611
749
  body: JSON.stringify({ file, alsoDelete: [...cascade] }),
612
750
  });
613
751
  if (state.selected === file || cascade.has(state.selected)) state.selected = null;
614
- await openProject(state.slug, { keepTab: true });
752
+ await openStore(state.storeId, { keepTab: true });
615
753
  const count = result.record.files.length;
616
754
  toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
617
755
  action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
@@ -634,16 +772,85 @@ async function openDeleteDialog(file) {
634
772
  }
635
773
  }
636
774
 
637
- async function openProjectDeleteDialog() {
775
+ /**
776
+ * Name a store whose transcripts have aged out.
777
+ *
778
+ * Claude Code sweeps session transcripts on a retention period but keeps memory
779
+ * forever, so the evidence that names a store expires while the store does not.
780
+ * This is the only thing in the app that writes outside a memory directory, so
781
+ * it happens only when asked for, and says so.
782
+ */
783
+ function openRememberPathDialog() {
784
+ const store = state.store;
785
+ const input = node('input', {
786
+ type: 'text',
787
+ class: 'path-input',
788
+ value: store.guess || '',
789
+ placeholder: '/Users/you/repos/the-project',
790
+ spellcheck: 'false',
791
+ });
792
+
793
+ const body = node('div', { class: 'modal-body' }, [
794
+ node('p', { class: 'muted', style: 'margin-top:0', 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.` }),
795
+ input,
796
+ node('p', { class: 'muted', style: 'margin-bottom:0;font-size:12.5px', 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.' }),
797
+ ]);
798
+
799
+ const modal = node('div', { class: 'modal' }, [
800
+ node('header', {}, [node('h3', { text: 'Remember this project\u2019s path' })]),
801
+ body,
802
+ node('footer', {}, [
803
+ node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
804
+ node('button', {
805
+ class: 'btn primary',
806
+ text: 'Remember',
807
+ onclick: async () => {
808
+ const value = input.value.trim();
809
+ if (!value) return;
810
+ try {
811
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/path/remember`, {
812
+ method: 'POST',
813
+ body: JSON.stringify({ path: value }),
814
+ });
815
+ } catch (err) {
816
+ return toast(err.message, { error: true });
817
+ }
818
+ closeModal();
819
+ await reloadStores();
820
+ await openStore(state.storeId, { keepTab: true });
821
+ toast(`Remembered ${value}`);
822
+ },
823
+ }),
824
+ ]),
825
+ ]);
826
+
827
+ const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
828
+ el('modal-root').append(backdrop);
829
+ input.focus();
830
+ input.select();
831
+ }
832
+
833
+ async function forgetProjectPath() {
834
+ try {
835
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/path/forget`, { method: 'POST' });
836
+ } catch (err) {
837
+ return toast(err.message, { error: true });
838
+ }
839
+ await reloadStores();
840
+ await openStore(state.storeId, { keepTab: true });
841
+ toast('Forgot the remembered path');
842
+ }
843
+
844
+ async function openStoreDeleteDialog() {
638
845
  let preview;
639
846
  try {
640
- preview = await api(`/api/projects/${encodeURIComponent(state.slug)}/project/delete-preview`, { method: 'POST' });
847
+ preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/project/delete-preview`, { method: 'POST' });
641
848
  } catch (err) {
642
849
  return toast(err.message, { error: true });
643
850
  }
644
851
 
645
852
  if (!preview.files.length && !preview.hasIndex) {
646
- return toast('This project has no memory to delete');
853
+ return toast('This store has no memory to delete');
647
854
  }
648
855
 
649
856
  const body = node('div', { class: 'modal-body' });
@@ -658,14 +865,21 @@ async function openProjectDeleteDialog() {
658
865
 
659
866
  body.append(node('div', { class: 'will' }, [
660
867
  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' }),
868
+ node('div', { class: 'will-item keep', text: state.store.kind === 'auto'
869
+ ? 'Session transcripts (*.jsonl) and the project folder itself'
870
+ : 'The agent-memory folder itself, and every other agent\u2019s memory' }),
662
871
  ]));
663
872
 
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.' }));
873
+ body.append(node('p', { class: 'muted', style: 'margin:0;font-size:12.5px', 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.' }));
874
+
875
+ // A project-scoped agent store is committed, so this is a working-tree change.
876
+ if (state.store.kind === 'agent-project') {
877
+ body.append(node('p', { class: 'sub-warn', style: 'margin:8px 0 0', text: 'This store is checked into the repository. Deleting from it changes tracked files, and will show up in git status.' }));
878
+ }
665
879
 
666
880
  const modal = node('div', { class: 'modal' }, [
667
881
  node('header', {}, [
668
- node('h3', { text: `Delete all memory for ${state.project.label}?` }),
882
+ node('h3', { text: `Delete all memory for ${state.store.label}?` }),
669
883
  node('p', { class: 'muted', style: 'margin:0;font-size:13px', text: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}` }),
670
884
  ]),
671
885
  body,
@@ -677,10 +891,10 @@ async function openProjectDeleteDialog() {
677
891
  onclick: async () => {
678
892
  closeModal();
679
893
  try {
680
- const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/project/delete`, { method: 'POST' });
894
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/project/delete`, { method: 'POST' });
681
895
  state.selected = null;
682
- await openProject(state.slug, { keepTab: true });
683
- toast(`Cleared ${state.project.label}`, {
896
+ await openStore(state.storeId, { keepTab: true });
897
+ toast(`Cleared ${state.store.label}`, {
684
898
  action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
685
899
  });
686
900
  } catch (err) {
@@ -728,7 +942,7 @@ const PRUNE_SORTS = {
728
942
  };
729
943
 
730
944
  function renderBudget(container) {
731
- const { index } = state.project.stats;
945
+ const { index } = state.store.stats;
732
946
  const percent = Math.min(100, index.worstPercent);
733
947
  const level = index.level === 'over' ? 'way-over' : index.level === 'near' ? 'over' : '';
734
948
 
@@ -738,6 +952,28 @@ function renderBudget(container) {
738
952
  ? 'Approaching the limit. Once MEMORY.md passes it, everything after the cutoff stops being loaded at all.'
739
953
  : 'Comfortably inside the limit. MEMORY.md is loaded at the start of every session.';
740
954
 
955
+ if (index.cutoff) {
956
+ const dropped = index.cutoff.droppedEntries;
957
+ const box = node('div', { class: 'cut-summary' });
958
+ box.append(node('div', { class: 'cut-summary-head', text: dropped.length
959
+ ? `${dropped.length} ${dropped.length === 1 ? 'memory is' : 'memories are'} past the cutoff and not loaded`
960
+ : `${index.cutoff.droppedLines} lines are past the cutoff and not loaded` }));
961
+ box.append(node('p', { class: 'muted', text: `MEMORY.md stops loading at line ${index.cutoff.rawLine + 1}, bounded by ${index.cutoff.by}. Claude cannot see anything below it.` }));
962
+ for (const entry of dropped.slice(0, 20)) {
963
+ box.append(node('div', { class: 'issue' }, [
964
+ node('div', { class: 'issue-body' }, [
965
+ node('div', { class: 'issue-title', text: entry.title || entry.file }),
966
+ node('div', { class: 'issue-detail', text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
967
+ ]),
968
+ node('button', { class: 'btn small', text: 'Open', onclick: () => selectMemory(entry.file) }),
969
+ ]));
970
+ }
971
+ if (dropped.length > 20) {
972
+ box.append(node('p', { class: 'muted', text: `${dropped.length - 20} more not listed.` }));
973
+ }
974
+ container.append(box);
975
+ }
976
+
741
977
  container.append(node('div', { class: 'meter' }, [
742
978
  node('div', { class: 'meter-top' }, [
743
979
  node('span', { class: 'meter-value', text: `${Math.round(index.worstPercent)}%` }),
@@ -758,7 +994,7 @@ function renderBudget(container) {
758
994
  }
759
995
 
760
996
  function renderPrune(container) {
761
- const project = state.project;
997
+ const project = state.store;
762
998
  renderBudget(container);
763
999
 
764
1000
  if (!project.memories.length) {
@@ -867,7 +1103,7 @@ function renderPrune(container) {
867
1103
 
868
1104
  async function openBulkDeleteDialog(files) {
869
1105
  if (!files.length) return;
870
- const memories = files.map((file) => state.project.memories.find((m) => m.file === file)).filter(Boolean);
1106
+ const memories = files.map((file) => state.store.memories.find((m) => m.file === file)).filter(Boolean);
871
1107
 
872
1108
  const body = node('div', { class: 'modal-body' });
873
1109
  const removals = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: `${memories.length} memories will be trashed` })]);
@@ -903,13 +1139,13 @@ async function openBulkDeleteDialog(files) {
903
1139
  onclick: async () => {
904
1140
  closeModal();
905
1141
  try {
906
- const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/delete-many`, {
1142
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete-many`, {
907
1143
  method: 'POST',
908
1144
  body: JSON.stringify({ files, label: `${memories.length} pruned memories` }),
909
1145
  });
910
1146
  state.pruneSelection.clear();
911
1147
  if (doomed.has(state.selected)) state.selected = null;
912
- await openProject(state.slug, { keepTab: true });
1148
+ await openStore(state.storeId, { keepTab: true });
913
1149
  toast(`Deleted ${memories.length} memories`, {
914
1150
  action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
915
1151
  });
@@ -958,19 +1194,21 @@ function renderSearch() {
958
1194
  ? `${data.total} result${data.total === 1 ? '' : 's'} for "${state.query.trim()}"`
959
1195
  : `No results for "${state.query.trim()}"`;
960
1196
 
961
- if (!data.total && !data.projects.length) {
1197
+ if (!data.total && !data.stores.length) {
962
1198
  host.append(node('p', { class: 'muted', text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
963
1199
  return;
964
1200
  }
965
1201
 
966
- for (const group of data.projects) {
1202
+ for (const group of data.stores) {
967
1203
  const block = node('div', { class: 'result-group' });
968
- block.append(node('div', { class: 'section-label', text: `${group.label} · ${group.results.length}` }));
1204
+ block.append(node('div', { class: 'section-label', text: group.sublabel
1205
+ ? `${group.label} · ${group.sublabel} · ${group.results.length}`
1206
+ : `${group.label} · ${group.results.length}` }));
969
1207
 
970
1208
  for (const result of group.results) {
971
1209
  const button = node('button', {
972
1210
  class: 'result',
973
- onclick: () => openFromSearch(group.slug, result.file),
1211
+ onclick: () => openFromSearch(group.id, result.file),
974
1212
  });
975
1213
  const top = node('div', { class: 'result-top' });
976
1214
  const name = node('span', { class: 'result-name' });
@@ -991,7 +1229,7 @@ function renderSearch() {
991
1229
  for (const hit of group.indexHits) {
992
1230
  const button = node('button', {
993
1231
  class: 'result',
994
- onclick: () => openFromSearch(group.slug, hit.file, 'index'),
1232
+ onclick: () => openFromSearch(group.id, hit.file, 'index'),
995
1233
  });
996
1234
  button.append(node('div', { class: 'result-where', text: `MEMORY.md line ${hit.index + 1}` }));
997
1235
  const line = node('div', { class: 'result-snippet' });
@@ -1003,9 +1241,9 @@ function renderSearch() {
1003
1241
  }
1004
1242
  }
1005
1243
 
1006
- async function openFromSearch(slug, file, tab = 'memories') {
1244
+ async function openFromSearch(id, file, tab = 'memories') {
1007
1245
  clearSearch();
1008
- if (slug !== state.slug) await openProject(slug);
1246
+ if (id !== state.storeId) await openStore(id);
1009
1247
  state.tab = tab;
1010
1248
  if (file) state.selected = file;
1011
1249
  renderTabs();
@@ -1022,8 +1260,8 @@ function clearSearch() {
1022
1260
  function renderView() {
1023
1261
  const searching = state.search !== null;
1024
1262
  el('search-view').hidden = !searching;
1025
- el('project-view').hidden = searching || !state.project;
1026
- el('empty-state').hidden = searching || Boolean(state.project);
1263
+ el('project-view').hidden = searching || !state.store;
1264
+ el('empty-state').hidden = searching || Boolean(state.store);
1027
1265
  if (searching) renderSearch();
1028
1266
  }
1029
1267
 
@@ -1034,13 +1272,14 @@ function renderTab() {
1034
1272
  container.textContent = '';
1035
1273
  if (state.tab === 'memories') renderMemories(container);
1036
1274
  else if (state.tab === 'prune') renderPrune(container);
1275
+ else if (state.tab === 'context') renderContext(container);
1037
1276
  else if (state.tab === 'index') renderIndex(container);
1038
1277
  else if (state.tab === 'health') renderHealth(container);
1039
1278
  else if (state.tab === 'trash') renderTrash(container);
1040
1279
  else if (state.tab === 'graph') {
1041
1280
  const wrap = node('div', { id: 'graph-wrap' });
1042
1281
  container.append(wrap);
1043
- renderGraph(wrap, state.project.graph, {
1282
+ renderGraph(wrap, state.store.graph, {
1044
1283
  selected: state.selected,
1045
1284
  spread: state.spread,
1046
1285
  onSelect: (file) => selectMemory(file),
@@ -1065,34 +1304,87 @@ function selectMemory(file) {
1065
1304
  document.querySelector('.mem.active')?.scrollIntoView({ block: 'nearest' });
1066
1305
  }
1067
1306
 
1068
- async function openProject(slug, { keepTab = false } = {}) {
1069
- if (slug !== state.slug) state.pruneSelection.clear();
1070
- state.slug = slug;
1307
+ /** Re-read the store list, e.g. after a path is remembered or forgotten. */
1308
+ async function reloadStores() {
1309
+ const data = await api('/api/stores');
1310
+ state.stores = data.stores;
1311
+ renderStores();
1312
+ return data;
1313
+ }
1314
+
1315
+ /** The name and provenance line above the tabs, which differs by store kind. */
1316
+ function renderStoreHeader() {
1317
+ const store = state.store;
1318
+ el('project-title').textContent = store.label;
1319
+
1320
+ const sub = el('project-sub');
1321
+ sub.textContent = '';
1322
+
1323
+ if (store.kind !== 'auto') {
1324
+ const scope = store.kind === 'agent-user' ? 'user'
1325
+ : store.kind === 'agent-project' ? 'project' : 'local';
1326
+ sub.append(node('span', { text: store.dir }));
1327
+ sub.append(node('span', { class: 'sub-note', text: `subagent memory, ${scope} scope` }));
1328
+ // A project-scoped store is committed, so deleting from it edits the repo
1329
+ // rather than a private cache. Worth saying before, not after.
1330
+ if (store.kind === 'agent-project') {
1331
+ sub.append(node('span', { class: 'sub-warn', text: 'checked into the repository - changes here show up in git status' }));
1332
+ }
1333
+ return;
1334
+ }
1335
+
1336
+ sub.append(node('span', { text: store.resolvedBy === 'unresolved'
1337
+ ? `${store.path} (real path unresolved)`
1338
+ : store.path }));
1339
+
1340
+ // One store serves every worktree and subdirectory of a repository, which is
1341
+ // not obvious from a single path and changes what a memory here applies to.
1342
+ const others = (store.workingDirs || []).filter((d) => d !== store.path);
1343
+ if (others.length) {
1344
+ sub.append(node('span', { class: 'sub-note', text: `also used from ${others.join(', ')}` }));
1345
+ }
1346
+ if (store.resolvedBy === 'unresolved') {
1347
+ sub.append(node('button', { class: 'btn small link-btn', text: 'Remember path\u2026', onclick: openRememberPathDialog }));
1348
+ } else if (store.resolvedBy === 'remembered') {
1349
+ sub.append(node('span', { class: 'sub-note' }, [
1350
+ document.createTextNode('path remembered, not recovered from a transcript '),
1351
+ node('button', { class: 'btn small link-btn', text: 'Forget', onclick: forgetProjectPath }),
1352
+ ]));
1353
+ }
1354
+
1355
+ const auto = store.autoMemory;
1356
+ if (auto && auto.known && !auto.enabled) {
1357
+ sub.append(node('span', { class: 'sub-warn', text: auto.scope === 'env'
1358
+ ? 'auto memory is off (CLAUDE_CODE_DISABLE_AUTO_MEMORY) - this store will not grow'
1359
+ : `auto memory is off (${auto.setBy}) - this store will not grow` }));
1360
+ }
1361
+ }
1362
+
1363
+ async function openStore(id, { keepTab = false } = {}) {
1364
+ if (id !== state.storeId) state.pruneSelection.clear();
1365
+ state.storeId = id;
1071
1366
  if (!keepTab) {
1072
1367
  state.tab = 'memories';
1073
1368
  state.selected = null;
1074
1369
  }
1075
- renderProjects();
1370
+ renderStores();
1076
1371
 
1077
1372
  try {
1078
- state.project = await api(`/api/projects/${encodeURIComponent(slug)}`);
1373
+ state.store = await api(`/api/stores/${encodeURIComponent(id)}`);
1079
1374
  } catch (err) {
1080
1375
  return toast(err.message, { error: true });
1081
1376
  }
1082
1377
 
1083
- if (state.selected && !state.project.memories.some((m) => m.file === state.selected)) {
1378
+ if (state.selected && !state.store.memories.some((m) => m.file === state.selected)) {
1084
1379
  state.selected = null;
1085
1380
  }
1086
1381
 
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;
1382
+ renderStoreHeader();
1091
1383
 
1092
1384
  // 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();
1385
+ const listed = state.stores.find((store) => store.id === id);
1386
+ if (listed) listed.memoryCount = state.store.memories.length;
1387
+ renderStores();
1096
1388
 
1097
1389
  renderView();
1098
1390
  renderTabs();
@@ -1110,20 +1402,20 @@ function setCollapsed(value) {
1110
1402
  applyCollapsed();
1111
1403
  // The graph sizes itself to the container, so it has to be redrawn once the
1112
1404
  // pane has actually changed width.
1113
- if (state.tab === 'graph' && state.project) requestAnimationFrame(renderTab);
1405
+ if (state.tab === 'graph' && state.store) requestAnimationFrame(renderTab);
1114
1406
  }
1115
1407
 
1116
1408
  async function init() {
1117
1409
  el('show-all').addEventListener('change', (event) => {
1118
1410
  state.showAll = event.target.checked;
1119
- renderProjects();
1411
+ renderStores();
1120
1412
  });
1121
1413
 
1122
1414
  el('search').addEventListener('input', (event) => scheduleSearch(event.target.value));
1123
1415
  el('search-clear').addEventListener('click', clearSearch);
1124
1416
  el('collapse').addEventListener('click', () => setCollapsed(true));
1125
1417
  el('expand').addEventListener('click', () => setCollapsed(false));
1126
- el('delete-project').addEventListener('click', openProjectDeleteDialog);
1418
+ el('delete-project').addEventListener('click', openStoreDeleteDialog);
1127
1419
  document.addEventListener('keydown', (event) => {
1128
1420
  const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName);
1129
1421
  if (event.key === '/' && !typing) {
@@ -1142,12 +1434,17 @@ async function init() {
1142
1434
  applyCollapsed();
1143
1435
 
1144
1436
  try {
1145
- const data = await api('/api/projects');
1146
- state.projects = data.projects;
1437
+ const data = await reloadStores();
1147
1438
  el('root-path').textContent = data.root;
1148
- renderProjects();
1149
- const first = state.projects.find((p) => p.memoryCount > 0);
1150
- if (first) openProject(first.slug);
1439
+ if (data.rootSource && data.rootSource !== 'default') {
1440
+ el('root-path').title = data.rootFile
1441
+ ? `autoMemoryDirectory set in ${data.rootFile}`
1442
+ : 'store chosen with --root';
1443
+ el('root-path').append(node('span', { class: 'root-source', text: data.rootSource }));
1444
+ }
1445
+ if (data.rootWarning) toast(data.rootWarning, { error: true });
1446
+ const first = state.stores.find((store) => store.memoryCount > 0);
1447
+ if (first) openStore(first.id);
1151
1448
  } catch (err) {
1152
1449
  toast(err.message, { error: true });
1153
1450
  }