claude-memory-admin 1.2.0 → 1.4.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/README.md CHANGED
@@ -97,8 +97,17 @@ project is to the cliff, and makes it quick to get back under it.
97
97
  - **Graph** the wikilinks between memories. Hovering dims everything that is not
98
98
  a neighbour, which is the only practical way to read a dense cluster.
99
99
  - **Health**: orphans, dangling pointers, broken wikilinks, files linked only
100
- mid-sentence, `name` fields that disagree with the filename. An orphan can be
101
- given the `MEMORY.md` bullet it is missing without leaving the page.
100
+ mid-sentence, `name` fields that disagree with the filename, two files claiming
101
+ one `name`, a file bulleted twice, a blank `description`, a `type` outside the
102
+ four documented ones, a memory that is frontmatter and little else, a hook that
103
+ only restates the description it points at, a heading with nothing under it, and
104
+ entries that spill onto a second line. An orphan can be given the `MEMORY.md`
105
+ bullet it is missing without leaving the page.
106
+ - **Every count is visible before you click.** Each tab carries a badge, coloured
107
+ amber for something to tidy and red for something actively broken, and the
108
+ sidebar gives each store a dot of its worst severity - memory, instructions and
109
+ settings together. Which project is in trouble is the first thing the app tells
110
+ you, not something you find by opening eight tabs each.
102
111
  - **Dates you can trust.** Claude Code stamps `modified` into frontmatter, but
103
112
  only on files that already have some, and never adds frontmatter to a file
104
113
  without it. Anything falling back to the file's mtime is labelled, because
@@ -178,6 +187,11 @@ It also finds the failures that leave a file silently doing nothing:
178
187
  - A markdown file sitting in `~/.claude` that nothing in the chain reaches. It
179
188
  looks load-bearing and loads nothing, which is what happens when the import
180
189
  that pulled it in is deleted or its content is inlined.
190
+ - One file reached by two chains - your `~/.claude/CLAUDE.md` and the project's
191
+ own both importing it, say. Both copies load, and you pay for the content twice
192
+ every session.
193
+ - A `CLAUDE.md` or rule file that is empty apart from its frontmatter. It costs a
194
+ read and contributes no instruction.
181
195
 
182
196
  Backticks are respected, so a `` `@README` `` in your prose is not reported as an
183
197
  import, and neither is an email address.
@@ -200,6 +214,26 @@ This is re-derived from the documented resolution rules rather than reported by
200
214
  Claude Code, and the tab says so. Run `/context` in a session for the ground
201
215
  truth, or the `InstructionsLoaded` hook to log exactly what loaded and why.
202
216
 
217
+ ## The one check that reads your code
218
+
219
+ Every check above reads `memory/` and the `CLAUDE.md` chain, and nothing else.
220
+ One more is available and **off by default**, because it reads the project itself:
221
+ it takes the paths a memory names in a code span and asks whether anything in the
222
+ repository still matches them. A memory whose `Foo.cs` was renamed two months ago
223
+ still reads as authoritative, and nothing else on this page can tell.
224
+
225
+ Turn it on from the Health tab, per store; the choice is remembered in the browser
226
+ and nothing is written to disk. It walks the project once, skipping `.git`,
227
+ `node_modules`, build output and the like, and matches by suffix - a memory that
228
+ says `Infrastructure/Reporting/Foo.cs` for a file that really lives under
229
+ `services/argus/src` is right, and resolving that against the repository root
230
+ alone reported seven real paths in ten as missing. Only a token with a real source
231
+ extension is treated as a claim about a file, because memories are also full of
232
+ HTTP routes and type names that no file was ever going to match.
233
+
234
+ It is a pointer, not a verdict, and the tab says so: a file that moved reads the
235
+ same as one the memory never got right.
236
+
203
237
  ## Which settings are actually in force
204
238
 
205
239
  Five files can set the keys this tool cares about, and the one everybody edits,
@@ -274,6 +308,10 @@ claude-memory-admin --root /tmp/memory-snapshot
274
308
  ## Safety
275
309
 
276
310
  - Binds `127.0.0.1`; no telemetry, no network calls.
311
+ - Reads only `~/.claude/projects`, the agent memory directories and the `CLAUDE.md`
312
+ chain, unless you switch on the path check above, which then also walks that one
313
+ project's directory. It only ever reads: no path a memory names is opened, only
314
+ looked up in an index built from the project itself.
277
315
  - Every write target must resolve to a plain `.md` file inside that project's own
