Skip to content

Commit 383b0d4

Browse files
ndemiancclaude
andcommitted
feat(levelcode): auto-archive stale sessions at 30 days + a Pin toggle
The "keep the magic" pair from the sessions design (§4.9): the working set fades on its own, and you protect what matters. Auto-archive quietly retires sessions you haven't touched in 30 days; Pin exempts a session from that (and floats it above the time buckets, past the 30-item cap). - sessions.js: autoArchiveStale({ days, nowMs }) archives every active, non-pinned, non-live session older than the cutoff — append-only (a label event each), NEVER deletes, pinned exempt, idempotent. Exported alongside the existing setPinned. - extension.js: handleSessionAction gains 'pin' (toggles setPinned + refreshes both surfaces); activate runs the sweep once on startup (deferred, guarded, off when the setting is 0) and refreshes the list if anything faded. - media/chat.html, media/sessionsView.html: a Pin/Unpin button in the card's hover actions (byte-identical in the shared SESSIONS-PURE block — drift test green); the star already marked pinned at rest. - package.json: sessions.autoArchiveDays (default 30, 0 = off). - tests: +1 sessions (sweep: stale faded, pinned/recent exempt, idempotent), +1 sessionsUi (Pin toggle on the card + housekeeping wiring), and the rich/ rest card tests now assert Pin/Unpin. Full suite 30 files green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a6c66d1 commit 383b0d4

7 files changed

