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 +42 -2
- package/package.json +1 -1
- package/public/app.mjs +344 -45
- package/public/index.html +7 -4
- package/public/styles.css +1 -1
- package/public/ui.mjs +5 -2
- package/server.mjs +65 -3
- package/src/checks.mjs +187 -0
- package/src/instructions.mjs +37 -2
- package/src/model.mjs +6 -0
- package/src/pathcheck.mjs +108 -0
- package/src/settings.mjs +23 -1
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
|
|
101
|
-
|
|
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
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
|
|
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
|
|
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 === '
|
|
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(
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
646
|
+
} else {
|
|
647
|
+
for (const item of health.issues) container.append(renderIssue(item, memories));
|
|
516
648
|
}
|
|
517
649
|
|
|
518
|
-
|
|
519
|
-
|
|
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:
|
|
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: '
|
|
633
|
-
run: () =>
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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(
|
|
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)
|
|
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
|
-
<
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
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">☾</button>
|
|
35
|
+
</div>
|
|
33
36
|
</footer>
|
|
34
37
|
</aside>
|
|
35
38
|
|