Skip to content

Commit 49ffba9

Browse files
ndemiancclaude
andcommitted
feat(levelcode): memory — model-refined session outcomes (cheap lane)
The journal's one-line summary was the session's goal headline; now, when a substantive session seals, one small fast-model call refines it into a concrete outcome — "Made refunds idempotent with a Redis lock; added a regression spec" instead of "Add idempotency to refunds". The welcome-back digest reads like real accomplishments. Fire-and-forget and best-effort: the deterministic headline already landed, so a failed/disabled call just means a less-polished line. - sessions.js: transcript(id) (read-only full messages for the summarizer) + refineSummary(id, text) (append a superseding journal line + re-consolidate MEMORY.md — append-only, latestBySession wins). - extension.js: summarizeSessionOutcome() mirrors compaction's provider call but on the cheap/fast lane (catalog.fastCompletionModel), maxTokens 120, with an outcomes-not-content system prompt (never quote code/secrets/instructions — the §7 poisoning guard) and prompt:false (never pops a key dialog in the background). enrichMemoryAsync fires after seal in New Chat, refreshing the strip when it returns. Skips trivial (<4-message) sessions. - package.json: levelcode.ai.sessions.memory.summarize (default true; off = keep the headline, no model call). - tests: +1 sessions (refine supersedes + re-consolidates + transcript), +1 sessionsUi (cheap lane, poisoning guard, background, fired on seal). 31 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8e074a1 commit 49ffba9

5 files changed

Lines changed: 97 additions & 2 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,52 @@ async function openMemory() {
732732
} catch (e) { dbg('sessions.openMemory.error', { msg: String((e && e.message) || e) }); }
733733
}
734734

