claude-memory-admin 1.5.0 → 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.
@@ -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
+ }
@@ -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} in Health`);
17
+ if (store.context) parts.push(`${store.context} in Context`);
18
+ if (store.settings) parts.push(`${store.settings} in Settings`);
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,37 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { state } from '/state.mjs';
4
+ import { restoreFromTrash } from '/store.mjs';
5
+
6
+ const INDEX_EDIT_LABELS = {
7
+ hook: 'MEMORY.md hook shortened',
8
+ move: 'MEMORY.md entry moved',
9
+ add: 'MEMORY.md entry added',
10
+ };
11
+
12
+ export function renderTrash(container) {
13
+ const trash = state.store.trash;
14
+ if (!trash.length) {
15
+ container.append(node('p', { class: ui.note, text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
16
+ return;
17
+ }
18
+ for (const record of trash) {
19
+ const when = String(record.deletedAt).replace('T', ' ').replace(/\..*$/, '');
20
+ const detail = record.kind === 'wikilink'
21
+ ? `unlinked in ${record.sourceFile} · ${when}`
22
+ : record.kind === 'index-edit'
23
+ ? `${INDEX_EDIT_LABELS[record.op] || 'MEMORY.md edited'} · ${when}`
24
+ : record.kind === 'merge'
25
+ ? `merged into ${record.into} · ${when} · ${record.backups?.length || 0} file(s) rewritten`
26
+ : `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
27
+ container.append(node('div', { class: ui.issue(!record.present) }, [
28
+ node('div', { class: ui.issueBody }, [
29
+ node('div', { class: ui.issueTitle, text: record.label || record.id }),
30
+ node('div', { class: ui.issueDetail, text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
31
+ ]),
32
+ record.present
33
+ ? node('button', { class: ui.buttonPrimarySmall, text: 'Restore', onclick: () => restoreFromTrash(record.id) })
34
+ : null,
35
+ ]));
36
+ }
37
+ }
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 }),