claude-memory-admin 1.5.1 → 1.6.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.
Files changed (39) hide show
  1. package/README.md +79 -124
  2. package/package.json +1 -1
  3. package/public/api.mjs +26 -0
  4. package/public/app.mjs +35 -2270
  5. package/public/bus.mjs +7 -0
  6. package/public/dialog.mjs +7 -0
  7. package/public/dialogs/add-entry.mjs +69 -0
  8. package/public/dialogs/bulk-delete.mjs +59 -0
  9. package/public/dialogs/delete.mjs +114 -0
  10. package/public/dialogs/hook-editor.mjs +54 -0
  11. package/public/dialogs/merge.mjs +81 -0
  12. package/public/dialogs/move.mjs +43 -0
  13. package/public/dialogs/remember-path.mjs +65 -0
  14. package/public/dialogs/store-delete.mjs +64 -0
  15. package/public/dialogs/trash.mjs +53 -0
  16. package/public/index.html +1 -0
  17. package/public/parts.mjs +65 -0
  18. package/public/state.mjs +48 -0
  19. package/public/store.mjs +121 -0
  20. package/public/styles.css +1 -1
  21. package/public/ui.mjs +44 -16
  22. package/public/views/cleanup.mjs +63 -0
  23. package/public/views/context.mjs +121 -0
  24. package/public/views/cost-issue.mjs +70 -0
  25. package/public/views/environment.mjs +12 -0
  26. package/public/views/header.mjs +183 -0
  27. package/public/views/index.mjs +122 -0
  28. package/public/views/issue.mjs +262 -0
  29. package/public/views/memories.mjs +156 -0
  30. package/public/views/memory-list.mjs +121 -0
  31. package/public/views/memory.mjs +32 -0
  32. package/public/views/path-check.mjs +103 -0
  33. package/public/views/search.mjs +100 -0
  34. package/public/views/segments.mjs +37 -0
  35. package/public/views/sessions.mjs +235 -0
  36. package/public/views/settings.mjs +121 -0
  37. package/public/views/stores.mjs +65 -0
  38. package/public/views/worklist.mjs +33 -0
  39. package/src/sessions.mjs +69 -1
