claude-memory-admin 1.3.0 → 1.5.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
@@ -17,8 +17,14 @@ const state = {
17
17
  pruneSort: 'oldest',
18
18
  pruneSelection: new Set(),
19
19
  indexView: localStorage.getItem('memoryIndexView') === 'source' ? 'source' : 'rendered',
20
+ aux: { instructions: null, settings: null, sessions: null },
21
+ pathCheck: null,
20
22
  };
21
23
 
24
+ const RANK = { ok: 0, warn: 1, bad: 2 };
25
+ const worst = (...severities) => severities.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'ok');
26
+ const worstSeverity = (items) => (!items.length ? 'ok' : items.some((item) => item.severity === 'bad') ? 'bad' : 'warn');
27
+
22
28
  const el = (id) => document.getElementById(id);
23
29
 
24
30
  async function api(path, options) {
@@ -88,16 +94,25 @@ function storeSubtitle(store) {
88
94
  return `${scope} · ${store.sublabel}`;
89
95
  }
90
96
 
97
+ function issueTitle(store) {
98
+ const parts = [];
99
+ if (store.issueCount) parts.push(`${store.issueCount} in Health`);
100
+ if (store.context) parts.push(`${store.context} in Context`);
101
+ if (store.settings) parts.push(`${store.settings} in Settings`);
102
+ if (store.kind === 'auto' && store.resolvedBy === 'unresolved') {
103
+ parts.push('Real path could not be resolved - showing the raw folder name');
104
+ }
105
+ return parts.length ? `${store.dir}\n${parts.join(', ')}` : store.dir;
106
+ }
107
+
91
108
  function storeButton(store) {
92
109
  const global = store.kind === 'global';
93
- const health = global ? 'ok' : store.memoryCount === 0 ? 'none' : 'ok';
110
+ const health = !global && !store.hasMemoryDir ? 'none' : store.severity || 'ok';
94
111
  const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
95
112
  return node('button', {
96
113
  class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
97
114
  onclick: () => openStore(store.id),
98
- title: store.kind === 'auto' && store.resolvedBy === 'unresolved'
99
- ? 'Real path could not be resolved - showing the raw folder name'
100
- : store.dir,
115
+ title: issueTitle(store),
101
116
  }, [
102
117
  node('span', { class: ui.storeRow }, [
103
118
  node('span', { class: ui.dot(health) }),
@@ -138,6 +153,7 @@ const TABS = [
138
153
  { id: 'graph', label: 'Graph' },
139
154
  { id: 'prune', label: 'Prune' },
140
155
  { id: 'context', label: 'Context' },
156
+ { id: 'sessions', label: 'Sessions' },
141
157
  { id: 'health', label: 'Health' },
142
158
  { id: 'settings', label: 'Settings' },
143
159
  { id: 'trash', label: 'Trash' },
@@ -154,18 +170,38 @@ function renderTabs() {
154
170
  for (const tab of TABS) {
155
171
  if (global && tab.id !== 'context') continue;
156
172
  if (!global && tab.id === 'context' && !hasProjectDir) continue;
173
+ if (tab.id === 'sessions' && !state.store.sessions?.count) continue;
157
174
  let badge = null;
175
+ let tone = 'neutral';
158
176
  if (tab.id === 'memories') badge = String(state.store.memories.length);
159
- if (tab.id === 'health' && state.store.health.issueCount) badge = String(state.store.health.issueCount);
160
177
  if (tab.id === 'trash' && state.store.trash.length) badge = String(state.store.trash.length);
161
- if (tab.id === 'prune' && state.store.stats.index.overTarget) badge = '!';
178
+ if (tab.id === 'health' && state.store.health.issueCount) {
179
+ badge = String(state.store.health.issueCount);
180
+ tone = state.store.health.severity;
181
+ }
182
+ if (tab.id === 'prune' && state.store.stats.index.level !== 'ok') {
183
+ badge = '!';
184
+ tone = state.store.stats.index.level === 'over' ? 'bad' : 'warn';
185
+ }
186
+ if (tab.id === 'sessions' && state.store.sessions) {
187
+ badge = String(state.store.sessions.count);
188
+ if (state.store.sessions.expiringCount) tone = 'warn';
189
+ }
190
+ if (tab.id === 'context' && state.aux.instructions?.problems.length) {
191
+ badge = String(state.aux.instructions.problems.length);
192
+ tone = worstSeverity(state.aux.instructions.problems);
193
+ }
194
+ if (tab.id === 'settings' && state.aux.settings?.problems.length) {
195
+ badge = String(state.aux.settings.problems.length);
196
+ tone = worstSeverity(state.aux.settings.problems);
197
+ }
162
198
 
163
199
  container.append(node('button', {
164
200
  class: ui.tab(state.tab === tab.id),
165
201
  onclick: () => { state.tab = tab.id; renderTabs(); renderTab(); },
166
202
  }, [
167
203
  document.createTextNode(tab.label),
168
- badge ? node('span', { class: ui.tabBadge(tab.id === 'health' || tab.id === 'prune'), text: badge }) : null,
204
+ badge ? node('span', { class: ui.tabBadge(tone), text: badge }) : null,
169
205
  ]));
170
206
  }
171
207
  }
@@ -248,13 +284,16 @@ function renderDetail() {
248
284
  ];
249
285
  if (!memory.nameMatchesFile) rows.push(['name', `${memory.name} (differs from filename)`]);
250
286
  for (const [key, value] of Object.entries(memory.metadata)) {
251
- if (key !== 'type' && key !== 'modified') rows.push([key, value]);
287
+ if (key !== 'type' && key !== 'modified' && key !== 'originSessionId') rows.push([key, value]);
252
288
  }
253
289
  for (const [key, value] of rows) {
254
290
  meta.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: String(value) }));
255
291
  }
256
292
  card.append(meta);
257
293
 
294
+ const origin = provenanceLine(memory);
295
+ if (origin) card.append(origin);
296
+
258
297
  const body = node('div', { class: ui.prose });
259
298
  card.append(body);
260
299
  renderBody(body, memory, state.store);
@@ -395,18 +434,22 @@ const CONTEXT_PROBLEMS = {
395
434
  'long-claude-md': (p) => [`${p.lines} lines, over the 200-line guidance`, `${p.file} - long files cost context every session and reduce adherence`],
396
435
  '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.`],
397
436
  'unreferenced-user-file': (p) => ['Nothing in the chain reaches this file', `${p.file} sits next to CLAUDE.md and loads nothing. Import it with @${p.file.split('/').pop()}, or delete it.`],
437
+ 'duplicate-load': (p) => [`Loaded ${p.count} times: ${p.file}`, `reached from ${[...new Set(p.via)].join(' and ')} - every copy is read into context, so you pay ~${p.wastedTokens.toLocaleString()} extra tokens a session`],
438
+ 'empty-instruction-file': (p) => ['Loads, but says nothing', `${p.file} is empty apart from its frontmatter, so it costs a read and contributes no instruction`],
398
439
  };
399
440
 
400
441
  async function renderContext(container) {
401
- container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
402
- let data;
403
- try {
404
- data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
405
- } catch (err) {
406
- container.textContent = '';
407
- return container.append(node('p', { class: ui.note, text: err.message }));
442
+ let data = state.aux.instructions;
443
+ if (!data) {
444
+ container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
445
+ try {
446
+ data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
447
+ } catch (err) {
448
+ container.textContent = '';
449
+ return container.append(node('p', { class: ui.note, text: err.message }));
450
+ }
451
+ if (state.tab !== 'context') return;
408
452
  }
409
- if (state.tab !== 'context') return;
410
453
  container.textContent = '';
411
454
 
412
455
  const global = state.store.kind === 'global';
@@ -434,8 +477,7 @@ async function renderContext(container) {
434
477
  for (const problem of data.problems) {
435
478
  const describe = CONTEXT_PROBLEMS[problem.kind];
436
479
  const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
437
- const bad = problem.kind === 'missing' || problem.kind === 'invalid-glob' || problem.kind === 'glob-budget';
438
- container.append(node('div', { class: ui.issue(bad) }, [
480
+ container.append(node('div', { class: ui.issue(problem.severity === 'bad') }, [
439
481
  node('div', { class: ui.issueBody }, [
440
482
  node('div', { class: ui.issueTitle, text: title }),
441
483
  node('div', { class: ui.issueDetail, text: detail }),
@@ -506,18 +548,135 @@ function issue(title, detail, { bad = false, action, secondary } = {}) {
506
548
  ]);
507
549
  }
508
550
 
551
+ function storeHasProjectDir() {
552
+ return state.store.kind === 'auto'
553
+ ? state.store.resolvedBy !== 'unresolved'
554
+ : Boolean(state.store.projectPath);
555
+ }
556
+
557
+ const pathCheckKey = () => `pathCheck:${state.storeId}`;
558
+
559
+ async function fetchPathCheck() {
560
+ const id = state.storeId;
561
+ let next;
562
+ try {
563
+ next = { running: false, data: await api(`/api/stores/${encodeURIComponent(id)}/path-check`) };
564
+ } catch (err) {
565
+ next = { running: false, error: err.message };
566
+ }
567
+ if (state.storeId !== id) return;
568
+ state.pathCheck = next;
569
+ if (state.tab === 'health') renderTab();
570
+ }
571
+
572
+ function startPathCheck({ remember = true } = {}) {
573
+ if (remember) localStorage.setItem(pathCheckKey(), '1');
574
+ state.pathCheck = { running: true };
575
+ fetchPathCheck();
576
+ }
577
+
578
+ function runPathCheck() {
579
+ startPathCheck();
580
+ renderTab();
581
+ }
582
+
583
+ function stopPathCheck() {
584
+ localStorage.removeItem(pathCheckKey());
585
+ state.pathCheck = null;
586
+ renderTab();
587
+ }
588
+
589
+ function renderPathCheck(container) {
590
+ if (!storeHasProjectDir()) return;
591
+
592
+ if (!state.pathCheck && localStorage.getItem(pathCheckKey()) === '1') {
593
+ startPathCheck({ remember: false });
594
+ }
595
+ const check = state.pathCheck;
596
+
597
+ const head = node('div', { class: ui.issue(false) });
598
+ const body = node('div', { class: ui.issueBody });
599
+
600
+ if (!check) {
601
+ body.append(
602
+ node('div', { class: ui.issueTitle, text: 'Paths named in memories are not being checked' }),
603
+ node('div', { class: ui.issueDetail, text: 'Every other check on this tab reads only memory/. This one reads the project itself, to find memories naming files that no longer exist. It stays off until you ask, and is remembered per store.' }),
604
+ );
605
+ head.append(body, node('button', { class: ui.buttonSmall, text: 'Check against the repo', onclick: () => runPathCheck() }));
606
+ return container.append(head);
607
+ }
608
+
609
+ if (check.running) {
610
+ body.append(node('div', { class: ui.issueTitle, text: 'Checking paths against the repo…' }));
611
+ head.append(body);
612
+ return container.append(head);
613
+ }
614
+
615
+ if (check.error) {
616
+ body.append(
617
+ node('div', { class: ui.issueTitle, text: 'Path check could not run' }),
618
+ node('div', { class: ui.issueDetail, text: check.error }),
619
+ );
620
+ head.append(body, node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }));
621
+ return container.append(head);
622
+ }
623
+
624
+ const { checked, capped, missing, indexed, truncated } = check.data;
625
+ const scanned = `${indexed.toLocaleString()} files and folders scanned${truncated ? ', stopped at the index limit' : ''}`;
626
+ body.append(
627
+ node('div', { class: ui.issueTitle, text: missing.length
628
+ ? `${missing.length} of ${checked} paths named in memories match no file in the project`
629
+ : `All ${checked} paths named in memories still match a file` }),
630
+ node('div', { class: ui.issueDetail, text: capped
631
+ ? `${scanned}; stopped after the first ${checked} paths, so there may be more`
632
+ : scanned }),
633
+ );
634
+ if (missing.length) {
635
+ body.append(node('div', { class: ui.issueDetail, text: 'A pointer rather than a verdict: a file that moved reads the same as one the memory never got right.' }));
636
+ }
637
+ head.append(
638
+ body,
639
+ node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }),
640
+ node('button', { class: ui.buttonSmall, text: 'Re-check', onclick: () => runPathCheck() }),
641
+ );
642
+ container.append(head);
643
+
644
+ for (const item of missing) container.append(renderIssue(item, state.store.memories));
645
+ }
646
+
509
647
  function renderHealth(container) {
510
648
  const { health, memories } = state.store;
649
+
511
650
  if (!health.issues.length) {
512
651
  container.append(node('div', { class: ui.card }, [
513
652
  node('p', { class: ui.okLine, text: '\u2713 No consistency problems found.' }),
514
653
  node('p', { class: ui.noteTight, text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
515
654
  ]));
516
- return;
655
+ } else {
656
+ for (const item of health.issues) container.append(renderIssue(item, memories));
517
657
  }
518
658
 
519
- for (const item of health.issues) {
520
- container.append(renderIssue(item, memories));
659
+ container.append(node('div', { class: ui.sectionLabel, text: 'Beyond the memory directory' }));
660
+ renderPathCheck(container);
661
+ }
662
+
663
+ function goToTab(id) {
664
+ state.tab = id;
665
+ renderTabs();
666
+ renderTab();
667
+ }
668
+
669
+ async function removeIndexLine(lineIndex, expectedText, done) {
670
+ if (!confirm(`Remove line ${lineIndex + 1} from MEMORY.md?\n\n${expectedText}`)) return;
671
+ try {
672
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
673
+ method: 'POST',
674
+ body: JSON.stringify({ lineIndex, expectedText }),
675
+ });
676
+ toast(done);
677
+ await openStore(state.storeId, { keepTab: true });
678
+ } catch (err) {
679
+ toast(err.message, { error: true });
521
680
  }
522
681
  }
523
682
 
@@ -533,19 +692,7 @@ function renderIssue(item, memories) {
533
692
  bad,
534
693
  action: {
535
694
  label: 'Remove pointer',
536
- run: async () => {
537
- if (!confirm(`Remove line ${entry.index + 1} from MEMORY.md?\n\n${entry.text}`)) return;
538
- try {
539
- await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
540
- method: 'POST',
541
- body: JSON.stringify({ lineIndex: entry.index, expectedText: entry.text }),
542
- });
543
- toast('Pointer removed');
544
- await openStore(state.storeId, { keepTab: true });
545
- } catch (err) {
546
- toast(err.message, { error: true });
547
- }
548
- },
695
+ run: () => removeIndexLine(entry.index, entry.text, 'Pointer removed'),
549
696
  },
550
697
  },
551
698
  );
@@ -627,16 +774,130 @@ function renderIssue(item, memories) {
627
774
  return issue(
628
775
  `${item.count} index hook${item.count === 1 ? '' : 's'} over 200 characters`,
629
776
  `longest is ${item.longest.hookLength} chars on "${item.longest.title}" - hooks load every session, so these are the cheapest thing to trim.`,
777
+ {
778
+ bad,
779
+ action: { label: 'Open Prune', run: () => goToTab('prune') },
780
+ },
781
+ );
782
+ }
783
+
784
+ if (item.kind === 'duplicate-name') {
785
+ return issue(
786
+ `Two memories claim the name "${item.name}"`,
787
+ `${item.files.join(', ')} - every [[${item.name}]] resolves to ${item.reachable}, so the other one cannot be linked at all.`,
788
+ {
789
+ bad,
790
+ secondary: { label: 'Open first', run: () => selectMemory(item.files[0]) },
791
+ action: { label: 'Open second', run: () => selectMemory(item.files[1]) },
792
+ },
793
+ );
794
+ }
795
+
796
+ if (item.kind === 'duplicate-index-entry') {
797
+ return issue(
798
+ `Listed twice in MEMORY.md: ${item.file}`,
799
+ `lines ${item.lines.map((line) => line + 1).join(' and ')} - one memory, two index lines, both loaded every session.`,
630
800
  {
631
801
  bad,
632
802
  action: {
633
- label: 'Open Prune',
634
- run: () => { state.tab = 'prune'; renderTabs(); renderTab(); },
803
+ label: 'Remove duplicate',
804
+ run: () => removeIndexLine(item.removable.index, item.removable.text, 'Duplicate removed'),
635
805
  },
636
806
  },
637
807
  );
638
808
  }
639
809
 
810
+ if (item.kind === 'missing-description') {
811
+ return issue(
812
+ `No description: ${item.file}`,
813
+ 'The description is what a memory is recalled by, so this one is found on its name alone.',
814
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
815
+ );
816
+ }
817
+
818
+ if (item.kind === 'unknown-type') {
819
+ return issue(
820
+ `Unrecognised type: ${item.file}`,
821
+ `type: "${item.type}" - expected one of user, feedback, project, reference.`,
822
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
823
+ );
824
+ }
825
+
826
+ if (item.kind === 'empty-body') {
827
+ const memory = memories.find((m) => m.file === item.file);
828
+ return issue(
829
+ `Almost nothing in it: ${item.file}`,
830
+ `"${memory?.name || item.file}" has ${item.chars} characters of body - it costs an index line every session and says nothing.`,
831
+ {
832
+ bad,
833
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
834
+ action: { label: 'Delete', run: () => openDeleteDialog(item.file) },
835
+ },
836
+ );
837
+ }
838
+
839
+ if (item.kind === 'hook-repeats-description') {
840
+ return issue(
841
+ `Hook repeats the description: ${item.file}`,
842
+ `MEMORY.md line ${item.index + 1} - ${item.hookLength} characters restating what the file's own description already says. Only the hook loads every session.`,
843
+ {
844
+ bad,
845
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
846
+ action: { label: 'Open Prune', run: () => goToTab('prune') },
847
+ },
848
+ );
849
+ }
850
+
851
+ if (item.kind === 'empty-section') {
852
+ return issue(
853
+ `Empty section in MEMORY.md: "${item.section}"`,
854
+ `line ${item.index + 1} - the heading has no entries under it, and headings count against the 200-line budget too.`,
855
+ { bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
856
+ );
857
+ }
858
+
859
+ if (item.kind === 'stale-path') {
860
+ return issue(
861
+ `No file matches ${item.token}`,
862
+ `named by "${item.name || item.file}"`,
863
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
864
+ );
865
+ }
866
+
867
+ if (item.kind === 'index-continuation') {
868
+ return issue(
869
+ `${item.count} index ${item.count === 1 ? 'entry spills' : 'entries spill'} onto extra lines`,
870
+ `${item.extraLines} extra line${item.extraLines === 1 ? '' : 's'} of the 200-line budget - the guidance is one line per entry, with the detail in the topic file.`,
871
+ { bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
872
+ );
873
+ }
874
+
875
+ if (item.kind === 'provenance-expired') {
876
+ return issue(
877
+ `Provenance gone: ${item.name}`,
878
+ `the session it was written in (${item.sessionId}) has been swept, so why this memory exists can no longer be traced. Nothing recovers it - judge the memory on what it says.`,
879
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
880
+ );
881
+ }
882
+
883
+ if (item.kind === 'path-evidence-expiring') {
884
+ return issue(
885
+ item.days === 0
886
+ ? 'The last transcript naming this project is due to be swept'
887
+ : `The last transcript naming this project is swept in ${item.days} days`,
888
+ `${item.path} is recovered from the session transcripts, and Claude Code drops those after ${item.retentionDays} days while never touching memory/. Once the last one goes this store shows its raw folder name instead. Remember path records it now.`,
889
+ { bad, action: { label: 'Remember path\u2026', run: openRememberPathDialog } },
890
+ );
891
+ }
892
+
893
+ if (item.kind === 'no-memory-despite-sessions') {
894
+ return issue(
895
+ `${item.sessionCount} ${item.sessionCount === 1 ? 'session' : 'sessions'}, and no memory written`,
896
+ `auto memory is on for this project, but nothing has been saved in the ${item.retentionDays} days of transcripts kept here. Either nothing was worth remembering, or memory is not being written.`,
897
+ { bad },
898
+ );
899
+ }
900
+
640
901
  return issue(item.kind, JSON.stringify(item));
641
902
  }
642
903
 
@@ -661,16 +922,169 @@ const SETTINGS_PROBLEMS = {
661
922
 
662
923
  const show = (value) => (value === undefined ? 'unset' : JSON.stringify(value));
663
924
 
925
+ const TITLE_SOURCE = {
926
+ 'ai-title': 'title Claude Code generated for this session',
927
+ slug: 'session slug, no generated title found in the part read',
928
+ prompt: 'first prompt, no generated title found in the part read',
929
+ };
930
+
931
+ const sizeLabel = (bytes) => (bytes >= 1048576
932
+ ? `${(bytes / 1048576).toFixed(1)} MB`
933
+ : `${(bytes / 1024).toFixed(1)} KB`);
934
+
935
+ function sessionDate(iso) {
936
+ return iso ? iso.replace('T', ' ').replace(/\..*$/, '') : 'unknown';
937
+ }
938
+
939
+ function sessionRow(session, memoriesFrom) {
940
+ const soon = session.expiresInDays !== null && session.expiresInDays <= 7;
941
+ const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
942
+
943
+ const tags = [];
944
+ if (session.gitBranch) tags.push(node('span', { class: ui.badge('neutral'), text: session.gitBranch }));
945
+ if (session.model) tags.push(node('span', { class: ui.badge('neutral'), text: session.model }));
946
+ if (session.expiresInDays !== null) {
947
+ tags.push(node('span', {
948
+ class: ui.badge(soon ? 'warn' : 'neutral'),
949
+ text: session.expiresInDays === 0 ? 'due to be swept' : `swept in ${session.expiresInDays}d`,
950
+ }));
951
+ }
952
+ for (const memory of memoriesFrom) {
953
+ tags.push(node('span', { class: ui.badge('project'), text: memory.name }));
954
+ }
955
+
956
+ const body = node('div', { class: ui.contextBody, hidden: true });
957
+ const facts = node('dl', { class: ui.metaList });
958
+ const rows = [
959
+ ['session', session.id],
960
+ ['last active', `${sessionDate(session.modified)} (file mtime)`],
961
+ ['size', sizeLabel(session.bytes)],
962
+ ];
963
+ if (session.cwd) rows.push(['cwd', session.cwd]);
964
+ if (session.version) rows.push(['version', session.version]);
965
+ if (session.titleFrom) rows.push(['title from', TITLE_SOURCE[session.titleFrom] || session.titleFrom]);
966
+ for (const [key, value] of rows) {
967
+ facts.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: value }));
968
+ }
969
+ body.append(facts);
970
+ if (session.prompt) {
971
+ body.append(node('div', { class: ui.sectionLabel, text: 'Opened with' }));
972
+ body.append(node('p', { class: ui.note, text: session.prompt }));
973
+ }
974
+ if (memoriesFrom.length) {
975
+ body.append(node('div', { class: ui.sectionLabel, text: 'Memories written here' }));
976
+ const chips = node('div', { class: ui.linkRow });
977
+ for (const memory of memoriesFrom) {
978
+ chips.append(node('button', {
979
+ class: ui.chip(true),
980
+ text: memory.name,
981
+ onclick: () => selectMemory(memory.file),
982
+ }));
983
+ }
984
+ body.append(chips);
985
+ }
986
+
987
+ const button = node('button', {
988
+ class: ui.contextRowButton,
989
+ 'aria-expanded': 'false',
990
+ onclick: (event) => {
991
+ const open = body.hidden;
992
+ body.hidden = !open;
993
+ caret.textContent = open ? '\u25be' : '\u25b8';
994
+ event.currentTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
995
+ },
996
+ }, [
997
+ node('div', { class: ui.contextMain }, [
998
+ node('div', { class: ui.contextTags }, tags),
999
+ node('div', { class: ui.contextFile }, [caret, document.createTextNode(session.title || session.id)]),
1000
+ ]),
1001
+ node('div', { class: ui.contextSize, text: sessionDate(session.modified).slice(0, 10) }),
1002
+ ]);
1003
+
1004
+ return node('div', {}, [button, body]);
1005
+ }
1006
+
1007
+ function retentionTrack(sessions, days) {
1008
+ const track = node('div', { class: ui.retentionTrack }, [node('span', { class: ui.retentionSweep })]);
1009
+ for (const session of sessions) {
1010
+ if (session.expiresInDays === null) continue;
1011
+ const share = days > 0 ? Math.min(1, session.expiresInDays / days) : 1;
1012
+ track.append(node('span', {
1013
+ class: ui.retentionTick(session.expiresInDays <= 7),
1014
+ style: `left: ${(share * 100).toFixed(2)}%`,
1015
+ title: `${session.title || session.id}\nswept in ${session.expiresInDays} days`,
1016
+ }));
1017
+ }
1018
+ return node('div', {}, [
1019
+ track,
1020
+ node('div', { class: ui.retentionScale }, [
1021
+ node('span', { text: 'swept' }),
1022
+ node('span', { text: `today · kept ${days} days` }),
1023
+ ]),
1024
+ ]);
1025
+ }
1026
+
1027
+ async function renderSessions(container) {
1028
+ let data = state.aux.sessions;
1029
+ if (!data) {
1030
+ container.append(node('p', { class: ui.note, text: 'Reading session transcripts\u2026' }));
1031
+ try {
1032
+ data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/sessions`);
1033
+ } catch (err) {
1034
+ container.textContent = '';
1035
+ return container.append(node('p', { class: ui.note, text: err.message }));
1036
+ }
1037
+ if (state.tab !== 'sessions') return;
1038
+ state.aux.sessions = data;
1039
+ }
1040
+ container.textContent = '';
1041
+
1042
+ const byOrigin = new Map();
1043
+ for (const memory of state.store.memories) {
1044
+ if (!memory.origin?.present) continue;
1045
+ if (!byOrigin.has(memory.origin.sessionId)) byOrigin.set(memory.origin.sessionId, []);
1046
+ byOrigin.get(memory.origin.sessionId).push(memory);
1047
+ }
1048
+
1049
+ container.append(node('p', { class: ui.noteTight, text: 'The transcripts sitting beside this store\u2019s memory. Claude Code deletes them once they pass the retention period and never touches memory/, so this is the evidence that expires while the memories stay. This tab only reads, and only the head of each file - nothing here deletes a transcript.' }));
1050
+
1051
+ container.append(node('div', { class: ui.meter }, [
1052
+ node('div', { class: ui.meterTop }, [
1053
+ node('span', { class: ui.meterValue, text: String(data.count) }),
1054
+ node('span', { class: ui.meterUnit, text: data.count === 1 ? 'session kept' : 'sessions kept' }),
1055
+ ]),
1056
+ retentionTrack(data.sessions, data.days),
1057
+ node('div', { class: ui.meterFacts }, [
1058
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: sizeLabel(data.bytes) }), document.createTextNode(' on disk')]),
1059
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${data.days} days` }), document.createTextNode(' retention (cleanupPeriodDays)')]),
1060
+ node('span', {}, [
1061
+ node('b', { class: ui.meterFactValue, text: data.evidenceExpiresInDays === null ? '-' : `${data.evidenceExpiresInDays} days` }),
1062
+ document.createTextNode(' until the last proof of this path goes'),
1063
+ ]),
1064
+ ]),
1065
+ node('p', { class: ui.meterNote, text: 'Sweep dates come from each file\u2019s mtime, which any copy or restore resets, so they are an estimate of when Claude Code will drop them rather than something the transcript recorded about itself.' }),
1066
+ ]));
1067
+
1068
+ container.append(node('div', { class: ui.sectionLabel, text: `Sessions \u00b7 ${data.count}` }));
1069
+ const card = node('div', { class: ui.card });
1070
+ for (const session of data.sessions) {
1071
+ card.append(sessionRow(session, byOrigin.get(session.id) || []));
1072
+ }
1073
+ container.append(card);
1074
+ }
1075
+
664
1076
  async function renderSettings(container) {
665
- container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
666
- let data;
667
- try {
668
- data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
669
- } catch (err) {
670
- container.textContent = '';
671
- return container.append(node('p', { class: ui.note, text: err.message }));
1077
+ let data = state.aux.settings;
1078
+ if (!data) {
1079
+ container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
1080
+ try {
1081
+ data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
1082
+ } catch (err) {
1083
+ container.textContent = '';
1084
+ return container.append(node('p', { class: ui.note, text: err.message }));
1085
+ }
1086
+ if (state.tab !== 'settings') return;
672
1087
  }
673
- if (state.tab !== 'settings') return;
674
1088
  container.textContent = '';
675
1089
 
676
1090
  container.append(node('p', { class: ui.noteTight, text: 'What Claude Code would read for this store, in precedence order: managed policy first, then project and local settings, then your user file. This tab is read-only — it reports what is configured, it never changes it.' }));
@@ -684,7 +1098,7 @@ async function renderSettings(container) {
684
1098
  for (const problem of data.problems) {
685
1099
  const describe = SETTINGS_PROBLEMS[problem.kind];
686
1100
  const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
687
- container.append(node('div', { class: ui.issue(true) }, [
1101
+ container.append(node('div', { class: ui.issue(problem.severity === 'bad') }, [
688
1102
  node('div', { class: ui.issueBody }, [
689
1103
  node('div', { class: ui.issueTitle, text: title }),
690
1104
  node('div', { class: ui.issueDetail, text: detail }),
@@ -1707,6 +2121,7 @@ function renderTab() {
1707
2121
  if (state.tab === 'memories') renderMemories(container);
1708
2122
  else if (state.tab === 'prune') renderPrune(container);
1709
2123
  else if (state.tab === 'context') renderContext(container);
2124
+ else if (state.tab === 'sessions') renderSessions(container);
1710
2125
  else if (state.tab === 'index') renderIndex(container);
1711
2126
  else if (state.tab === 'health') renderHealth(container);
1712
2127
  else if (state.tab === 'settings') renderSettings(container);
@@ -1743,9 +2158,65 @@ async function reloadStores() {
1743
2158
  const data = await api('/api/stores');
1744
2159
  state.stores = data.stores;
1745
2160
  renderStores();
2161
+ sweepIssues();
1746
2162
  return data;
1747
2163
  }
1748
2164
 
2165
+ async function sweepIssues() {
2166
+ let data;
2167
+ try {
2168
+ data = await api('/api/stores/issues');
2169
+ } catch {
2170
+ return;
2171
+ }
2172
+ const bySummary = new Map(data.stores.map((summary) => [summary.id, summary]));
2173
+ for (const store of state.stores) Object.assign(store, bySummary.get(store.id) || {});
2174
+ renderStores();
2175
+ }
2176
+
2177
+ function provenanceLine(memory) {
2178
+ const origin = memory.origin;
2179
+ if (!origin) return null;
2180
+
2181
+ const known = state.aux.sessions?.sessions.find((session) => session.id === origin.sessionId);
2182
+ const label = origin.present ? (known?.title || origin.sessionId) : origin.sessionId;
2183
+
2184
+ const row = node('div', { class: ui.provenanceRow }, [node('span', { text: 'written in' })]);
2185
+
2186
+ if (origin.present) {
2187
+ row.append(node('button', {
2188
+ class: ui.provenanceLink(true),
2189
+ text: label,
2190
+ title: 'Open the Sessions tab',
2191
+ onclick: () => goToTab('sessions'),
2192
+ }));
2193
+ if (known?.gitBranch) row.append(node('span', { text: `on ${known.gitBranch}` }));
2194
+ if (origin.modified) row.append(node('span', { text: sessionDate(origin.modified).slice(0, 10) }));
2195
+ if (!known) loadSessionsForProvenance();
2196
+ } else {
2197
+ row.append(node('span', {
2198
+ class: ui.provenanceLink(false),
2199
+ text: label,
2200
+ title: 'This transcript is no longer on disk',
2201
+ }));
2202
+ row.append(node('span', { text: '- transcript swept, so why this memory exists can no longer be traced' }));
2203
+ }
2204
+ return row;
2205
+ }
2206
+
2207
+ async function loadSessionsForProvenance() {
2208
+ if (state.aux.sessions || !state.store?.sessions?.count) return;
2209
+ const id = state.storeId;
2210
+ try {
2211
+ const data = await api(`/api/stores/${encodeURIComponent(id)}/sessions`);
2212
+ if (state.storeId !== id) return;
2213
+ state.aux.sessions = data;
2214
+ renderTab();
2215
+ } catch {
2216
+ return;
2217
+ }
2218
+ }
2219
+
1749
2220
  function renderStoreHeader() {
1750
2221
  const store = state.store;
1751
2222
  el('project-title').textContent = store.label;
@@ -1788,6 +2259,18 @@ function renderStoreHeader() {
1788
2259
  ]));
1789
2260
  }
1790
2261
 
2262
+ const evidence = store.sessions?.evidenceExpiresInDays;
2263
+ if (!store.sessions?.remembered
2264
+ && (store.resolvedBy === 'transcript' || store.resolvedBy === 'repo-root')
2265
+ && typeof evidence === 'number' && evidence <= 14) {
2266
+ sub.append(node('span', { class: ui.subWarn }, [
2267
+ document.createTextNode(evidence === 0
2268
+ ? 'the last transcript proving this path is due to be swept - the store keeps its memories and loses its name '
2269
+ : `the last transcript proving this path is swept in ${evidence} days, and nothing else records it `),
2270
+ node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }),
2271
+ ]));
2272
+ }
2273
+
1791
2274
  const auto = store.autoMemory;
1792
2275
  if (auto && auto.known && !auto.enabled) {
1793
2276
  sub.append(node('span', { class: ui.subWarn, text: auto.scope === 'env'
@@ -1799,6 +2282,8 @@ function renderStoreHeader() {
1799
2282
  async function openStore(id, { keepTab = false } = {}) {
1800
2283
  if (id !== state.storeId) state.pruneSelection.clear();
1801
2284
  state.storeId = id;
2285
+ state.aux = { instructions: null, settings: null, sessions: null };
2286
+ state.pathCheck = null;
1802
2287
  if (!keepTab) {
1803
2288
  const opening = state.stores.find((store) => store.id === id);
1804
2289
  state.tab = opening && opening.kind === 'global' ? 'context' : 'memories';
@@ -1819,12 +2304,34 @@ async function openStore(id, { keepTab = false } = {}) {
1819
2304
  renderStoreHeader();
1820
2305
 
1821
2306
  const listed = state.stores.find((store) => store.id === id);
1822
- if (listed) listed.memoryCount = state.store.memories.length;
2307
+ if (listed) {
2308
+ listed.memoryCount = state.store.memories.length;
2309
+ listed.issueCount = state.store.health.issueCount;
2310
+ listed.severity = worst(state.store.health.severity, listed.context ? 'warn' : 'ok', listed.settings ? 'warn' : 'ok');
2311
+ }
1823
2312
  renderStores();
1824
2313
 
1825
2314
  renderView();
1826
2315
  renderTabs();
1827
2316
  renderTab();
2317
+
2318
+ Promise.all([
2319
+ api(`/api/stores/${encodeURIComponent(id)}/instructions`).catch(() => null),
2320
+ api(`/api/stores/${encodeURIComponent(id)}/settings`).catch(() => null),
2321
+ ]).then(([instructions, settings]) => {
2322
+ if (state.storeId !== id) return;
2323
+ state.aux = { instructions, settings };
2324
+ renderTabs();
2325
+ if (!listed) return;
2326
+ listed.context = instructions ? instructions.problems.length : 0;
2327
+ listed.settings = settings ? settings.problems.length : 0;
2328
+ listed.severity = worst(
2329
+ state.store.health.severity,
2330
+ instructions ? worstSeverity(instructions.problems) : 'ok',
2331
+ settings ? worstSeverity(settings.problems) : 'ok',
2332
+ );
2333
+ renderStores();
2334
+ });
1828
2335
  }
1829
2336
 
1830
2337
  function applyCollapsed() {