278
316
  `memory/` directory. `..`, absolute paths and subdirectories are refused.
279
317
  - One exception, and only when you ask for it: **Remember path** writes
@@ -320,6 +358,8 @@ ready to serve and never builds anything. After editing the source, run
320
358
  | `src/stores.mjs` | Store discovery: the user scope, auto memory and the three agent scopes |
321
359
  | `src/instructions.mjs` | CLAUDE.md chain, `@` imports and rules resolution |
322
360
  | `src/parse.mjs` | `MEMORY.md` and frontmatter parsers, wikilinks |
361
+ | `src/checks.mjs` | The consistency checks, as pure functions over parsed data |
362
+ | `src/pathcheck.mjs` | The opt-in check of paths a memory names, against the repo |
323
363
  | `src/model.mjs` | Joins index, files, graph and health into one model |
324
364
  | `src/stats.mjs` | Load-limit accounting and overlap detection |
325
365
  | `src/search.mjs` | Full-text search across every store |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-admin",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Browse, audit and prune the auto memory Claude Code keeps under ~/.claude/projects",
5
5
  "keywords": [
6
6
  "claude",
package/public/app.mjs CHANGED
@@ -10,14 +10,21 @@ const state = {
10
10
  selected: null,
11
11
  showAll: false,
12
12
  collapsed: localStorage.getItem('sidebarCollapsed') === '1',
13
+ theme: document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light',
13
14
  spread: Number(localStorage.getItem('graphSpread')) || 1.6,
14
15
  query: '',
15
16
  search: null,
16
17
  pruneSort: 'oldest',
17
18
  pruneSelection: new Set(),
18
19
  indexView: localStorage.getItem('memoryIndexView') === 'source' ? 'source' : 'rendered',
20
+ aux: { instructions: null, settings: null },
21
+ pathCheck: null,
19
22
  };
20
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
+
21
28
  const el = (id) => document.getElementById(id);
22
29
 
23
30
  async function api(path, options) {
@@ -87,16 +94,25 @@ function storeSubtitle(store) {
87
94
  return `${scope} · ${store.sublabel}`;
88
95
  }
89
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
+
90
108
  function storeButton(store) {
91
109
  const global = store.kind === 'global';
92
- const health = global ? 'ok' : store.memoryCount === 0 ? 'none' : 'ok';
110
+ const health = !global && !store.hasMemoryDir ? 'none' : store.severity || 'ok';
93
111
  const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
94
112
  return node('button', {
95
113
  class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
96
114
  onclick: () => openStore(store.id),
97
- title: store.kind === 'auto' && store.resolvedBy === 'unresolved'
98
- ? 'Real path could not be resolved - showing the raw folder name'
99
- : store.dir,
115
+ title: issueTitle(store),
100
116
  }, [
101
117
  node('span', { class: ui.storeRow }, [
102
118
  node('span', { class: ui.dot(health) }),
@@ -154,17 +170,32 @@ function renderTabs() {
154
170
  if (global && tab.id !== 'context') continue;
155
171
  if (!global && tab.id === 'context' && !hasProjectDir) continue;
156
172
  let badge = null;
173
+ let tone = 'neutral';
157
174
  if (tab.id === 'memories') badge = String(state.store.memories.length);
158
- if (tab.id === 'health' && state.store.health.issueCount) badge = String(state.store.health.issueCount);
159
175
  if (tab.id === 'trash' && state.store.trash.length) badge = String(state.store.trash.length);
160
- if (tab.id === 'prune' && state.store.stats.index.overTarget) badge = '!';
176
+ if (tab.id === 'health' && state.store.health.issueCount) {
177
+ badge = String(state.store.health.issueCount);
178
+ tone = state.store.health.severity;
179
+ }
180
+ if (tab.id === 'prune' && state.store.stats.index.level !== 'ok') {
181
+ badge = '!';
182
+ tone = state.store.stats.index.level === 'over' ? 'bad' : 'warn';
183
+ }
184
+ if (tab.id === 'context' && state.aux.instructions?.problems.length) {
185
+ badge = String(state.aux.instructions.problems.length);
186
+ tone = worstSeverity(state.aux.instructions.problems);
187
+ }
188
+ if (tab.id === 'settings' && state.aux.settings?.problems.length) {
189
+ badge = String(state.aux.settings.problems.length);
190
+ tone = worstSeverity(state.aux.settings.problems);
191
+ }
161
192
 
162
193
  container.append(node('button', {
163
194
  class: ui.tab(state.tab === tab.id),
164
195
  onclick: () => { state.tab = tab.id; renderTabs(); renderTab(); },
165
196
  }, [
166
197
  document.createTextNode(tab.label),
167
- badge ? node('span', { class: ui.tabBadge(tab.id === 'health' || tab.id === 'prune'), text: badge }) : null,
198
+ badge ? node('span', { class: ui.tabBadge(tone), text: badge }) : null,
168
199
  ]));
169
200
  }
170
201
  }
@@ -394,18 +425,22 @@ const CONTEXT_PROBLEMS = {
394
425
  'long-claude-md': (p) => [`${p.lines} lines, over the 200-line guidance`, `${p.file} - long files cost context every session and reduce adherence`],
395
426
  '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.`],
396
427
  '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.`],
428
+ '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`],
429
+ '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`],
397
430
  };
398
431
 
399
432
  async function renderContext(container) {
400
- container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
401
- let data;
402
- try {
403
- data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
404
- } catch (err) {
405
- container.textContent = '';
406
- return container.append(node('p', { class: ui.note, text: err.message }));
433
+ let data = state.aux.instructions;
434
+ if (!data) {
435
+ container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
436
+ try {
437
+ data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
438
+ } catch (err) {
439
+ container.textContent = '';
440
+ return container.append(node('p', { class: ui.note, text: err.message }));
441
+ }
442
+ if (state.tab !== 'context') return;
407
443
  }
408
- if (state.tab !== 'context') return;
409
444
  container.textContent = '';
410
445
 
411
446
  const global = state.store.kind === 'global';
@@ -433,8 +468,7 @@ async function renderContext(container) {
433
468
  for (const problem of data.problems) {
434
469
  const describe = CONTEXT_PROBLEMS[problem.kind];
435
470
  const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
436
- const bad = problem.kind === 'missing' || problem.kind === 'invalid-glob' || problem.kind === 'glob-budget';
437
- container.append(node('div', { class: ui.issue(bad) }, [
471
+ container.append(node('div', { class: ui.issue(problem.severity === 'bad') }, [
438
472
  node('div', { class: ui.issueBody }, [
439
473
  node('div', { class: ui.issueTitle, text: title }),
440
474
  node('div', { class: ui.issueDetail, text: detail }),
@@ -505,18 +539,135 @@ function issue(title, detail, { bad = false, action, secondary } = {}) {
505
539
  ]);
506
540
  }
507
541
 
542
+ function storeHasProjectDir() {
543
+ return state.store.kind === 'auto'
544
+ ? state.store.resolvedBy !== 'unresolved'
545
+ : Boolean(state.store.projectPath);
546
+ }
547
+
548
+ const pathCheckKey = () => `pathCheck:${state.storeId}`;
549
+
550
+ async function fetchPathCheck() {
551
+ const id = state.storeId;
552
+ let next;
553
+ try {
554
+ next = { running: false, data: await api(`/api/stores/${encodeURIComponent(id)}/path-check`) };
555
+ } catch (err) {
556
+ next = { running: false, error: err.message };
557
+ }
558
+ if (state.storeId !== id) return;
559
+ state.pathCheck = next;
560
+ if (state.tab === 'health') renderTab();
561
+ }
562
+
563
+ function startPathCheck({ remember = true } = {}) {
564
+ if (remember) localStorage.setItem(pathCheckKey(), '1');
565
+ state.pathCheck = { running: true };
566
+ fetchPathCheck();
567
+ }
568
+
569
+ function runPathCheck() {
570
+ startPathCheck();
571
+ renderTab();
572
+ }
573
+
574
+ function stopPathCheck() {
575
+ localStorage.removeItem(pathCheckKey());
576
+ state.pathCheck = null;
577
+ renderTab();
578
+ }
579
+
580
+ function renderPathCheck(container) {
581
+ if (!storeHasProjectDir()) return;
582
+
583
+ if (!state.pathCheck && localStorage.getItem(pathCheckKey()) === '1') {
584
+ startPathCheck({ remember: false });
585
+ }
586
+ const check = state.pathCheck;
587
+
588
+ const head = node('div', { class: ui.issue(false) });
589
+ const body = node('div', { class: ui.issueBody });
590
+
591
+ if (!check) {
592
+ body.append(
593
+ node('div', { class: ui.issueTitle, text: 'Paths named in memories are not being checked' }),
594
+ 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.' }),
595
+ );
596
+ head.append(body, node('button', { class: ui.buttonSmall, text: 'Check against the repo', onclick: () => runPathCheck() }));
597
+ return container.append(head);
598
+ }
599
+
600
+ if (check.running) {
601
+ body.append(node('div', { class: ui.issueTitle, text: 'Checking paths against the repo…' }));
602
+ head.append(body);
603
+ return container.append(head);
604
+ }
605
+
606
+ if (check.error) {
607
+ body.append(
608
+ node('div', { class: ui.issueTitle, text: 'Path check could not run' }),
609
+ node('div', { class: ui.issueDetail, text: check.error }),
610
+ );
611
+ head.append(body, node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }));
612
+ return container.append(head);
613
+ }
614
+
615
+ const { checked, capped, missing, indexed, truncated } = check.data;
616
+ const scanned = `${indexed.toLocaleString()} files and folders scanned${truncated ? ', stopped at the index limit' : ''}`;
617
+ body.append(
618
+ node('div', { class: ui.issueTitle, text: missing.length
619
+ ? `${missing.length} of ${checked} paths named in memories match no file in the project`
620
+ : `All ${checked} paths named in memories still match a file` }),
621
+ node('div', { class: ui.issueDetail, text: capped
622
+ ? `${scanned}; stopped after the first ${checked} paths, so there may be more`
623
+ : scanned }),
624
+ );
625
+ if (missing.length) {
626
+ 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.' }));
627
+ }
628
+ head.append(
629
+ body,
630
+ node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }),
631
+ node('button', { class: ui.buttonSmall, text: 'Re-check', onclick: () => runPathCheck() }),
632
+ );
633
+ container.append(head);
634
+
635
+ for (const item of missing) container.append(renderIssue(item, state.store.memories));
636
+ }
637
+
508
638
  function renderHealth(container) {
509
639
  const { health, memories } = state.store;
640
+
510
641
  if (!health.issues.length) {
511
642
  container.append(node('div', { class: ui.card }, [
512
643
  node('p', { class: ui.okLine, text: '\u2713 No consistency problems found.' }),
513
644
  node('p', { class: ui.noteTight, text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
514
645
  ]));
515
- return;
646
+ } else {
647
+ for (const item of health.issues) container.append(renderIssue(item, memories));
516
648
  }
517
649
 
518
- for (const item of health.issues) {
519
- container.append(renderIssue(item, memories));
650
+ container.append(node('div', { class: ui.sectionLabel, text: 'Beyond the memory directory' }));
651
+ renderPathCheck(container);
652
+ }
653
+
654
+ function goToTab(id) {
655
+ state.tab = id;
656
+ renderTabs();
657
+ renderTab();
658
+ }
659
+
660
+ async function removeIndexLine(lineIndex, expectedText, done) {
661
+ if (!confirm(`Remove line ${lineIndex + 1} from MEMORY.md?\n\n${expectedText}`)) return;
662
+ try {
663
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
664
+ method: 'POST',
665
+ body: JSON.stringify({ lineIndex, expectedText }),
666
+ });
667
+ toast(done);
668
+ await openStore(state.storeId, { keepTab: true });
669
+ } catch (err) {
670
+ toast(err.message, { error: true });
520
671
  }
521
672
  }
522
673
 
@@ -532,19 +683,7 @@ function renderIssue(item, memories) {
532
683
  bad,
533
684
  action: {
534
685
  label: 'Remove pointer',
535
- run: async () => {
536
- if (!confirm(`Remove line ${entry.index + 1} from MEMORY.md?\n\n${entry.text}`)) return;
537
- try {
538
- await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
539
- method: 'POST',
540
- body: JSON.stringify({ lineIndex: entry.index, expectedText: entry.text }),
541
- });
542
- toast('Pointer removed');
543
- await openStore(state.storeId, { keepTab: true });
544
- } catch (err) {
545
- toast(err.message, { error: true });
546
- }
547
- },
686
+ run: () => removeIndexLine(entry.index, entry.text, 'Pointer removed'),
548
687
  },
549
688
  },
550
689
  );
@@ -626,16 +765,104 @@ function renderIssue(item, memories) {
626
765
  return issue(
627
766
  `${item.count} index hook${item.count === 1 ? '' : 's'} over 200 characters`,
628
767
  `longest is ${item.longest.hookLength} chars on "${item.longest.title}" - hooks load every session, so these are the cheapest thing to trim.`,
768
+ {
769
+ bad,
770
+ action: { label: 'Open Prune', run: () => goToTab('prune') },
771
+ },
772
+ );
773
+ }
774
+
775
+ if (item.kind === 'duplicate-name') {
776
+ return issue(
777
+ `Two memories claim the name "${item.name}"`,
778
+ `${item.files.join(', ')} - every [[${item.name}]] resolves to ${item.reachable}, so the other one cannot be linked at all.`,
779
+ {
780
+ bad,
781
+ secondary: { label: 'Open first', run: () => selectMemory(item.files[0]) },
782
+ action: { label: 'Open second', run: () => selectMemory(item.files[1]) },
783
+ },
784
+ );
785
+ }
786
+
787
+ if (item.kind === 'duplicate-index-entry') {
788
+ return issue(
789
+ `Listed twice in MEMORY.md: ${item.file}`,
790
+ `lines ${item.lines.map((line) => line + 1).join(' and ')} - one memory, two index lines, both loaded every session.`,
629
791
  {
630
792
  bad,
631
793
  action: {
632
- label: 'Open Prune',
633
- run: () => { state.tab = 'prune'; renderTabs(); renderTab(); },
794
+ label: 'Remove duplicate',
795
+ run: () => removeIndexLine(item.removable.index, item.removable.text, 'Duplicate removed'),
634
796
  },
635
797
  },
636
798
  );
637
799
  }
638
800
 
801
+ if (item.kind === 'missing-description') {
802
+ return issue(
803
+ `No description: ${item.file}`,
804
+ 'The description is what a memory is recalled by, so this one is found on its name alone.',
805
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
806
+ );
807
+ }
808
+
809
+ if (item.kind === 'unknown-type') {
810
+ return issue(
811
+ `Unrecognised type: ${item.file}`,
812
+ `type: "${item.type}" - expected one of user, feedback, project, reference.`,
813
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
814
+ );
815
+ }
816
+
817
+ if (item.kind === 'empty-body') {
818
+ const memory = memories.find((m) => m.file === item.file);
819
+ return issue(
820
+ `Almost nothing in it: ${item.file}`,
821
+ `"${memory?.name || item.file}" has ${item.chars} characters of body - it costs an index line every session and says nothing.`,
822
+ {
823
+ bad,
824
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
825
+ action: { label: 'Delete', run: () => openDeleteDialog(item.file) },
826
+ },
827
+ );
828
+ }
829
+
830
+ if (item.kind === 'hook-repeats-description') {
831
+ return issue(
832
+ `Hook repeats the description: ${item.file}`,
833
+ `MEMORY.md line ${item.index + 1} - ${item.hookLength} characters restating what the file's own description already says. Only the hook loads every session.`,
834
+ {
835
+ bad,
836
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
837
+ action: { label: 'Open Prune', run: () => goToTab('prune') },
838
+ },
839
+ );
840
+ }
841
+
842
+ if (item.kind === 'empty-section') {
843
+ return issue(
844
+ `Empty section in MEMORY.md: "${item.section}"`,
845
+ `line ${item.index + 1} - the heading has no entries under it, and headings count against the 200-line budget too.`,
846
+ { bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
847
+ );
848
+ }
849
+
850
+ if (item.kind === 'stale-path') {
851
+ return issue(
852
+ `No file matches ${item.token}`,
853
+ `named by "${item.name || item.file}"`,
854
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
855
+ );
856
+ }
857
+
858
+ if (item.kind === 'index-continuation') {
859
+ return issue(
860
+ `${item.count} index ${item.count === 1 ? 'entry spills' : 'entries spill'} onto extra lines`,
861
+ `${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.`,
862
+ { bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
863
+ );
864
+ }
865
+
639
866
  return issue(item.kind, JSON.stringify(item));
640
867
  }
641
868
 
@@ -661,15 +888,17 @@ const SETTINGS_PROBLEMS = {
661
888
  const show = (value) => (value === undefined ? 'unset' : JSON.stringify(value));
662
889
 
663
890
  async function renderSettings(container) {
664
- container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
665
- let data;
666
- try {
667
- data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
668
- } catch (err) {
669
- container.textContent = '';
670
- return container.append(node('p', { class: ui.note, text: err.message }));
891
+ let data = state.aux.settings;
892
+ if (!data) {
893
+ container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
894
+ try {
895
+ data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
896
+ } catch (err) {
897
+ container.textContent = '';
898
+ return container.append(node('p', { class: ui.note, text: err.message }));
899
+ }
900
+ if (state.tab !== 'settings') return;
671
901
  }
672
- if (state.tab !== 'settings') return;
673
902
  container.textContent = '';
674
903
 
675
904
  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.' }));
@@ -683,7 +912,7 @@ async function renderSettings(container) {
683
912
  for (const problem of data.problems) {
684
913
  const describe = SETTINGS_PROBLEMS[problem.kind];
685
914
  const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
686
- container.append(node('div', { class: ui.issue(true) }, [
915
+ container.append(node('div', { class: ui.issue(problem.severity === 'bad') }, [
687
916
  node('div', { class: ui.issueBody }, [
688
917
  node('div', { class: ui.issueTitle, text: title }),
689
918
  node('div', { class: ui.issueDetail, text: detail }),
@@ -1742,9 +1971,22 @@ async function reloadStores() {
1742
1971
  const data = await api('/api/stores');
1743
1972
  state.stores = data.stores;
1744
1973
  renderStores();
1974
+ sweepIssues();
1745
1975
  return data;
1746
1976
  }
1747
1977
 
1978
+ async function sweepIssues() {
1979
+ let data;
1980
+ try {
1981
+ data = await api('/api/stores/issues');
1982
+ } catch {
1983
+ return;
1984
+ }
1985
+ const bySummary = new Map(data.stores.map((summary) => [summary.id, summary]));
1986
+ for (const store of state.stores) Object.assign(store, bySummary.get(store.id) || {});
1987
+ renderStores();
1988
+ }
1989
+
1748
1990
  function renderStoreHeader() {
1749
1991
  const store = state.store;
1750
1992
  el('project-title').textContent = store.label;
@@ -1798,6 +2040,8 @@ function renderStoreHeader() {
1798
2040
  async function openStore(id, { keepTab = false } = {}) {
1799
2041
  if (id !== state.storeId) state.pruneSelection.clear();
1800
2042
  state.storeId = id;
2043
+ state.aux = { instructions: null, settings: null };
2044
+ state.pathCheck = null;
1801
2045
  if (!keepTab) {
1802
2046
  const opening = state.stores.find((store) => store.id === id);
1803
2047
  state.tab = opening && opening.kind === 'global' ? 'context' : 'memories';
@@ -1818,12 +2062,34 @@ async function openStore(id, { keepTab = false } = {}) {
1818
2062
  renderStoreHeader();
1819
2063
 
1820
2064
  const listed = state.stores.find((store) => store.id === id);
1821
- if (listed) listed.memoryCount = state.store.memories.length;
2065
+ if (listed) {
2066
+ listed.memoryCount = state.store.memories.length;
2067
+ listed.issueCount = state.store.health.issueCount;
2068
+ listed.severity = worst(state.store.health.severity, listed.context ? 'warn' : 'ok', listed.settings ? 'warn' : 'ok');
2069
+ }
1822
2070
  renderStores();
1823
2071
 
1824
2072
  renderView();
1825
2073
  renderTabs();
1826
2074
  renderTab();
2075
+
2076
+ Promise.all([
2077
+ api(`/api/stores/${encodeURIComponent(id)}/instructions`).catch(() => null),
2078
+ api(`/api/stores/${encodeURIComponent(id)}/settings`).catch(() => null),
2079
+ ]).then(([instructions, settings]) => {
2080
+ if (state.storeId !== id) return;
2081
+ state.aux = { instructions, settings };
2082
+ renderTabs();
2083
+ if (!listed) return;
2084
+ listed.context = instructions ? instructions.problems.length : 0;
2085
+ listed.settings = settings ? settings.problems.length : 0;
2086
+ listed.severity = worst(
2087
+ state.store.health.severity,
2088
+ instructions ? worstSeverity(instructions.problems) : 'ok',
2089
+ settings ? worstSeverity(settings.problems) : 'ok',
2090
+ );
2091
+ renderStores();
2092
+ });
1827
2093
  }
1828
2094
 
1829
2095
  function applyCollapsed() {
@@ -1833,6 +2099,26 @@ function applyCollapsed() {
1833
2099
  localStorage.setItem('sidebarCollapsed', state.collapsed ? '1' : '0');
1834
2100
  }
1835
2101
 
2102
+ function paintTheme() {
2103
+ document.documentElement.dataset.theme = state.theme;
2104
+ const dark = state.theme === 'dark';
2105
+ const button = el('theme-toggle');
2106
+ button.textContent = dark ? '\u2600' : '\u263E';
2107
+ button.title = dark ? 'Switch to the light theme ( t )' : 'Switch to the dark theme ( t )';
2108
+ button.setAttribute('aria-label', dark ? 'Switch to the light theme' : 'Switch to the dark theme');
2109
+ button.setAttribute('aria-pressed', String(dark));
2110
+ }
2111
+
2112
+ function setTheme(value) {
2113
+ state.theme = value;
2114
+ localStorage.setItem('theme', value);
2115
+ paintTheme();
2116
+ }
2117
+
2118
+ function toggleTheme() {
2119
+ setTheme(state.theme === 'dark' ? 'light' : 'dark');
2120
+ }
2121
+
1836
2122
  function setCollapsed(value) {
1837
2123
  state.collapsed = value;
1838
2124
  applyCollapsed();
@@ -1857,6 +2143,13 @@ async function init() {
1857
2143
  el('collapse').addEventListener('click', () => setCollapsed(true));
1858
2144
  el('expand').addEventListener('click', () => setCollapsed(false));
1859
2145
  el('delete-project').addEventListener('click', openStoreDeleteDialog);
2146
+ el('theme-toggle').addEventListener('click', toggleTheme);
2147
+ const scheme = matchMedia('(prefers-color-scheme: dark)');
2148
+ scheme.addEventListener('change', (event) => {
2149
+ if (localStorage.getItem('theme')) return;
2150
+ state.theme = event.matches ? 'dark' : 'light';
2151
+ paintTheme();
2152
+ });
1860
2153
  document.addEventListener('keydown', (event) => {
1861
2154
  const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName);
1862
2155
  if (event.key === '/' && !typing) {
@@ -1866,6 +2159,11 @@ async function init() {
1866
2159
  el('search').select();
1867
2160
  return;
1868
2161
  }
2162
+ if (event.key === 't' && !typing) {
2163
+ event.preventDefault();
2164
+ toggleTheme();
2165
+ return;
2166
+ }
1869
2167
  if (event.key === 'Escape') {
1870
2168
  if (el('modal-root').firstChild) closeModal();
1871
2169
  else if (state.search !== null) clearSearch();
@@ -1873,6 +2171,7 @@ async function init() {
1873
2171
  }
1874
2172
  });
1875
2173
  applyCollapsed();
2174
+ paintTheme();
1876
2175
 
1877
2176
  try {
1878
2177
  const data = await reloadStores();
package/public/index.html CHANGED
@@ -26,10 +26,13 @@
26
26
  </header>
27
27
  <nav id="project-list" data-ui="sidebarList" aria-label="Projects"></nav>
28
28
  <footer data-ui="sidebarFoot">
29
- <a href="https://bitspire.ch/" target="_blank" rel="noopener noreferrer" data-ui="sidebarCredit">
30
- <span>Brought to you by</span>
31
- <img src="/assets/bitspire-logo.webp" alt="BitSpire" width="96" data-ui="sidebarLogo">
32
- </a>
29
+ <div data-ui="sidebarFootRow">
30
+ <a href="https://bitspire.ch/" target="_blank" rel="noopener noreferrer" data-ui="sidebarCredit">
31
+ <span>Brought to you by</span>
32
+ <img src="/assets/bitspire-logo.webp" alt="BitSpire" width="96" data-ui="sidebarLogo">
33
+ </a>
34
+ <button id="theme-toggle" data-ui="themeToggle" title="Switch to the dark theme ( t )" aria-label="Switch to the dark theme" aria-pressed="false">&#9790;</button>
35
+ </div>
33
36
  </footer>
34
37
  </aside>
35
38