Lines changed: 78 additions & 3 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,7 @@ async function handleSessionAction(action, id) {
704704
if (action === 'resume') { await resumeSession(id); return; }
705705
if (action === 'done') { m.archive(id); refreshSessions(); return; }
706706
if (action === 'delete') { m.trash(id); refreshSessions(); return; }
707+
if (action === 'pin') { const cur = (m.list().find((e) => e.id === id) || {}).pinned; m.setPinned(id, !cur); refreshSessions(); return; }
707708
if (action === 'rename') {
708709
const cur = (m.list().find((e) => e.id === id) || {}).title || '';
709710
const title = await vscode.window.showInputBox({ prompt: 'Rename this session', value: cur, validateInput: (v) => (v && v.trim() ? null : 'Enter a name') });
@@ -2237,6 +2238,16 @@ function activate(context) {
22372238
fastCompletionModel: catalog.fastCompletionModel
22382239
});
22392240

2241+
// Sessions housekeeping: fade sessions untouched for a while out of the working set (§4.9 — auto-archive
2242+
// at 30 days, pinned exempt, NEVER deletes). Deferred + guarded so it never slows or breaks activation;
2243+
// off when levelcode.ai.sessions.autoArchiveDays is 0.
2244+
setTimeout(() => {
2245+
try {
2246+
const days = aiConfig().get('sessions.autoArchiveDays', 30);
2247+
if (days > 0) { const mgr = sessionsManager(); if (mgr) { const nArch = mgr.autoArchiveStale({ days }); if (nArch) { dbg('sessions.autoArchive', { archived: nArch, days }); refreshSessions(); } } }
2248+
} catch (e) { /* housekeeping is best-effort */ }
2249+
}, 1500);
2250+
22402251
// AI-first: reveal the chat (in the secondary side bar) on EVERY launch until the user has actually
22412252
// engaged (sent their first message). This makes sure new users always see it, instead of it only
22422253
// showing once. Once they've sent a message (handleSend sets the flag) we stop forcing it and defer

‎extensions/levelcode-ai/media/chat.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3328,7 +3328,7 @@
33283328
const rich = '<div class="sessrich">'
33293329
+ (files.length ? '<div class="sessfiles">' + chips + '</div>' : '')
33303330
+ '<div class="sessmeta">' + (e.model ? escFn(sessModelShort(e.model)) + ' · ' : '') + turns + ' turn' + (turns === 1 ? '' : 's') + ' ' + sessSparkSvg(e.spark) + '</div>'
3331-
+ '<div class="sessacts"><button class="sessbtn pri" data-act="resume">Resume &#9166;</button><button class="sessbtn" data-act="done">Done</button><button class="sessbtn" data-act="rename">Rename</button><button class="sessbtn" data-act="delete">Delete</button></div>'
3331+
+ '<div class="sessacts"><button class="sessbtn pri" data-act="resume">Resume &#9166;</button><button class="sessbtn" data-act="pin">' + (e.pinned ? 'Unpin' : 'Pin') + '</button><button class="sessbtn" data-act="done">Done</button><button class="sessbtn" data-act="rename">Rename</button><button class="sessbtn" data-act="delete">Delete</button></div>'
33323332
+ '</div>';
33333333
return '<div class="sesscard' + st + '" data-id="' + escAttrFn(e.id || '') + '" tabindex="0" role="button" aria-label="' + escAttrFn(e.title || 'Untitled') + '">'
33343334
+ '<div class="sesstop"><span class="sessname" title="' + escAttrFn(e.title || '') + '">' + escFn(e.title || 'Untitled') + '</span>' + (e.pinned ? '<span class="sessstar" aria-label="pinned">★</span>' : '') + '</div>'

‎extensions/levelcode-ai/media/sessionsView.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
const rich = '<div class="sessrich">'
102102
+ (files.length ? '<div class="sessfiles">' + chips + '</div>' : '')
103103
+ '<div class="sessmeta">' + (e.model ? escFn(sessModelShort(e.model)) + ' · ' : '') + turns + ' turn' + (turns === 1 ? '' : 's') + ' ' + sessSparkSvg(e.spark) + '</div>'
104-
+ '<div class="sessacts"><button class="sessbtn pri" data-act="resume">Resume &#9166;</button><button class="sessbtn" data-act="done">Done</button><button class="sessbtn" data-act="rename">Rename</button><button class="sessbtn" data-act="delete">Delete</button></div>'
104+
+ '<div class="sessacts"><button class="sessbtn pri" data-act="resume">Resume &#9166;</button><button class="sessbtn" data-act="pin">' + (e.pinned ? 'Unpin' : 'Pin') + '</button><button class="sessbtn" data-act="done">Done</button><button class="sessbtn" data-act="rename">Rename</button><button class="sessbtn" data-act="delete">Delete</button></div>'
105105
+ '</div>';
106106
return '<div class="sesscard' + st + '" data-id="' + escAttrFn(e.id || '') + '" tabindex="0" role="button" aria-label="' + escAttrFn(e.title || 'Untitled') + '">'
107107
+ '<div class="sesstop"><span class="sessname" title="' + escAttrFn(e.title || '') + '">' + escFn(e.title || 'Untitled') + '</span>' + (e.pinned ? '<span class="sessstar" aria-label="pinned">★</span>' : '') + '</div>'

‎extensions/levelcode-ai/package.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,12 @@
441441
"description": "Folder to store session History in. Empty = ~/.levelcode/sessions. Each project gets its own subfolder; the files are append-only JSONL you can back up, diff, or delete by hand.",
442442
"scope": "machine"
443443
},
444+
"levelcode.ai.sessions.autoArchiveDays": {
445+
"type": "number",
446+
"default": 30,
447+
"minimum": 0,
448+
"description": "Automatically archive sessions you haven't touched in this many days, to keep History focused on recent work. Archived sessions are hidden from the list but never deleted (the JSONL stays on disk), and pinned sessions are always kept. Set to 0 to turn auto-archiving off."
449+
},
444450
"levelcode.ai.sessions.resumeBudgetPct": {
445451
"type": "number",
446452
"default": 40,

‎extensions/levelcode-ai/sessions.js‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,34 @@ function createSessions(opts) {
125125
return appendTo(id, events.titleEvent(String(title).trim(), iso()));
126126
}
127127

128+
/**
129+
* Fade stale sessions out of the working set (§4.9 — "auto-archive at 30 days, keep the magic"): archive
130+
* every ACTIVE, non-pinned, non-live session whose last activity is older than `days`. Pinned means
131+
* "keep" (exempt); already archived/trashed are skipped; the live session is never touched. Append-only
132+
* (a label event each) — it NEVER deletes — and best-effort. Returns how many it archived.
133+
*/
134+
function autoArchiveStale(opts) {
135+
const o = opts || {};
136+
const days = Number.isFinite(o.days) && o.days > 0 ? o.days : 30;
137+
const nowMs = Number.isFinite(o.nowMs) ? o.nowMs : clock().getTime();
138+
const cutoff = nowMs - days * 86400000;
139+
const liveNow = live ? live.id : null;
140+
let archived = 0;
141+
for (const e of list()) {
142+
if (!e || e.pinned || e.id === liveNow) { continue; }
143+
if ((e.lifecycle || 'active') !== 'active') { continue; }
144+
const last = Date.parse(e.updatedAt || e.createdAt || '');
145+
if (Number.isFinite(last) && last < cutoff && archive(e.id)) { archived++; }
146+
}
147+
return archived;
148+
}
149+
128150
/** The session index for this project (what the panel/view list). Empty (never throws) if unreadable. */
129151
function list() { try { return store.loadIndex(root, slug).entries; } catch (e) { return []; } }
130152

131153
function liveId() { return live ? live.id : null; }
132154

133-
return { ensure, recordTurn, seal, resume, archive, trash, setPinned, rename, list, liveId };
155+
return { ensure, recordTurn, seal, resume, archive, trash, setPinned, rename, autoArchiveStale, list, liveId };
134156
}
135157

136158
module.exports = { createSessions };

‎extensions/levelcode-ai/test/sessions.test.js‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,30 @@ test('MANAGER: archive / rename / trash are append-only edits that update the in
176176
assert.strictEqual(m.list().find((e) => e.id === id).lifecycle, 'trashed', 'the latest label event wins');
177177
});
178178

