Skip to content

Commit 71cea45

Browse files
ndemiancclaude
andcommitted
feat(levelcode): cross-session memory M1 — the journal (extract-on-seal)
First slice of project memory (docs/levelcode-sessions-memory.md): every session that finishes records a one-line outcome to a per-project journal, so future sessions can build on what past ones achieved. Plain JSONL you own — greppable, editable, local, sourced + dated. This is the storage substrate; the recall tool, the model-refined summary, and the always-on digest layer on top next. - sessionMemory.js: the memory store (pure fs/path, no vscode/model, temp-dir tested). journal.jsonl beside the sessions at memory/; outcomeEntry() derives a sourced+dated line from a session's index row (title/files/turns/state — the summary is the goal headline for now, a cheap-lane model pass refines it later); append/read (corruption-tolerant); latestBySession() collapses the append-only log to one current outcome per session, newest-first. - sessions.js: seal() now journals one deterministic outcome line per sealed session (best-effort — memory never disturbs a chat), gated by opts.memory. - extension.js: the manager passes memory = sessions.memory.enabled. - package.json: levelcode.ai.sessions.memory.enabled (default true). - tests: +4 sessionMemory (outcome/append/read/latest), +2 sessions (seal journals a line; memory:false disables). 31 files green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c392ae5 commit 71cea45

6 files changed