@@ -0,0 +1,100 @@
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, goTo } 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, 'list'),
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, segment = 'list') {
89
+ clearSearch();
90
+ if (id !== state.storeId) await openStore(id);
91
+ if (file) state.selected = file;
92
+ goTo('memory', segment);
93
+ }
94
+
95
+ export function clearSearch() {
96
+ state.query = '';
97
+ state.search = null;
98
+ el('search').value = '';
99
+ paint('view');
100
+ }
@@ -0,0 +1,37 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { state } from '/state.mjs';
4
+ import { goTo } from '/parts.mjs';
5
+ import { segmentsFor } from '/views/header.mjs';
6
+
7
+ export function segmentBar(tab) {
8
+ const segments = segmentsFor(tab);
9
+ if (segments.length < 2) return null;
10
+ return node('div', { class: ui.segmentBar }, [
11
+ node('div', { class: ui.segmentGroup, role: 'tablist' }, segments.map((seg) => node('button', {
12
+ class: ui.segment(state.segment[tab] === seg.id),
13
+ role: 'tab',
14
+ 'aria-selected': String(state.segment[tab] === seg.id),
15
+ onclick: () => goTo(tab, seg.id),
16
+ }, [
17
+ document.createTextNode(seg.label),
18
+ seg.badge ? node('span', { class: ui.tabBadge(seg.tone) }, [document.createTextNode(seg.badge)]) : null,
19
+ ]))),
20
+ ]);
21
+ }
22
+
23
+ export function activeSegment(tab) {
24
+ const segments = segmentsFor(tab);
25
+ if (!segments.length) return null;
26
+ const current = state.segment[tab];
27
+ return segments.some((seg) => seg.id === current) ? current : segments[0].id;
28
+ }
29
+
30
+ export function renderSegmented(container, tab, views) {
31
+ const bar = segmentBar(tab);
32
+ if (bar) container.append(bar);
33
+ const host = node('div');
34
+ container.append(host);
35
+ const segment = activeSegment(tab);
36
+ return views[segment] ? views[segment](host) : null;
37
+ }
@@ -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, isAt } 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 (!isAt('environment', '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,121 @@
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 { isAt } from '/parts.mjs';
6
+
7
+ const SETTINGS_PROBLEMS = {
8
+ unparseable: (p) => [
9
+ `Not valid JSON: ${p.scope} settings`,
10
+ `${p.file} — Claude Code cannot read this file either, so every value in it is being ignored. ${p.detail}`,
11
+ ],
12
+ unreadable: (p) => [
13
+ `Cannot be read: ${p.scope} settings`,
14
+ `${p.file} — ${p.detail}`,
15
+ ],
16
+ 'not-object': (p) => [
17
+ `Not a settings object: ${p.scope} settings`,
18
+ `${p.file} — ${p.detail}`,
19
+ ],
20
+ 'invalid-auto-memory-directory': (p) => [
21
+ 'autoMemoryDirectory cannot be used',
22
+ `${p.file} — ${p.detail}. Claude Code accepts only an absolute or ~/-prefixed path.`,
23
+ ],
24
+ };
25
+
26
+ const show = (value) => (value === undefined ? 'unset' : JSON.stringify(value));
27
+
28
+ export async function renderSettings(container) {
29
+ let data = state.aux.settings;
30
+ if (!data) {
31
+ container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
32
+ try {
33
+ data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
34
+ } catch (err) {
35
+ container.textContent = '';
36
+ return container.append(node('p', { class: ui.note, text: err.message }));
37
+ }
38
+ if (!isAt('environment', 'settings')) return;
39
+ }
40
+ container.textContent = '';
41
+
42
+ 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.' }));
43
+
44
+ if (!data.projectDir) {
45
+ 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.' }));
46
+ }
47
+
48
+ if (data.problems.length) {
49
+ container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
50
+ for (const problem of data.problems) {
51
+ const describe = SETTINGS_PROBLEMS[problem.kind];
52
+ const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
53
+ container.append(node('div', { class: ui.issue(problem.severity === 'bad') }, [
54
+ node('div', { class: ui.issueBody }, [
55
+ node('div', { class: ui.issueTitle, text: title }),
56
+ node('div', { class: ui.issueDetail, text: detail }),
57
+ ]),
58
+ ]));
59
+ }
60
+ }
61
+
62
+ if (data.env.overrides) {
63
+ container.append(node('div', { class: ui.sectionLabel, text: 'Environment override' }));
64
+ container.append(node('div', { class: ui.issue(false) }, [
65
+ node('div', { class: ui.issueBody }, [
66
+ node('div', { class: ui.issueTitle, text: `${data.env.name}=${data.env.value}` }),
67
+ 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.` }),
68
+ ]),
69
+ ]));
70
+ }
71
+
72
+ for (const entry of data.keys) {
73
+ const card = node('div', { class: ui.card });
74
+ const head = node('div', { class: ui.settingsKeyHead }, [
75
+ node('span', { class: ui.settingsKeyName, text: entry.key }),
76
+ node('span', {
77
+ class: ui.settingsEffective,
78
+ text: entry.effective ? show(entry.effective.value) : `${show(entry.fallback)} (default)`,
79
+ }),
80
+ ]);
81
+ if (entry.effective) {
82
+ head.append(node('span', { class: ui.scopeBadge(entry.effective.scope), text: entry.effective.scope }));
83
+ }
84
+ card.append(head);
85
+ card.append(node('p', { class: ui.noteTight, text: entry.detail }));
86
+
87
+ if (entry.normalized !== undefined && entry.effective && entry.normalized !== entry.effective.value) {
88
+ card.append(node('div', { class: ui.issue(false) }, [
89
+ node('div', { class: ui.issueBody }, [
90
+ node('div', { class: ui.issueTitle, text: `Configured ${show(entry.effective.value)}, but ${show(entry.normalized)} applies` }),
91
+ node('div', { class: ui.issueDetail, text: 'Claude Code ignores a value it cannot use and falls back to the default.' }),
92
+ ]),
93
+ ]));
94
+ }
95
+
96
+ if (!entry.values.length) {
97
+ card.append(node('p', { class: ui.note, text: 'No settings file sets this, so the built-in default applies.' }));
98
+ } else {
99
+ for (const value of entry.values) {
100
+ card.append(node('div', { class: ui.settingsLayerRow }, [
101
+ node('span', { class: ui.scopeBadge(value.scope), text: value.scope }),
102
+ node('span', { class: ui.settingsLayerValue(value.wins), text: show(value.value) }),
103
+ node('span', { class: ui.settingsLayerFile, text: value.file }),
104
+ value.wins ? node('span', { class: ui.badge('ok'), text: 'wins' }) : null,
105
+ ]));
106
+ }
107
+ }
108
+ container.append(card);
109
+ }
110
+
111
+ container.append(node('div', { class: ui.sectionLabel, text: `Files consulted · ${data.layers.length}` }));
112
+ const files = node('div', { class: ui.card });
113
+ for (const layer of data.layers) {
114
+ files.append(node('div', { class: ui.settingsLayerRow }, [
115
+ node('span', { class: ui.scopeBadge(layer.scope), text: layer.scope }),
116
+ node('span', { class: ui.settingsLayerValue(layer.status === 'ok'), text: layer.status }),
117
+ node('span', { class: ui.settingsLayerFile, text: layer.file }),
118
+ ]));
119
+ }
120
+ container.append(files);
121
+ }
@@ -0,0 +1,65 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { el, node } from '/dom.mjs';
3
+ import { state } from '/state.mjs';
4
+ import { openStore } from '/store.mjs';
5
+
6
+ function storeSubtitle(store) {
7
+ if (store.kind === 'global') return store.dir;
8
+ if (store.kind === 'auto') return store.path;
9
+ const scope = store.kind === 'agent-user' ? 'user'
10
+ : store.kind === 'agent-project' ? 'project' : 'local';
11
+ return `${scope} · ${store.sublabel}`;
12
+ }
13
+
14
+ function issueTitle(store) {
15
+ const parts = [];
16
+ if (store.issueCount) parts.push(`${store.issueCount} to fix in Cleanup`);
17
+ const environment = (store.context || 0) + (store.settings || 0);
18
+ if (environment) parts.push(`${environment} in Environment`);
19
+ if (store.kind === 'auto' && store.resolvedBy === 'unresolved') {
20
+ parts.push('Real path could not be resolved - showing the raw folder name');
21
+ }
22
+ return parts.length ? `${store.dir}\n${parts.join(', ')}` : store.dir;
23
+ }
24
+
25
+ function storeButton(store) {
26
+ const global = store.kind === 'global';
27
+ const health = !global && !store.hasMemoryDir ? 'none' : store.severity || 'ok';
28
+ const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
29
+ return node('button', {
30
+ class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
31
+ onclick: () => openStore(store.id),
32
+ title: issueTitle(store),
33
+ }, [
34
+ node('span', { class: ui.storeRow }, [
35
+ node('span', { class: ui.dot(health) }),
36
+ node('span', { class: ui.storeName, text: store.label }),
37
+ off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
38
+ global ? null : node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
39
+ ]),
40
+ node('span', { class: ui.storePath, text: storeSubtitle(store) }),
41
+ ]);
42
+ }
43
+
44
+ export function renderStores() {
45
+ const list = el('project-list');
46
+ list.textContent = '';
47
+
48
+ const visible = state.stores.filter((s) => s.kind === 'global' || state.showAll || s.hasMemoryDir);
49
+ if (!visible.length) {
50
+ list.append(node('p', { class: ui.note, text: 'No memory stores found.' }));
51
+ return;
52
+ }
53
+
54
+ const groups = [
55
+ ['Global', visible.filter((s) => s.kind === 'global')],
56
+ ['Projects', visible.filter((s) => s.kind === 'auto')],
57
+ ['Subagents', visible.filter((s) => s.kind !== 'auto' && s.kind !== 'global')],
58
+ ];
59
+
60
+ const filled = groups.filter(([, stores]) => stores.length);
61
+ for (const [title, stores] of filled) {
62
+ if (filled.length > 1) list.append(node('div', { class: ui.sidebarGroup, text: title }));
63
+ for (const store of stores) list.append(storeButton(store));
64
+ }
65
+ }
@@ -0,0 +1,33 @@
1
+ const COST_KINDS = new Set(['long-hook', 'hook-repeats-description', 'empty-section', 'index-continuation']);
2
+
3
+ function rankOf(kind, severity) {
4
+ if (kind === 'past-cutoff') return 0;
5
+ if (severity === 'bad') return 1;
6
+ if (COST_KINDS.has(kind)) return 2;
7
+ if (kind === 'overlap') return 4;
8
+ return 3;
9
+ }
10
+
11
+ export function buildWorklist(store) {
12
+ if (!store || !store.stats) return [];
13
+ const items = [];
14
+ const { index } = store.stats;
15
+
16
+ for (const entry of index.cutoff?.droppedEntries ?? []) {
17
+ items.push({ kind: 'past-cutoff', severity: 'bad', entry });
18
+ }
19
+ for (const hook of index.longHooks) {
20
+ items.push({ kind: 'long-hook', severity: 'warn', hook });
21
+ }
22
+ for (const item of store.health.issues) {
23
+ if (item.kind === 'long-hooks') continue;
24
+ items.push(item);
25
+ }
26
+ for (const pair of store.duplicates) {
27
+ items.push({ kind: 'overlap', severity: 'warn', pair });
28
+ }
29
+
30
+ return items
31
+ .map((item) => ({ ...item, rank: rankOf(item.kind, item.severity) }))
32
+ .sort((a, b) => a.rank - b.rank);
33
+ }
package/src/sessions.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { HEAD_READS, readHead } from './projects.mjs';
4
- import { cleanupPeriodDays } from './settings.mjs';
4
+ import { cleanupPeriodDays, DEFAULT_CLEANUP_PERIOD_DAYS } from './settings.mjs';
5
5
  import { ageInDays } from './stats.mjs';
6
6
 
7
7
  export const EXPIRY_WARNING_DAYS = 7;
@@ -145,6 +145,73 @@ export function retention(sessions, { projectDir = null, now = Date.now() } = {}
145
145
  };
146
146
  }
147
147
 
148
+ export const HEAT_WEEKS_MAX = 53;
149
+ export const HEAT_LEVELS = 4;
150
+
151
+ const DAY_MS = 86400000;
152
+ const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
153
+
154
+ const dayKey = (ms) => new Date(ms).toISOString().slice(0, 10);
155
+ const startOfDay = (ms) => Math.floor(ms / DAY_MS) * DAY_MS;
156
+ const mondayOf = (ms) => ms - ((new Date(ms).getUTCDay() + 6) % 7) * DAY_MS;
157
+ const sundayOf = (ms) => ms + ((7 - new Date(ms).getUTCDay()) % 7) * DAY_MS;
158
+
159
+ export function heatLevel(count, max) {
160
+ if (!count) return 0;
161
+ if (max <= HEAT_LEVELS) return count;
162
+ return Math.min(HEAT_LEVELS, Math.ceil((count / max) * HEAT_LEVELS));
163
+ }
164
+
165
+ export function heatmap(sessions, { days = DEFAULT_CLEANUP_PERIOD_DAYS, now = Date.now() } = {}) {
166
+ const today = startOfDay(now);
167
+ const counts = new Map();
168
+ for (const session of sessions) {
169
+ if (typeof session.modified !== 'string') continue;
170
+ const date = session.modified.slice(0, 10);
171
+ counts.set(date, (counts.get(date) || 0) + 1);
172
+ }
173
+
174
+ const span = Math.max(1, Math.round(days));
175
+ let earliest = today - (span - 1) * DAY_MS;
176
+ for (const date of counts.keys()) {
177
+ const at = Date.parse(`${date}T00:00:00.000Z`);
178
+ if (!Number.isNaN(at) && at < earliest) earliest = at;
179
+ }
180
+
181
+ const end = sundayOf(today);
182
+ let start = mondayOf(earliest);
183
+ if (Math.round((end + DAY_MS - start) / (7 * DAY_MS)) > HEAT_WEEKS_MAX) {
184
+ start = end - (HEAT_WEEKS_MAX * 7 - 1) * DAY_MS;
185
+ }
186
+
187
+ const max = counts.size ? Math.max(...counts.values()) : 0;
188
+ const weeks = [];
189
+ const months = [];
190
+ let labelled = null;
191
+ for (let monday = start; monday <= end; monday += 7 * DAY_MS) {
192
+ const week = [];
193
+ for (let offset = 0; offset < 7; offset += 1) {
194
+ const ms = monday + offset * DAY_MS;
195
+ const date = dayKey(ms);
196
+ const count = counts.get(date) || 0;
197
+ week.push({ date, count, level: heatLevel(count, max), future: ms > today });
198
+ }
199
+ const month = new Date(monday).getUTCMonth();
200
+ months.push(month === labelled ? null : MONTH_LABELS[month]);
201
+ labelled = month;
202
+ weeks.push(week);
203
+ }
204
+
205
+ return {
206
+ weeks,
207
+ months,
208
+ max,
209
+ total: [...counts.values()].reduce((sum, value) => sum + value, 0),
210
+ start: dayKey(start),
211
+ end: dayKey(end),
212
+ };
213
+ }
214
+
148
215
  export function originSession(slugDir, sessionId) {
149
216
  if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) return null;
150
217
  const file = path.join(slugDir, `${sessionId}.jsonl`);
@@ -166,6 +233,7 @@ export function sessionsWithSummaries(slugDir, { projectDir = null, now = Date.n
166
233
  const report = retention(listSessions(slugDir), { projectDir, now });
167
234
  return {
168
235
  ...report,
236
+ heat: heatmap(report.sessions, { days: report.days, now }),
169
237
  sessions: report.sessions.map((session) => ({
170
238
  ...session,
171
239
  ...sessionSummary(session.file, { bytes: session.bytes }),