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/README.md +66 -3
- package/package.json +1 -1
- package/public/app.mjs +553 -46
- package/public/styles.css +1 -1
- package/public/ui.mjs +18 -2
- package/server.mjs +74 -3
- package/src/checks.mjs +245 -0
- package/src/instructions.mjs +37 -2
- package/src/model.mjs +45 -0
- package/src/pathcheck.mjs +108 -0
- package/src/projects.mjs +6 -2
- package/src/sessions.mjs +174 -0
- package/src/settings.mjs +23 -1
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
|
|
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
|
|
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 === '
|
|
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(
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
655
|
+
} else {
|
|
656
|
+
for (const item of health.issues) container.append(renderIssue(item, memories));
|
|
517
657
|
}
|
|
518
658
|
|
|
519
|
-
|
|
520
|
-
|
|
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:
|
|
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: '
|
|
634
|
-
run: () =>
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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(
|
|
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)
|
|
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() {
|