Lines changed: 214 additions & 1 deletion

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@ function sessionsManager() {
662662
try {
663663
_sessionsMgr = createSessions({
664664
root: sessionsRoot(), slug, projectPath,
665+
memory: aiConfig().get('sessions.memory.enabled', true), // journal a per-session outcome on seal
665666
// A tiny workspaceState-backed pointer to the live session id (a reload can tell what was live;
666667
// used later by resume). Namespaced + guarded — a state failure must not break persistence.
667668
state: ctx ? { get: (k) => ctx.workspaceState.get('levelcode.ai.session.' + k), set: (k, v) => ctx.workspaceState.update('levelcode.ai.session.' + k, v) } : null

‎extensions/levelcode-ai/package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,11 @@
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.memory.enabled": {
445+
"type": "boolean",
446+
"default": true,
447+
"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."
448+
},
444449
"levelcode.ai.sessions.autoArchiveDays": {
445450
"type": "number",
446451
"default": 30,
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// @ts-check
2+
'use strict';
3+
4+
/*
5+
* sessionMemory.js — the project's cross-session memory store (docs/levelcode-sessions-memory.md).
6+
*
7+
* Plain, per-project, greppable files that live BESIDE the sessions they came from:
8+
* ~/.levelcode/sessions/<project-slug>/memory/
9+
* journal.jsonl append-only, one line per sealed session — its outcome, files, state, link back
10+
* facts.jsonl durable facts with provenance (later layers)
11+
* MEMORY.md the always-on capped digest (later layers)
12+
*
13+
* This module is the STORE only — pure Node (fs/path), no vscode, no model, unit-tested against a temp dir
14+
* like sessionStore. It is deliberately dumb: it writes and reads plain JSONL. The value layers on top —
15+
* a model-refined summary on seal, the consolidated digest, the recall tool — are separate slices that use
16+
* this as their substrate. Everything here is best-effort at the caller: memory must never disturb a chat.
17+
*
18+
* Four disciplines it upholds (design §0): the journal is the deep append-only record (never the always-on
19+
* context — that is the capped digest); it is plain text the user owns; and every entry is SOURCED (its
20+
* session id) and DATED, so a memory can always answer "says who, and when?" and be removed in one click.
21+
*/
22+
23+
const fs = require('fs');
24+
const path = require('path');
25+
26+
const SCHEMA_V = 1;
27+
28+
function memoryDir(root, slug) { return path.join(root, slug, 'memory'); }
29+
function journalFile(root, slug) { return path.join(memoryDir(root, slug), 'journal.jsonl'); }
30+
function factsFile(root, slug) { return path.join(memoryDir(root, slug), 'facts.jsonl'); }
31+
function memoryMdFile(root, slug) { return path.join(memoryDir(root, slug), 'MEMORY.md'); }
32+
33+
/**
34+
* Build a journal entry from a session's DERIVED index entry (deterministic — no model call). The `summary`
35+
* is the session's title (its goal headline) for now; a later cheap-lane pass refines it into a 1–3 sentence
36+
* outcome without changing this shape. `id` is the provenance link back to the full session JSONL.
37+
* @param {any} derived a sessionStore.deriveEntry(...) result
38+
* @param {string} [t] the seal time (ISO); falls back to the session's own updatedAt
39+
*/
40+
function outcomeEntry(derived, t) {
41+
const d = derived || {};
42+
const files = Array.isArray(d.filesEdited) ? d.filesEdited.slice(0, 6) : [];
43+
const title = d.title != null ? String(d.title) : null;
44+
return {
45+
v: SCHEMA_V,
46+
id: d.id != null ? String(d.id) : null, // source_session — provenance
47+
at: t || d.updatedAt || d.createdAt || null, // learned_at
48+
title: title,
49+
summary: title, // v1 outcome = the goal headline; model refines later
50+
files: files,
51+
turns: Number.isFinite(d.turns) ? d.turns : 0,
52+
state: d.state != null ? String(d.state) : 'done',
53+
pinned: !!d.pinned
54+
};
55+
}
56+
57+
/** Append one journal entry as a JSONL line (creates memory/ on first write). Returns the entry. */
58+
function appendJournal(root, slug, entry) {
59+
fs.mkdirSync(memoryDir(root, slug), { recursive: true });
60+
fs.appendFileSync(journalFile(root, slug), JSON.stringify(entry) + '\n');
61+
return entry;
62+
}
63+
64+
/** Read journal.jsonl → array of entries, oldest-first. Tolerant: skips blank/corrupt lines; [] if absent. */
65+
function readJournal(root, slug) {
66+
let raw;
67+
try { raw = fs.readFileSync(journalFile(root, slug), 'utf8'); }
68+
catch (e) { return []; } // no journal yet
69+
const out = [];
70+
for (const line of raw.split('\n')) {
71+
const s = line.trim();
72+
if (!s) { continue; }
73+
try { out.push(JSON.parse(s)); } catch (e) { /* skip a single corrupt line, keep the rest */ }
74+
}
75+
return out;
76+
}
77+
78+
/**
79+
* The journal collapsed to one entry per session — the LATEST outcome for each id wins (a resumed-then-sealed
80+
* session supersedes its earlier line), newest-first. This is what recall + the digest read; the raw
81+
* journal.jsonl stays the full append-only history.
82+
*/
83+
function latestBySession(entries) {
84+
const arr = Array.isArray(entries) ? entries : [];
85+
const byId = new Map();
86+
for (const e of arr) {
87+
if (!e || e.id == null) { continue; }
88+
byId.set(String(e.id), e); // later lines overwrite earlier → latest wins
89+
}
90+
const out = [...byId.values()];
91+
out.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')));
92+
return out;
93+
}
94+
95+
module.exports = {
96+
SCHEMA_V,
97+
memoryDir, journalFile, factsFile, memoryMdFile,
98+
outcomeEntry, appendJournal, readJournal, latestBySession
99+
};

‎extensions/levelcode-ai/sessions.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,17 @@
1818
const store = require('./sessionStore');
1919
const events = require('./sessionEvents');
2020
const planner = require('./sessionResume');
21+
const memory = require('./sessionMemory');
2122

2223
/**
2324
* @param {{ root: string, slug: string, projectPath: string,
2425
* state?: { get: (k: string) => any, set: (k: string, v: any) => any } | null,
25-
* now?: () => Date }} opts
26+
* memory?: boolean, now?: () => Date }} opts
2627
*/
2728
function createSessions(opts) {
2829
const root = opts.root, slug = opts.slug, projectPath = opts.projectPath;
2930
const state = opts.state || null;
31+
const memoryOn = opts.memory !== false; // cross-session memory: journal a line on seal (off → skip)
3032
const clock = opts.now || (() => new Date());
3133
/** @type {{ id: string, file: string } | null} */
3234
let live = null;
@@ -80,6 +82,14 @@ function createSessions(opts) {
8082
function seal(endState) {
8183
if (!live) { return; }
8284
try { store.appendEvent(live.file, events.endEvent(endState || 'done', iso())); reindex(); } catch (e) { /* best-effort */ }
85+
if (memoryOn) {
86+
// Cross-session memory: one journal line per sealed session (deterministic outcome now; a cheap-lane
87+
// model pass refines the summary later). Best-effort — memory must never disturb sealing a chat.
88+
try {
89+
const s = store.readSession(live.file);
90+
memory.appendJournal(root, slug, memory.outcomeEntry(store.deriveEntry(s.meta, s.events), iso()));
91+
} catch (e) { /* memory is best-effort */ }
92+
}
8393
live = null;
8494
if (state) { try { state.set('liveSessionId', null); } catch (e) { /* convenience */ } }
8595
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* sessionMemory — the cross-session memory store — run: node test/sessionMemory.test.js
3+
*
4+
* The plain-JSONL substrate for project memory (docs/levelcode-sessions-memory.md). Pure fs/path, so it
5+
* unit-tests against a temp dir: a journal entry derives deterministically from a session's index row
6+
* (sourced + dated), append/read roundtrips and tolerates corruption, and latestBySession collapses the
7+
* append-only log to one current outcome per session, newest-first.
8+
*--------------------------------------------------------------------------------------------*/
9+
// @ts-check
10+
'use strict';
11+
12+
const assert = require('assert');
13+
const fs = require('fs');
14+
const os = require('os');
15+
const path = require('path');
16+
const M = require('../sessionMemory');
17+
18+
let n = 0;
19+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
20+
21+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-mem-'));
22+
process.on('exit', () => { try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* best effort */ } });
23+
let seq = 0;
24+
const freshRoot = () => path.join(tmpRoot, 'r' + (seq++));
25+
const slug = 'proj';
26+
27+
test('OUTCOME: a journal entry derives from a session index row — deterministic, sourced + dated', () => {
28+
const derived = { id: 's1', title: 'Tidy the CHANGELOG for v1.0.4', updatedAt: 't-up', createdAt: 't-cr',
29+
turns: 6, state: 'done', pinned: true, filesEdited: ['RELEASE-NOTES.md', 'a', 'b', 'c', 'd', 'e', 'f', 'g'] };
30+
const e = M.outcomeEntry(derived, 't-seal');
31+
assert.strictEqual(e.id, 's1', 'source_session (provenance link back to the full JSONL)');
32+
assert.strictEqual(e.at, 't-seal', 'learned_at = the seal time when given');
33+
assert.strictEqual(e.title, 'Tidy the CHANGELOG for v1.0.4');
34+
assert.strictEqual(e.summary, e.title, 'v1 outcome summary = the goal headline (model refines later)');
35+
assert.strictEqual(e.turns, 6);
36+
assert.strictEqual(e.state, 'done');
37+
assert.strictEqual(e.pinned, true);
38+
assert.strictEqual(e.files.length, 6, 'files capped at 6');
39+
assert.strictEqual(e.files[0], 'RELEASE-NOTES.md');
40+
assert.strictEqual(M.outcomeEntry(derived).at, 't-up', 'falls back to updatedAt with no seal time');
41+
});
42+
43+
test('JOURNAL: append then read roundtrips oldest-first, creating memory/ on first write', () => {
44+
const root = freshRoot();
45+
assert.deepStrictEqual(M.readJournal(root, slug), [], 'empty before any write');
46+
M.appendJournal(root, slug, M.outcomeEntry({ id: 'a', title: 'one', updatedAt: 't1', turns: 1, state: 'done', filesEdited: [] }));
47+
M.appendJournal(root, slug, M.outcomeEntry({ id: 'b', title: 'two', updatedAt: 't2', turns: 2, state: 'done', filesEdited: ['x'] }));
48+
const j = M.readJournal(root, slug);
49+
assert.strictEqual(j.length, 2);
50+
assert.deepStrictEqual(j.map((e) => e.id), ['a', 'b'], 'append order (oldest-first)');
51+
assert.ok(fs.existsSync(M.journalFile(root, slug)), 'journal.jsonl exists under memory/');
52+
});
53+
54+
test('JOURNAL: read tolerates blank + corrupt lines (keeps the good ones)', () => {
55+
const root = freshRoot();
56+
fs.mkdirSync(M.memoryDir(root, slug), { recursive: true });
57+
fs.writeFileSync(M.journalFile(root, slug),
58+
JSON.stringify({ v: 1, id: 'a', at: 't1' }) + '\n\n{ not json\n' + JSON.stringify({ v: 1, id: 'b', at: 't2' }) + '\n');
59+
assert.deepStrictEqual(M.readJournal(root, slug).map((e) => e.id), ['a', 'b'], 'blank + garbage skipped');
60+
});
61+
62+
test('LATEST: one outcome per session — the latest supersedes, newest-first', () => {
63+
const entries = [
64+
{ id: 'a', at: '2026-01-01', title: 'a-old' },
65+
{ id: 'b', at: '2026-03-01', title: 'b' },
66+
{ id: 'a', at: '2026-02-01', title: 'a-new' } // 'a' resumed + re-sealed → later line wins
67+
];
68+
const latest = M.latestBySession(entries);
69+
assert.deepStrictEqual(latest.map((e) => e.id), ['b', 'a'], 'collapsed to one per id, newest-first by date');
70+
assert.strictEqual(latest.find((e) => e.id === 'a').title, 'a-new', 'the later line supersedes the earlier');
71+
});
72+
73+
console.log('sessionMemory: ' + n + ' tests passed');

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,4 +216,29 @@ test('MANAGER: autoArchiveStale fades stale, non-pinned sessions and exempts pin
216216
assert.strictEqual(m.autoArchiveStale({ days: 30, nowMs: T0 }), 0, 'idempotent — already-archived are skipped');
217217
});
218218

219+
test('MEMORY: sealing a session writes one journal line (the cross-session outcome record)', () => {
220+
const memory = require('../sessionMemory');
221+
const root = freshRoot(), slug = store.projectSlug('/pm');
222+
const m = createSessions({ root, slug, projectPath: '/pm' });
223+
m.recordTurn(turn('Tidy the CHANGELOG for v1.0.4', 'RELEASE-NOTES.md'), 'anthropic/claude-opus-5');
224+
const id = m.liveId();
225+
assert.deepStrictEqual(memory.readJournal(root, slug), [], 'nothing journaled until the session seals');
226+
m.seal('done');
227+
const j = memory.readJournal(root, slug);
228+
assert.strictEqual(j.length, 1, 'one journal line per sealed session');
229+
assert.strictEqual(j[0].id, id, 'sourced back to the session');
230+
assert.match(j[0].title, /Tidy the CHANGELOG/);
231+
assert.deepStrictEqual(j[0].files, ['RELEASE-NOTES.md'], 'the outcome carries the files it touched');
232+
assert.strictEqual(j[0].state, 'done');
233+
});
234+
235+
test('MEMORY: opts.memory=false disables journaling on seal', () => {
236+
const memory = require('../sessionMemory');
237+
const root = freshRoot(), slug = store.projectSlug('/pm2');
238+
const m = createSessions({ root, slug, projectPath: '/pm2', memory: false });
239+
m.recordTurn([{ role: 'user', content: 'x' }], 'm');
240+
m.seal('done');
241+
assert.deepStrictEqual(memory.readJournal(root, slug), [], 'no journal when memory is off');
242+
});
243+
219244
console.log('sessions: ' + n + ' tests passed');

0 commit comments

Comments
 (0)