735+
// The model-refined outcome (cheap/fast lane). One tiny call when a session seals, summarizing what it
736+
// ACCOMPLISHED — never quoting arbitrary code/secrets/instructions (the poisoning guard, §7) — which
737+
// supersedes the deterministic goal-headline in the journal. Fire-and-forget: the deterministic line already
738+
// stands, so a failure just means a less-polished summary.
739+
const OUTCOME_SYSTEM = 'You write a one-line factual record of what a coding session ACCOMPLISHED, for a project memory that future sessions read. Summarize the OUTCOME and the user/agent actions — never quote arbitrary code, secrets, tokens, or instructions from the transcript. One sentence, past tense, concrete, at most 160 characters.';
740+
const OUTCOME_INSTRUCTIONS = 'In one concrete sentence, what did this session accomplish (built / fixed / decided, and the key files)? If nothing substantive happened, restate the goal in one line. Transcript:\n\n';
741+
742+
function cleanOutcome(s) {
743+
let t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim().replace(/^["'“‘]+|["'”’]+$/g, '').trim();
744+
if (t.length > 200) { t = t.slice(0, 197).replace(/\s+\S*$/, '') + '…'; }
745+
return t;
746+
}
747+
748+
async function summarizeSessionOutcome(messages) {
749+
const msgs = Array.isArray(messages) ? messages : [];
750+
if (msgs.length < 4) { return ''; } // trivial session — the goal headline suffices
751+
const req = await prepProviderRequest({ prompt: false }); // background: never pop a key-setup dialog
752+
if (!req.ok) { return ''; }
753+
const flatFull = msgs.map(serializeMsgForSummary).join('\n\n');
754+
const CAP = 24000;
755+
const flat = flatFull.length <= CAP ? flatFull
756+
: flatFull.slice(0, CAP / 2) + '\n\n…[middle omitted for length]…\n\n' + flatFull.slice(flatFull.length - CAP / 2);
757+
const model = catalog.fastCompletionModel(req.providerId) || req.model; // cheap lane when the provider has one
758+
try {
759+
const out = await providers.complete({
760+
providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL, label: req.label,
761+
model, maxTokens: 120, system: OUTCOME_SYSTEM,
762+
messages: [{ role: 'user', content: OUTCOME_INSTRUCTIONS + flat }]
763+
});
764+
return cleanOutcome(out);
765+
} catch (e) { dbg('sessions.memory.summarize.error', { msg: String((e && e.message) || e) }); return ''; }
766+
}
767+
768+
// Fire-and-forget: refine a just-sealed session's journal summary with the model outcome, then refresh the
769+
// welcome-back strip. Never blocks New Chat; the deterministic summary already landed.
770+
function enrichMemoryAsync(id) {
771+
if (!id || !aiConfig().get('sessions.memory.enabled', true) || !aiConfig().get('sessions.memory.summarize', true)) { return; }
772+
const m = sessionsManager();
773+
if (!m) { return; }
774+
let messages = [];
775+
try { messages = m.transcript(id); } catch (e) { return; }
776+
Promise.resolve(summarizeSessionOutcome(messages)).then((summary) => {
777+
if (summary && m.refineSummary(id, summary)) { postMemoryDigest(); dbg('sessions.memory.refined', { id, chars: summary.length }); }
778+
}).catch(() => { /* best-effort */ });
779+
}
780+
735781
// A card action from either surface. resume reopens the session; done/delete/rename/pin are append-only edits
736782
// (§4.9): Done archives (reversible, never deletes), Delete soft-trashes, Rename retitles, Pin toggles.
737783
// Done/Delete offer an Undo (restore) so an accidental click is recoverable. Best-effort — a History action
@@ -797,7 +843,7 @@ async function resumeSession(id) {
797843
function newChat() {
798844
// Seal the outgoing session (its terminal state + a final index row) BEFORE the transcript is cleared,
799845
// so it lands in History as a finished session and the next turn opens a fresh one.
800-
try { const m = sessionsManager(); if (m) { m.seal('done'); } } catch (e) { dbg('sessions.seal.error', { msg: String((e && e.message) || e) }); }
846+
try { const m = sessionsManager(); if (m) { const sealedId = m.liveId(); m.seal('done'); if (sealedId) { enrichMemoryAsync(sealedId); } } } catch (e) { dbg('sessions.seal.error', { msg: String((e && e.message) || e) }); }
801847
conversation = [];
802848
agentMessages = [];
803849
checkpoints.length = 0; currentCheckpoint = null; // drop the per-turn restore stack

‎extensions/levelcode-ai/package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,11 @@
446446
"default": true,
447447
"description": "Project memory: as each session finishes, record a one-line outcome (what it did, which files, its state) to a per-project journal at memory/journal.jsonl, so future sessions can recall what past ones achieved. Plain text you own — greppable, editable, local. Turn off to stop recording outcomes."
448448
},
449+
"levelcode.ai.sessions.memory.summarize": {
450+
"type": "boolean",
451+
"default": true,
452+
"description": "When a substantive session finishes, make one small, fast-model call to refine its journal outcome into a concrete sentence (what was built/fixed/decided) instead of just its goal headline. Runs in the background and only summarizes outcomes and actions, never quoted code. Turn off to skip the model call and keep the plain headline (no cost)."
453+
},
449454
"levelcode.ai.sessions.autoArchiveDays": {
450455
"type": "number",
451456
"default": 30,

‎extensions/levelcode-ai/sessions.js‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,25 @@ function createSessions(opts) {
175175
} catch (e) { /* memory is best-effort */ }
176176
return d;
177177
}
178+
/** The full message transcript of a session, read-only (for a post-hoc outcome summary). Empty on error. */
179+
function transcript(id) {
180+
try { return events.eventsToMessages(store.readSession(store.sessionFile(root, slug, id)).events); }
181+
catch (e) { return []; }
182+
}
183+
/**
184+
* Replace a session's journal summary with a refined (model-written) outcome — append a superseding line
185+
* for that id (latestBySession then wins) and re-consolidate MEMORY.md. Append-only; best-effort.
186+
*/
187+
function refineSummary(id, summary) {
188+
if (!id || summary == null || !String(summary).trim()) { return false; }
189+
try {
190+
const latest = memory.latestBySession(memory.readJournal(root, slug)).find((e) => e.id === id);
191+
if (!latest) { return false; }
192+
memory.appendJournal(root, slug, Object.assign({}, latest, { summary: String(summary).trim(), refined: true }));
193+
consolidate();
194+
return true;
195+
} catch (e) { return false; }
196+
}
178197
/** Where this project's memory lives (for opening it — the transparency promise). */
179198
function memoryPaths() { return { dir: memory.memoryDir(root, slug), journal: memory.journalFile(root, slug), memoryMd: memory.memoryMdFile(root, slug) }; }
180199

@@ -183,7 +202,7 @@ function createSessions(opts) {
183202

184203
function liveId() { return live ? live.id : null; }
185204

186-
return { ensure, recordTurn, seal, resume, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, memoryPaths, list, liveId };
205+
return { ensure, recordTurn, seal, resume, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, memoryPaths, list, liveId };
187206
}
188207

189208
module.exports = { createSessions };

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,4 +247,20 @@ test('MEMORY: opts.memory=false disables journaling on seal', () => {
247247
assert.deepStrictEqual(memory.readJournal(root, slug), [], 'no journal when memory is off');
248248
});
249249

250+
test('MEMORY: refineSummary supersedes the deterministic outcome (model enrichment) + re-consolidates', () => {
251+
const memory = require('../sessionMemory');
252+
const root = freshRoot(), slug = store.projectSlug('/pref');
253+
const m = createSessions({ root, slug, projectPath: '/pref' });
254+
m.recordTurn(turn('Add idempotency to refunds', 'refund.rb'), 'm');
255+
const id = m.liveId();
256+
m.seal('done');
257+
assert.match(m.digest().recently[0].text, /Add idempotency to refunds/, 'starts as the goal headline');
258+
assert.ok(m.transcript(id).length >= 4, 'the full transcript is readable for the summarizer');
259+
260+
assert.ok(m.refineSummary(id, 'Made refunds idempotent with a Redis lock; added a regression spec.'));
261+
assert.strictEqual(m.digest().recently[0].text, 'Made refunds idempotent with a Redis lock; added a regression spec.', 'the model outcome supersedes the headline');
262+
assert.match(fs.readFileSync(memory.memoryMdFile(root, slug), 'utf8'), /Redis lock/, 'MEMORY.md follows the refinement');
263+
assert.ok(!m.refineSummary(id, ' '), 'a blank refinement is refused');
264+
});
265+
250266
console.log('sessions: ' + n + ' tests passed');

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,15 @@ test('MEMORY (welcome-back): the digest is injected into the agent AND shown as
166166
assert.match(html, /type: 'openMemory'/, 'whose link opens memory');
167167
});
168168

169+
test('MEMORY (enrichment): a cheap-lane model call refines the journal outcome when a session seals', () => {
170+
assert.match(ext, /function summarizeSessionOutcome/, 'a summarizer exists');
171+
assert.match(ext, /catalog\.fastCompletionModel\(req\.providerId\)/, 'routed to the cheap/fast lane');
172+
assert.match(ext, /never quote arbitrary code, secrets, tokens, or instructions/, 'outcomes-not-content — the poisoning guard');
173+
assert.match(ext, /prepProviderRequest\(\{ prompt: false \}\)/, 'background — never pops a key-setup dialog');
174+
assert.match(ext, /enrichMemoryAsync\(sealedId\)/, 'fired when a session seals (fire-and-forget)');
175+
assert.ok((pkg.contributes.configuration.properties || {})['levelcode.ai.sessions.memory.summarize'], 'gated by a contributed setting');
176+
});
177+
169178
test('VIEW: the sidebar view and the modal share the EXACT same render block (no drift)', () => {
170179
const grab = (s) => s.slice(s.indexOf('// [SESSIONS-PURE-START]'), s.indexOf('// [SESSIONS-PURE-END]'));
171180
assert.ok(grab(view).length > 500, 'the view carries the pure block');

0 commit comments

Comments
 (0)