179+
test('MANAGER: autoArchiveStale fades stale, non-pinned sessions and exempts pinned + recent', () => {
180+
const root = freshRoot(), slug = store.projectSlug('/aa');
181+
const DAY = 86400000;
182+
const T0 = Date.parse('2026-06-01T12:00:00Z'); // "now" for the sweep
183+
// stamp a whole session at a chosen wall-clock time (clock injected), optionally pinned at that same time
184+
const sessionAt = (ms, prompt, pin) => {
185+
const mm = createSessions({ root, slug, projectPath: '/aa', now: () => new Date(ms) });
186+
mm.recordTurn([{ role: 'user', content: prompt }], 'm');
187+
const id = mm.liveId();
188+
if (pin) { mm.setPinned(id, true); } // pin stamped old too, so it's genuinely old AND pinned
189+
mm.seal('done');
190+
return id;
191+
};
192+
const oldId = sessionAt(T0 - 40 * DAY, 'ancient'); // 40d old, unpinned → stale
193+
const recentId = sessionAt(T0 - 5 * DAY, 'recent'); // 5d old → kept
194+
const pinnedOldId = sessionAt(T0 - 90 * DAY, 'kept', true); // 90d old but pinned → exempt
195+
196+
const m = createSessions({ root, slug, projectPath: '/aa', now: () => new Date(T0) });
197+
assert.strictEqual(m.autoArchiveStale({ days: 30, nowMs: T0 }), 1, 'only the stale, non-pinned session faded');
198+
const byId = (id) => m.list().find((e) => e.id === id);
199+
assert.strictEqual(byId(oldId).lifecycle, 'archived', 'the 40-day-old session auto-archived');
200+
assert.strictEqual(byId(recentId).lifecycle, 'active', 'the 5-day-old session stayed');
201+
assert.strictEqual(byId(pinnedOldId).lifecycle, 'active', 'a pinned session is exempt however old');
202+
assert.strictEqual(m.autoArchiveStale({ days: 30, nowMs: T0 }), 0, 'idempotent — already-archived are skipped');
203+
});
204+
179205
console.log('sessions: ' + n + ' tests passed');

‎extensions/levelcode-ai/test/sessionsUi.test.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ test('CARD (rest): two lines — title + primary-file·time; done shows NO state
7171
assert.match(h, /Idempotent refunds/);
7272
assert.match(h, /refund\.rb · 2h/, 'the quiet line = primary file · relative time');
7373
assert.match(h, /sessstar/, 'pinned → a star');
74+
assert.match(h, /data-act="pin">Unpin</, 'a pinned session offers Unpin');
7475
});
7576

7677
test('CARD (state): interrupted gets the warn left-edge; a done card and an unpinned card stay bare', () => {
@@ -89,6 +90,7 @@ test('CARD (rich): the hover detail carries files, model, turns, sparkline, and
8990
assert.match(h, /41 turns/);
9091
assert.match(h, /<svg/, 'sparkline lives in the expand, not the resting row');
9192
assert.match(h, /data-act="resume"/, 'inline actions, no … menu');
93+
assert.match(h, /data-act="pin">Pin</, 'an unpinned session offers Pin');
9294
assert.match(h, /class="sesschip">\+1</, '4 files → 3 chips + overflow');
9395
});
9496

@@ -140,6 +142,14 @@ test('RESUME WIRING: the host acts on card actions and the chat replays a resume
140142
assert.match(html, /function resumeTranscript/, 'via a dedicated replay (prompts + prose, tool plumbing dropped)');
141143
});
142144

145+
test('HOUSEKEEPING: a Pin toggle on the card, and a 30-day auto-archive sweep on startup', () => {
146+
assert.match(ext, /action === 'pin'[\s\S]{0,120}setPinned\(id, !cur\)/, 'Pin toggles the pinned flag');
147+
assert.match(ext, /autoArchiveStale\(\{ days \}\)/, 'startup runs the auto-archive sweep');
148+
assert.match(ext, /sessions\.autoArchiveDays/, 'gated by the setting');
149+
assert.ok((pkg.contributes.configuration.properties || {})['levelcode.ai.sessions.autoArchiveDays'], 'the setting is contributed');
150+
assert.ok(/data-act="pin">/.test(html) && /data-act="pin">/.test(view), 'both surfaces render the Pin action');
151+
});
152+
143153
test('VIEW: the sidebar view and the modal share the EXACT same render block (no drift)', () => {
144154
const grab = (s) => s.slice(s.indexOf('// [SESSIONS-PURE-START]'), s.indexOf('// [SESSIONS-PURE-END]'));
145155
assert.ok(grab(view).length > 500, 'the view carries the pure block');

0 commit comments

Comments
 (0)