claude-memory-admin 1.5.1 → 1.5.2
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 +17 -2
- package/package.json +1 -1
- package/public/api.mjs +26 -0
- package/public/app.mjs +54 -2268
- package/public/bus.mjs +7 -0
- package/public/dialogs/add-entry.mjs +69 -0
- package/public/dialogs/bulk-delete.mjs +58 -0
- package/public/dialogs/delete.mjs +114 -0
- package/public/dialogs/hook-editor.mjs +54 -0
- package/public/dialogs/merge.mjs +81 -0
- package/public/dialogs/move.mjs +43 -0
- package/public/dialogs/remember-path.mjs +65 -0
- package/public/dialogs/store-delete.mjs +64 -0
- package/public/parts.mjs +58 -0
- package/public/state.mjs +22 -0
- package/public/store.mjs +116 -0
- package/public/styles.css +1 -1
- package/public/ui.mjs +36 -0
- package/public/views/context.mjs +120 -0
- package/public/views/header.mjs +126 -0
- package/public/views/health.mjs +21 -0
- package/public/views/index.mjs +122 -0
- package/public/views/issue.mjs +270 -0
- package/public/views/memories.mjs +183 -0
- package/public/views/path-check.mjs +102 -0
- package/public/views/prune.mjs +191 -0
- package/public/views/search.mjs +101 -0
- package/public/views/sessions.mjs +235 -0
- package/public/views/settings.mjs +120 -0
- package/public/views/stores.mjs +65 -0
- package/public/views/trash.mjs +37 -0
- package/src/sessions.mjs +69 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import * as ui from '/ui.mjs';
|
|
2
|
+
import { node } from '/dom.mjs';
|
|
3
|
+
import { state } from '/state.mjs';
|
|
4
|
+
import { paint } from '/bus.mjs';
|
|
5
|
+
import { formatAge } from '/parts.mjs';
|
|
6
|
+
import { selectMemory } from '/store.mjs';
|
|
7
|
+
import { openBulkDeleteDialog } from '/dialogs/bulk-delete.mjs';
|
|
8
|
+
import { openMergeDialog } from '/dialogs/merge.mjs';
|
|
9
|
+
import { openMoveDialog } from '/dialogs/move.mjs';
|
|
10
|
+
import { openHookEditor } from '/dialogs/hook-editor.mjs';
|
|
11
|
+
|
|
12
|
+
const PRUNE_SORTS = {
|
|
13
|
+
oldest: { label: 'oldest first', compare: (a, b) => (b.ageDays ?? -1) - (a.ageDays ?? -1) },
|
|
14
|
+
largest: { label: 'largest first', compare: (a, b) => b.bytes - a.bytes },
|
|
15
|
+
unlinked: { label: 'least linked', compare: (a, b) => a.inbound.length - b.inbound.length || (b.ageDays ?? 0) - (a.ageDays ?? 0) },
|
|
16
|
+
name: { label: 'by name', compare: (a, b) => a.name.localeCompare(b.name) },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function renderBudget(container) {
|
|
20
|
+
const { index } = state.store.stats;
|
|
21
|
+
const percent = Math.min(100, index.worstPercent);
|
|
22
|
+
const level = index.level;
|
|
23
|
+
|
|
24
|
+
const note = index.level === 'over'
|
|
25
|
+
? 'Over the limit. Everything past the cutoff is dropped when the session loads it, so those entries are effectively invisible to Claude right now.'
|
|
26
|
+
: index.level === 'near'
|
|
27
|
+
? 'Approaching the limit. Once MEMORY.md passes it, everything after the cutoff stops being loaded at all.'
|
|
28
|
+
: 'Comfortably inside the limit. MEMORY.md is loaded at the start of every session.';
|
|
29
|
+
|
|
30
|
+
if (index.cutoff) {
|
|
31
|
+
const dropped = index.cutoff.droppedEntries;
|
|
32
|
+
const box = node('div', { class: ui.cutSummary });
|
|
33
|
+
box.append(node('div', { class: ui.cutSummaryHead, text: dropped.length
|
|
34
|
+
? `${dropped.length} ${dropped.length === 1 ? 'memory is' : 'memories are'} past the cutoff and not loaded`
|
|
35
|
+
: `${index.cutoff.droppedLines} lines are past the cutoff and not loaded` }));
|
|
36
|
+
box.append(node('p', { class: ui.note, text: `MEMORY.md stops loading at line ${index.cutoff.rawLine + 1}, bounded by ${index.cutoff.by}. Claude cannot see anything below it.` }));
|
|
37
|
+
for (const entry of dropped.slice(0, 20)) {
|
|
38
|
+
box.append(node('div', { class: ui.issue(true) }, [
|
|
39
|
+
node('div', { class: ui.issueBody }, [
|
|
40
|
+
node('div', { class: ui.issueTitle, text: entry.title || entry.file }),
|
|
41
|
+
node('div', { class: ui.issueDetail, text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
|
|
42
|
+
]),
|
|
43
|
+
node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(entry.file) }),
|
|
44
|
+
node('button', { class: ui.buttonPrimarySmall, text: 'Move up', onclick: () => openMoveDialog(entry) }),
|
|
45
|
+
]));
|
|
46
|
+
}
|
|
47
|
+
if (dropped.length > 20) {
|
|
48
|
+
box.append(node('p', { class: ui.note, text: `${dropped.length - 20} more not listed.` }));
|
|
49
|
+
}
|
|
50
|
+
container.append(box);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
container.append(node('div', { class: ui.meter }, [
|
|
54
|
+
node('div', { class: ui.meterTop }, [
|
|
55
|
+
node('span', { class: ui.meterValue, text: `${Math.round(index.worstPercent)}%` }),
|
|
56
|
+
node('span', { class: ui.meterUnit, text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
|
|
57
|
+
]),
|
|
58
|
+
node('div', { class: ui.meterBar }, [
|
|
59
|
+
node('div', { class: ui.meterFill(level), style: `width:${percent.toFixed(1)}%` }),
|
|
60
|
+
]),
|
|
61
|
+
node('p', { class: ui.meterNote, text: note }),
|
|
62
|
+
node('div', { class: ui.meterFacts }, [
|
|
63
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
|
|
64
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
|
|
65
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(index.entryCount) }), document.createTextNode(' index entries')]),
|
|
66
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
|
|
67
|
+
]),
|
|
68
|
+
node('p', { class: ui.meterNote, text: 'Claude Code loads the first 200 lines or 25KB of MEMORY.md, whichever comes first, and drops the rest. Topic files are only read when Claude follows a link, so detail belongs in them and MEMORY.md should stay one line per entry.' }),
|
|
69
|
+
]));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function renderPrune(container) {
|
|
73
|
+
const project = state.store;
|
|
74
|
+
renderBudget(container);
|
|
75
|
+
|
|
76
|
+
if (!project.memories.length) {
|
|
77
|
+
container.append(node('p', { class: ui.note, text: 'Nothing to prune - this project has no memories.' }));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const selected = state.pruneSelection;
|
|
82
|
+
const bar = node('div', { class: ui.pruneBar });
|
|
83
|
+
const sort = node('select', { class: ui.select });
|
|
84
|
+
for (const [key, config] of Object.entries(PRUNE_SORTS)) {
|
|
85
|
+
sort.append(node('option', { value: key, text: config.label, selected: key === state.pruneSort ? '' : null }));
|
|
86
|
+
}
|
|
87
|
+
sort.value = state.pruneSort;
|
|
88
|
+
sort.onchange = () => { state.pruneSort = sort.value; paint('tab'); };
|
|
89
|
+
|
|
90
|
+
const count = node('span', { class: ui.note });
|
|
91
|
+
const deleteButton = node('button', { class: ui.buttonDangerSmall });
|
|
92
|
+
const clearButton = node('button', { class: ui.buttonSmall, text: 'Clear selection' });
|
|
93
|
+
|
|
94
|
+
bar.append(node('span', { class: ui.sectionLabel, text: 'Prune' }), sort, node('span', { class: ui.pruneSpacer }), count, clearButton, deleteButton);
|
|
95
|
+
container.append(bar);
|
|
96
|
+
|
|
97
|
+
const list = node('div');
|
|
98
|
+
const ordered = [...project.memories].sort(PRUNE_SORTS[state.pruneSort].compare);
|
|
99
|
+
const rows = [];
|
|
100
|
+
for (const memory of ordered) {
|
|
101
|
+
const box = node('input', { type: 'checkbox', class: ui.checkbox });
|
|
102
|
+
box.checked = selected.has(memory.file);
|
|
103
|
+
const row = node('label', { class: ui.pruneRow(box.checked) }, [
|
|
104
|
+
box,
|
|
105
|
+
node('span', { class: ui.pruneMain }, [
|
|
106
|
+
node('span', { class: ui.pruneName, text: memory.name }),
|
|
107
|
+
node('span', { class: ui.pruneDesc, text: memory.description || memory.file }),
|
|
108
|
+
]),
|
|
109
|
+
node('span', { class: ui.pruneFacts }, [
|
|
110
|
+
node('span', { class: ui.pruneFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
|
|
111
|
+
node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
|
|
112
|
+
node('span', { class: ui.pruneFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
|
|
113
|
+
node('span', { class: ui.pruneFact(memory.status !== 'indexed'), text: memory.status }),
|
|
114
|
+
]),
|
|
115
|
+
]);
|
|
116
|
+
box.addEventListener('change', () => {
|
|
117
|
+
if (box.checked) selected.add(memory.file); else selected.delete(memory.file);
|
|
118
|
+
row.className = ui.pruneRow(box.checked);
|
|
119
|
+
updateBar();
|
|
120
|
+
});
|
|
121
|
+
rows.push({ box, memory });
|
|
122
|
+
list.append(row);
|
|
123
|
+
}
|
|
124
|
+
container.append(list);
|
|
125
|
+
|
|
126
|
+
function updateBar() {
|
|
127
|
+
count.textContent = selected.size ? `${selected.size} selected` : '';
|
|
128
|
+
deleteButton.textContent = `Delete ${selected.size || ''}`.trim();
|
|
129
|
+
deleteButton.disabled = selected.size === 0;
|
|
130
|
+
}
|
|
131
|
+
clearButton.onclick = () => {
|
|
132
|
+
selected.clear();
|
|
133
|
+
paint('tab');
|
|
134
|
+
};
|
|
135
|
+
deleteButton.onclick = () => openBulkDeleteDialog([...selected]);
|
|
136
|
+
updateBar();
|
|
137
|
+
|
|
138
|
+
const { longHooks } = project.stats.index;
|
|
139
|
+
if (longHooks.length) {
|
|
140
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Long index hooks · ${longHooks.length}` }));
|
|
141
|
+
container.append(node('p', { class: ui.noteTight, text: 'The hook is the text after the dash in MEMORY.md. It is loaded every session, so a 400-character hook costs more than the memory it points at. Shortening these is the cheapest win.' }));
|
|
142
|
+
for (const hook of longHooks.slice(0, 12)) {
|
|
143
|
+
const row = node('div', { class: ui.issue(false) });
|
|
144
|
+
const editButton = node('button', { class: ui.buttonPrimarySmall, text: 'Edit hook' });
|
|
145
|
+
row.append(
|
|
146
|
+
node('div', { class: ui.issueBody }, [
|
|
147
|
+
node('div', { class: ui.issueTitle, text: `${hook.hookLength} chars: ${hook.title}` }),
|
|
148
|
+
node('div', { class: ui.issueDetail, text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
|
|
149
|
+
]),
|
|
150
|
+
node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(hook.file) }),
|
|
151
|
+
editButton,
|
|
152
|
+
);
|
|
153
|
+
editButton.onclick = () => openHookEditor(row, hook.index);
|
|
154
|
+
container.append(row);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (project.duplicates.length) {
|
|
159
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Possible overlap · ${project.duplicates.length}` }));
|
|
160
|
+
container.append(node('p', { class: ui.noteTight, text: 'Ranked by how much rare vocabulary these pairs share. This is a hint, not a verdict - open both and decide whether one covers the other.' }));
|
|
161
|
+
for (const pair of project.duplicates) {
|
|
162
|
+
container.append(node('div', { class: ui.dupe }, [
|
|
163
|
+
node('div', { class: ui.dupeHead }, [
|
|
164
|
+
node('span', { class: ui.dupeScore, text: `${pair.score}%` }),
|
|
165
|
+
node('span', { text: `shared: ${pair.shared.join(', ')}` }),
|
|
166
|
+
]),
|
|
167
|
+
node('div', { class: ui.dupePair }, [
|
|
168
|
+
...[pair.a, pair.b].map((side) => node('div', {
|
|
169
|
+
class: ui.dupeSide,
|
|
170
|
+
onclick: () => selectMemory(side.file),
|
|
171
|
+
}, [
|
|
172
|
+
node('div', { class: ui.dupeSideName, text: side.name }),
|
|
173
|
+
node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
|
|
174
|
+
])),
|
|
175
|
+
]),
|
|
176
|
+
node('div', { class: ui.dupeActions }, [
|
|
177
|
+
node('button', {
|
|
178
|
+
class: ui.buttonSmall,
|
|
179
|
+
text: `Merge into ${pair.a.name}`,
|
|
180
|
+
onclick: () => openMergeDialog(pair.a.file, pair.b.file),
|
|
181
|
+
}),
|
|
182
|
+
node('button', {
|
|
183
|
+
class: ui.buttonSmall,
|
|
184
|
+
text: `Merge into ${pair.b.name}`,
|
|
185
|
+
onclick: () => openMergeDialog(pair.b.file, pair.a.file),
|
|
186
|
+
}),
|
|
187
|
+
]),
|
|
188
|
+
]));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as ui from '/ui.mjs';
|
|
2
|
+
import { el, node } from '/dom.mjs';
|
|
3
|
+
import { api, toast } from '/api.mjs';
|
|
4
|
+
import { state } from '/state.mjs';
|
|
5
|
+
import { paint } from '/bus.mjs';
|
|
6
|
+
import { highlight } from '/parts.mjs';
|
|
7
|
+
import { openStore } from '/store.mjs';
|
|
8
|
+
|
|
9
|
+
let searchTimer = null;
|
|
10
|
+
|
|
11
|
+
export function scheduleSearch(value) {
|
|
12
|
+
state.query = value;
|
|
13
|
+
clearTimeout(searchTimer);
|
|
14
|
+
if (!value.trim()) {
|
|
15
|
+
state.search = null;
|
|
16
|
+
paint('view');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
searchTimer = setTimeout(runSearch, 180);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function runSearch() {
|
|
23
|
+
const query = state.query.trim();
|
|
24
|
+
if (!query) return;
|
|
25
|
+
try {
|
|
26
|
+
state.search = await api(`/api/search?q=${encodeURIComponent(query)}`);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return toast(err.message, { error: true });
|
|
29
|
+
}
|
|
30
|
+
paint('view');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderSearch() {
|
|
34
|
+
const data = state.search;
|
|
35
|
+
const host = el('search-results');
|
|
36
|
+
host.textContent = '';
|
|
37
|
+
el('search-title').textContent = data.total
|
|
38
|
+
? `${data.total} result${data.total === 1 ? '' : 's'} for "${state.query.trim()}"`
|
|
39
|
+
: `No results for "${state.query.trim()}"`;
|
|
40
|
+
|
|
41
|
+
if (!data.total && !data.stores.length) {
|
|
42
|
+
host.append(node('p', { class: ui.note, text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const group of data.stores) {
|
|
47
|
+
const block = node('div', { class: ui.resultGroup });
|
|
48
|
+
block.append(node('div', { class: ui.sectionLabel, text: group.sublabel
|
|
49
|
+
? `${group.label} · ${group.sublabel} · ${group.results.length}`
|
|
50
|
+
: `${group.label} · ${group.results.length}` }));
|
|
51
|
+
|
|
52
|
+
for (const result of group.results) {
|
|
53
|
+
const button = node('button', {
|
|
54
|
+
class: ui.result,
|
|
55
|
+
onclick: () => openFromSearch(group.id, result.file),
|
|
56
|
+
});
|
|
57
|
+
const top = node('div', { class: ui.resultTop });
|
|
58
|
+
const name = node('span', { class: ui.resultName });
|
|
59
|
+
name.append(highlight(result.name, data.terms));
|
|
60
|
+
top.append(name, node('span', { class: ui.typeBadge(result.type), text: result.type }));
|
|
61
|
+
if (result.status !== 'indexed') top.append(node('span', { class: ui.badge('warn'), text: result.status }));
|
|
62
|
+
button.append(top);
|
|
63
|
+
|
|
64
|
+
if (result.snippet) {
|
|
65
|
+
const snippet = node('div', { class: ui.resultSnippet });
|
|
66
|
+
snippet.append(highlight(result.snippet.text, data.terms));
|
|
67
|
+
button.append(snippet);
|
|
68
|
+
}
|
|
69
|
+
button.append(node('div', { class: ui.resultWhere, text: `${result.file} · matched in ${result.fields.join(', ')}` }));
|
|
70
|
+
block.append(button);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const hit of group.indexHits) {
|
|
74
|
+
const button = node('button', {
|
|
75
|
+
class: ui.result,
|
|
76
|
+
onclick: () => openFromSearch(group.id, hit.file, 'index'),
|
|
77
|
+
});
|
|
78
|
+
button.append(node('div', { class: ui.resultWhere, text: `MEMORY.md line ${hit.index + 1}` }));
|
|
79
|
+
const line = node('div', { class: ui.resultSnippet });
|
|
80
|
+
line.append(highlight(hit.text.trim(), data.terms));
|
|
81
|
+
button.append(line);
|
|
82
|
+
block.append(button);
|
|
83
|
+
}
|
|
84
|
+
host.append(block);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function openFromSearch(id, file, tab = 'memories') {
|
|
89
|
+
clearSearch();
|
|
90
|
+
if (id !== state.storeId) await openStore(id);
|
|
91
|
+
state.tab = tab;
|
|
92
|
+
if (file) state.selected = file;
|
|
93
|
+
paint('tabs', 'tab');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function clearSearch() {
|
|
97
|
+
state.query = '';
|
|
98
|
+
state.search = null;
|
|
99
|
+
el('search').value = '';
|
|
100
|
+
paint('view');
|
|
101
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import * as ui from '/ui.mjs';
|
|
2
|
+
import { node } from '/dom.mjs';
|
|
3
|
+
import { api } from '/api.mjs';
|
|
4
|
+
import { state } from '/state.mjs';
|
|
5
|
+
import { sizeLabel, sessionDate } from '/parts.mjs';
|
|
6
|
+
import { selectMemory } from '/store.mjs';
|
|
7
|
+
import { paint } from '/bus.mjs';
|
|
8
|
+
|
|
9
|
+
const TITLE_SOURCE = {
|
|
10
|
+
'ai-title': 'title Claude Code generated for this session',
|
|
11
|
+
slug: 'session slug, no generated title found in the part read',
|
|
12
|
+
prompt: 'first prompt, no generated title found in the part read',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function sessionRow(session, memoriesFrom) {
|
|
16
|
+
const soon = session.expiresInDays !== null && session.expiresInDays <= 7;
|
|
17
|
+
const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
|
|
18
|
+
|
|
19
|
+
const tags = [];
|
|
20
|
+
if (session.gitBranch) tags.push(node('span', { class: ui.badge('neutral'), text: session.gitBranch }));
|
|
21
|
+
if (session.model) tags.push(node('span', { class: ui.badge('neutral'), text: session.model }));
|
|
22
|
+
if (session.expiresInDays !== null) {
|
|
23
|
+
tags.push(node('span', {
|
|
24
|
+
class: ui.badge(soon ? 'warn' : 'neutral'),
|
|
25
|
+
text: session.expiresInDays === 0 ? 'due to be swept' : `swept in ${session.expiresInDays}d`,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
for (const memory of memoriesFrom) {
|
|
29
|
+
tags.push(node('span', { class: ui.badge('project'), text: memory.name }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const body = node('div', { class: ui.contextBody, hidden: true });
|
|
33
|
+
const facts = node('dl', { class: ui.metaList });
|
|
34
|
+
const rows = [
|
|
35
|
+
['session', session.id],
|
|
36
|
+
['last active', `${sessionDate(session.modified)} (file mtime)`],
|
|
37
|
+
['size', sizeLabel(session.bytes)],
|
|
38
|
+
];
|
|
39
|
+
if (session.cwd) rows.push(['cwd', session.cwd]);
|
|
40
|
+
if (session.version) rows.push(['version', session.version]);
|
|
41
|
+
if (session.titleFrom) rows.push(['title from', TITLE_SOURCE[session.titleFrom] || session.titleFrom]);
|
|
42
|
+
for (const [key, value] of rows) {
|
|
43
|
+
facts.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: value }));
|
|
44
|
+
}
|
|
45
|
+
body.append(facts);
|
|
46
|
+
if (session.prompt) {
|
|
47
|
+
body.append(node('div', { class: ui.sectionLabel, text: 'Opened with' }));
|
|
48
|
+
body.append(node('p', { class: ui.note, text: session.prompt }));
|
|
49
|
+
}
|
|
50
|
+
if (memoriesFrom.length) {
|
|
51
|
+
body.append(node('div', { class: ui.sectionLabel, text: 'Memories written here' }));
|
|
52
|
+
const chips = node('div', { class: ui.linkRow });
|
|
53
|
+
for (const memory of memoriesFrom) {
|
|
54
|
+
chips.append(node('button', {
|
|
55
|
+
class: ui.chip(true),
|
|
56
|
+
text: memory.name,
|
|
57
|
+
onclick: () => selectMemory(memory.file),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
body.append(chips);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const button = node('button', {
|
|
64
|
+
class: ui.contextRowButton,
|
|
65
|
+
'aria-expanded': 'false',
|
|
66
|
+
onclick: (event) => {
|
|
67
|
+
const open = body.hidden;
|
|
68
|
+
body.hidden = !open;
|
|
69
|
+
caret.textContent = open ? '\u25be' : '\u25b8';
|
|
70
|
+
event.currentTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
71
|
+
},
|
|
72
|
+
}, [
|
|
73
|
+
node('div', { class: ui.contextMain }, [
|
|
74
|
+
node('div', { class: ui.contextTags }, tags),
|
|
75
|
+
node('div', { class: ui.contextFile }, [caret, document.createTextNode(session.title || session.id)]),
|
|
76
|
+
]),
|
|
77
|
+
node('div', { class: ui.contextSize, text: sessionDate(session.modified).slice(0, 10) }),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
return node('div', {}, [button, body]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function retentionTrack(sessions, days) {
|
|
84
|
+
const track = node('div', { class: ui.retentionTrack }, [node('span', { class: ui.retentionSweep })]);
|
|
85
|
+
for (const session of sessions) {
|
|
86
|
+
if (session.expiresInDays === null) continue;
|
|
87
|
+
const share = days > 0 ? Math.min(1, session.expiresInDays / days) : 1;
|
|
88
|
+
track.append(node('span', {
|
|
89
|
+
class: ui.retentionTick(session.expiresInDays <= 7),
|
|
90
|
+
style: `left: ${(share * 100).toFixed(2)}%`,
|
|
91
|
+
title: `${session.title || session.id}\nswept in ${session.expiresInDays} days`,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
return node('div', {}, [
|
|
95
|
+
track,
|
|
96
|
+
node('div', { class: ui.retentionScale }, [
|
|
97
|
+
node('span', { text: 'swept' }),
|
|
98
|
+
node('span', { text: `today · kept ${days} days` }),
|
|
99
|
+
]),
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const WEEKDAYS = ['Mon', '', 'Wed', '', 'Fri', '', ''];
|
|
104
|
+
const LEVELS = [0, 1, 2, 3, 4];
|
|
105
|
+
|
|
106
|
+
function dayLabel(cell) {
|
|
107
|
+
if (cell.count === 0) return `no sessions on ${cell.date}`;
|
|
108
|
+
return cell.count === 1 ? `1 session on ${cell.date}` : `${cell.count} sessions on ${cell.date}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function heatCell(cell, selected, onPick) {
|
|
112
|
+
if (cell.future) return node('span', { class: ui.heatBlank });
|
|
113
|
+
const label = dayLabel(cell);
|
|
114
|
+
const on = cell.date === selected;
|
|
115
|
+
return node('button', {
|
|
116
|
+
class: ui.heatTile(cell.level, on),
|
|
117
|
+
title: label,
|
|
118
|
+
'aria-label': label,
|
|
119
|
+
'aria-pressed': on ? 'true' : 'false',
|
|
120
|
+
onclick: () => onPick(on ? null : cell.date),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function activityGrid(heat, selected, onPick) {
|
|
125
|
+
const months = node('div', { class: ui.heatMonthRow });
|
|
126
|
+
for (const label of heat.months) {
|
|
127
|
+
months.append(node('div', { class: ui.heatMonthCell }, [
|
|
128
|
+
label ? node('span', { class: ui.heatMonthLabel, text: label }) : null,
|
|
129
|
+
]));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const grid = node('div', { class: ui.heatGrid });
|
|
133
|
+
for (const week of heat.weeks) {
|
|
134
|
+
for (const cell of week) grid.append(heatCell(cell, selected, onPick));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const weekdays = node('div', { class: ui.heatDayCol });
|
|
138
|
+
for (const label of WEEKDAYS) weekdays.append(node('span', { class: ui.heatDayLabel, text: label }));
|
|
139
|
+
|
|
140
|
+
const legend = node('div', { class: ui.heatLegend }, [node('span', { text: 'Less' })]);
|
|
141
|
+
for (const level of LEVELS) legend.append(node('span', { class: ui.heatLegendSwatch(level) }));
|
|
142
|
+
legend.append(node('span', { text: 'More' }));
|
|
143
|
+
|
|
144
|
+
return node('div', { class: ui.heatBody }, [
|
|
145
|
+
weekdays,
|
|
146
|
+
node('div', { class: ui.heatMain }, [
|
|
147
|
+
node('div', { class: ui.heatScroll }, [months, grid]),
|
|
148
|
+
legend,
|
|
149
|
+
]),
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function meterPanel(title, aside, body) {
|
|
154
|
+
return node('div', { class: ui.meterPanel }, [
|
|
155
|
+
node('div', { class: ui.meterPanelHead }, [
|
|
156
|
+
node('span', { class: ui.meterPanelTitle, text: title }),
|
|
157
|
+
aside,
|
|
158
|
+
]),
|
|
159
|
+
body,
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function renderSessions(container) {
|
|
164
|
+
let data = state.aux.sessions;
|
|
165
|
+
if (!data) {
|
|
166
|
+
container.append(node('p', { class: ui.note, text: 'Reading session transcripts\u2026' }));
|
|
167
|
+
try {
|
|
168
|
+
data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/sessions`);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
container.textContent = '';
|
|
171
|
+
return container.append(node('p', { class: ui.note, text: err.message }));
|
|
172
|
+
}
|
|
173
|
+
if (state.tab !== 'sessions') return;
|
|
174
|
+
state.aux.sessions = data;
|
|
175
|
+
}
|
|
176
|
+
container.textContent = '';
|
|
177
|
+
|
|
178
|
+
const byOrigin = new Map();
|
|
179
|
+
for (const memory of state.store.memories) {
|
|
180
|
+
if (!memory.origin?.present) continue;
|
|
181
|
+
if (!byOrigin.has(memory.origin.sessionId)) byOrigin.set(memory.origin.sessionId, []);
|
|
182
|
+
byOrigin.get(memory.origin.sessionId).push(memory);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const selected = state.aux.sessionDay;
|
|
186
|
+
const pickDay = (date) => {
|
|
187
|
+
state.aux.sessionDay = date;
|
|
188
|
+
paint('tab');
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
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.' }));
|
|
192
|
+
|
|
193
|
+
container.append(node('div', { class: ui.meter }, [
|
|
194
|
+
node('div', { class: ui.meterTop }, [
|
|
195
|
+
node('span', { class: ui.meterValue, text: String(data.count) }),
|
|
196
|
+
node('span', { class: ui.meterUnit, text: data.count === 1 ? 'session kept' : 'sessions kept' }),
|
|
197
|
+
]),
|
|
198
|
+
node('div', { class: ui.meterPanels }, [
|
|
199
|
+
meterPanel('Retention', null, retentionTrack(data.sessions, data.days)),
|
|
200
|
+
data.heat ? meterPanel('Activity', null, activityGrid(data.heat, selected, pickDay)) : null,
|
|
201
|
+
]),
|
|
202
|
+
node('div', { class: ui.meterFacts }, [
|
|
203
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: sizeLabel(data.bytes) }), document.createTextNode(' on disk')]),
|
|
204
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${data.days} days` }), document.createTextNode(' retention (cleanupPeriodDays)')]),
|
|
205
|
+
node('span', {}, [
|
|
206
|
+
node('b', { class: ui.meterFactValue, text: data.evidenceExpiresInDays === null ? '-' : `${data.evidenceExpiresInDays} days` }),
|
|
207
|
+
document.createTextNode(' until the last proof of this path goes'),
|
|
208
|
+
]),
|
|
209
|
+
]),
|
|
210
|
+
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.' }),
|
|
211
|
+
]));
|
|
212
|
+
|
|
213
|
+
const visible = selected
|
|
214
|
+
? data.sessions.filter((session) => session.modified.slice(0, 10) === selected)
|
|
215
|
+
: data.sessions;
|
|
216
|
+
const listHead = node('div', { class: ui.sectionLabelRow }, [
|
|
217
|
+
node('span', {
|
|
218
|
+
class: ui.sectionLabelInline,
|
|
219
|
+
text: selected ? `Sessions \u00b7 ${visible.length} on ${selected}` : `Sessions \u00b7 ${data.count}`,
|
|
220
|
+
}),
|
|
221
|
+
]);
|
|
222
|
+
if (selected) {
|
|
223
|
+
listHead.append(node('button', {
|
|
224
|
+
class: ui.heatFilterClear,
|
|
225
|
+
text: 'show every day',
|
|
226
|
+
onclick: () => pickDay(null),
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
container.append(listHead);
|
|
230
|
+
const card = node('div', { class: ui.card });
|
|
231
|
+
for (const session of visible) {
|
|
232
|
+
card.append(sessionRow(session, byOrigin.get(session.id) || []));
|
|
233
|
+
}
|
|
234
|
+
container.append(card);
|
|
235
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as ui from '/ui.mjs';
|
|
2
|
+
import { node } from '/dom.mjs';
|
|
3
|
+
import { api } from '/api.mjs';
|
|
4
|
+
import { state } from '/state.mjs';
|
|
5
|
+
|
|
6
|
+
const SETTINGS_PROBLEMS = {
|
|
7
|
+
unparseable: (p) => [
|
|
8
|
+
`Not valid JSON: ${p.scope} settings`,
|
|
9
|
+
`${p.file} — Claude Code cannot read this file either, so every value in it is being ignored. ${p.detail}`,
|
|
10
|
+
],
|
|
11
|
+
unreadable: (p) => [
|
|
12
|
+
`Cannot be read: ${p.scope} settings`,
|
|
13
|
+
`${p.file} — ${p.detail}`,
|
|
14
|
+
],
|
|
15
|
+
'not-object': (p) => [
|
|
16
|
+
`Not a settings object: ${p.scope} settings`,
|
|
17
|
+
`${p.file} — ${p.detail}`,
|
|
18
|
+
],
|
|
19
|
+
'invalid-auto-memory-directory': (p) => [
|
|
20
|
+
'autoMemoryDirectory cannot be used',
|
|
21
|
+
`${p.file} — ${p.detail}. Claude Code accepts only an absolute or ~/-prefixed path.`,
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const show = (value) => (value === undefined ? 'unset' : JSON.stringify(value));
|
|
26
|
+
|
|
27
|
+
export async function renderSettings(container) {
|
|
28
|
+
let data = state.aux.settings;
|
|
29
|
+
if (!data) {
|
|
30
|
+
container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
|
|
31
|
+
try {
|
|
32
|
+
data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
container.textContent = '';
|
|
35
|
+
return container.append(node('p', { class: ui.note, text: err.message }));
|
|
36
|
+
}
|
|
37
|
+
if (state.tab !== 'settings') return;
|
|
38
|
+
}
|
|
39
|
+
container.textContent = '';
|
|
40
|
+
|
|
41
|
+
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.' }));
|
|
42
|
+
|
|
43
|
+
if (!data.projectDir) {
|
|
44
|
+
container.append(node('p', { class: ui.subWarn, text: 'This store has no resolved project directory, so its project and local settings layers could not be consulted. Values below come from managed policy and your user file only.' }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (data.problems.length) {
|
|
48
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
|
|
49
|
+
for (const problem of data.problems) {
|
|
50
|
+
const describe = SETTINGS_PROBLEMS[problem.kind];
|
|
51
|
+
const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
|
|
52
|
+
container.append(node('div', { class: ui.issue(problem.severity === 'bad') }, [
|
|
53
|
+
node('div', { class: ui.issueBody }, [
|
|
54
|
+
node('div', { class: ui.issueTitle, text: title }),
|
|
55
|
+
node('div', { class: ui.issueDetail, text: detail }),
|
|
56
|
+
]),
|
|
57
|
+
]));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (data.env.overrides) {
|
|
62
|
+
container.append(node('div', { class: ui.sectionLabel, text: 'Environment override' }));
|
|
63
|
+
container.append(node('div', { class: ui.issue(false) }, [
|
|
64
|
+
node('div', { class: ui.issueBody }, [
|
|
65
|
+
node('div', { class: ui.issueTitle, text: `${data.env.name}=${data.env.value}` }),
|
|
66
|
+
node('div', { class: ui.issueDetail, text: `Set in this shell, so it outranks every settings file and forces ${data.env.overrides} off. The store will not grow while it is set.` }),
|
|
67
|
+
]),
|
|
68
|
+
]));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const entry of data.keys) {
|
|
72
|
+
const card = node('div', { class: ui.card });
|
|
73
|
+
const head = node('div', { class: ui.settingsKeyHead }, [
|
|
74
|
+
node('span', { class: ui.settingsKeyName, text: entry.key }),
|
|
75
|
+
node('span', {
|
|
76
|
+
class: ui.settingsEffective,
|
|
77
|
+
text: entry.effective ? show(entry.effective.value) : `${show(entry.fallback)} (default)`,
|
|
78
|
+
}),
|
|
79
|
+
]);
|
|
80
|
+
if (entry.effective) {
|
|
81
|
+
head.append(node('span', { class: ui.scopeBadge(entry.effective.scope), text: entry.effective.scope }));
|
|
82
|
+
}
|
|
83
|
+
card.append(head);
|
|
84
|
+
card.append(node('p', { class: ui.noteTight, text: entry.detail }));
|
|
85
|
+
|
|
86
|
+
if (entry.normalized !== undefined && entry.effective && entry.normalized !== entry.effective.value) {
|
|
87
|
+
card.append(node('div', { class: ui.issue(false) }, [
|
|
88
|
+
node('div', { class: ui.issueBody }, [
|
|
89
|
+
node('div', { class: ui.issueTitle, text: `Configured ${show(entry.effective.value)}, but ${show(entry.normalized)} applies` }),
|
|
90
|
+
node('div', { class: ui.issueDetail, text: 'Claude Code ignores a value it cannot use and falls back to the default.' }),
|
|
91
|
+
]),
|
|
92
|
+
]));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!entry.values.length) {
|
|
96
|
+
card.append(node('p', { class: ui.note, text: 'No settings file sets this, so the built-in default applies.' }));
|
|
97
|
+
} else {
|
|
98
|
+
for (const value of entry.values) {
|
|
99
|
+
card.append(node('div', { class: ui.settingsLayerRow }, [
|
|
100
|
+
node('span', { class: ui.scopeBadge(value.scope), text: value.scope }),
|
|
101
|
+
node('span', { class: ui.settingsLayerValue(value.wins), text: show(value.value) }),
|
|
102
|
+
node('span', { class: ui.settingsLayerFile, text: value.file }),
|
|
103
|
+
value.wins ? node('span', { class: ui.badge('ok'), text: 'wins' }) : null,
|
|
104
|
+
]));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
container.append(card);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Files consulted · ${data.layers.length}` }));
|
|
111
|
+
const files = node('div', { class: ui.card });
|
|
112
|
+
for (const layer of data.layers) {
|
|
113
|
+
files.append(node('div', { class: ui.settingsLayerRow }, [
|
|
114
|
+
node('span', { class: ui.scopeBadge(layer.scope), text: layer.scope }),
|
|
115
|
+
node('span', { class: ui.settingsLayerValue(layer.status === 'ok'), text: layer.status }),
|
|
116
|
+
node('span', { class: ui.settingsLayerFile, text: layer.file }),
|
|
117
|
+
]));
|
|
118
|
+
}
|
|
119
|
+
container.append(files);
|
|
120
|
+
}
|