-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsessionStore.js
More file actions
261 lines (225 loc) · 13.5 KB
/
Copy pathsessionStore.js
File metadata and controls
261 lines (225 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
// @ts-check
'use strict';
/*
* sessionStore.js — the persistence spine for LevelCode chat sessions.
*
* Pure Node (fs / path / crypto), no `vscode` — so it unit-tests like update.js and mcpConfig.js.
* The design lives in docs/levelcode-chat-sessions-design.md (storage) and
* docs/levelcode-sessions-experience.md (why the shapes are what they are).
*
* The one property this module exists to guarantee is FAST RETRIEVAL that does not degrade with a
* session's age or with the size of the corpus:
*
* • LISTING / SEARCHING reads the in-memory INDEX, never the session files. `loadIndex` is paid once;
* `getEntry` / `listEntries` are pure lookups over that array. A chat created 25 days ago (or a
* year ago) costs exactly the same as one created a minute ago — the index carries its metadata.
* • SWITCHING (resume) reads exactly ONE file — that session's `.jsonl` — via `readSession`. It never
* scans the directory or touches its neighbours, so wall-clock is bounded by that one session's
* size, not by how many sessions exist or how old this one is.
* • The index is a CACHE, never load-bearing: `loadIndex` rebuilds it by scanning on any corruption,
* and `scanProject` (reading the files) is always the source of truth (anti-Copilot #vscdb).
*
* Storage layout (per project):
* <root>/<project-slug>/
* index.json cache of derived entries — rebuildable by scanning
* <session-id>.jsonl one meta line + append-only events
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
/** Bump only on an incompatible on-disk change; `loadIndex` refuses a newer index and rebuilds. */
const SCHEMA_V = 1;
const INDEX_NAME = 'index.json';
const PREVIEW_MAX = 96;
// ── slug & ids (pure) ──────────────────────────────────────────────────────────────────────────
/**
* A project path → a human-readable, filesystem-safe directory slug.
* "/Users/ada/code/thin.ly" → "-Users-ada-code-thin-ly"
*
* Every run of non-`[A-Za-z0-9_]` collapses to a single "-"; the LEADING dash is kept (it encodes the
* absolute-path root and matches the convention Claude Code and this repo's own memory dir already use),
* a trailing dash is trimmed. The slug is intentionally lossy (two unicode-only paths can collide); that
* is safe because the REAL path is stored in each file's meta line — a collision degrades to "listed
* under a shared name", never to "lost" (design §3 keying). Callers disambiguate on `meta.project`.
*/
function projectSlug(projectPath) {
const s = String(projectPath == null ? '' : projectPath).replace(/[^A-Za-z0-9_]+/g, '-').replace(/-+$/, '');
return s || '-'; // the filesystem root (or an empty path) still needs a directory name
}
/**
* A sortable, filesystem-safe session id: `YYYY-MM-DDTHH-MM-SS-<rand>` (colons/dots → dashes, so it is a
* legal filename and lexicographically time-ordered). `now`/`rand` are injected for deterministic tests;
* default to the clock and 4 base36 chars of randomness.
*/
function newSessionId(now, rand) {
const d = now instanceof Date ? now : new Date();
const stamp = d.toISOString().replace(/[:.]/g, '-').slice(0, 19); // 2026-07-28T09-12-33
const r = rand != null ? String(rand) : crypto.randomBytes(3).toString('hex').slice(0, 4);
return stamp + '-' + r;
}
// ── paths (pure) ───────────────────────────────────────────────────────────────────────────────
function projectDir(root, slug) { return path.join(root, slug); }
function sessionFile(root, slug, id) { return path.join(root, slug, String(id) + '.jsonl'); }
function indexFile(root, slug) { return path.join(root, slug, INDEX_NAME); }
// ── event encoding & parsing (pure) ──────────────────────────────────────────────────────────────
/** The birth line — written once, so even a one-message crash leaves a listable session (design §4). */
function metaLine(id, projectPath, createdAtIso, title, forkedFrom) {
const m = { kind: 'meta', v: SCHEMA_V, id: String(id), project: String(projectPath == null ? '' : projectPath),
createdAt: String(createdAtIso), title: title == null ? null : String(title) };
// Provenance for a forked session, and ONLY for one — the key is absent on an ordinary session so
// every existing file and expectation stays byte-identical. Unknown meta keys are ignored by
// parseSession and deriveEntry, so this is additive in both directions: an older build reading a
// forked session simply does not know it was forked, rather than failing to read it.
if (forkedFrom != null && String(forkedFrom)) { m.forkedFrom = String(forkedFrom); }
return m;
}
function encodeEvent(event) { return JSON.stringify(event) + '\n'; }
/**
* Parse a session file's text into `{ meta, events }`. Tolerant by construction: append-only means the
* only way a line is malformed is a torn final write (crash mid-append), so a trailing unparseable line
* is DROPPED rather than throwing — the crash costs at most the in-flight event (design §prior-art).
*/
function parseSession(text) {
const lines = String(text == null ? '' : text).split('\n');
let meta = null;
const events = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) { continue; } // blank (incl. the trailing newline's empty tail)
let obj;
try { obj = JSON.parse(line); }
catch { if (i === lines.length - 1) { break; } else { continue; } } // torn last line → stop; a mid-file junk line → skip
if (obj && obj.kind === 'meta' && !meta) { meta = obj; }
else { events.push(obj); }
}
return { meta, events };
}
// ── derive an index entry from a parsed session (pure — the heart of the cache) ──────────────────
function truncate(s, n) { const t = String(s == null ? '' : s); return t.length > n ? t.slice(0, n - 1) + '…' : t; }
/**
* Fold a parsed session into the compact entry the History list reads. PURE and total — every field
* degrades gracefully so a partial/old-schema session still yields a listable row (never throws).
*/
function deriveEntry(meta, events) {
const evs = Array.isArray(events) ? events : [];
const id = meta && meta.id ? String(meta.id) : null;
const createdAt = meta && meta.createdAt ? meta.createdAt : (evs[0] && evs[0].t) || null;
let title = null, updatedAt = createdAt, model = null, state = 'active', lifecycle = 'active', pinned = false;
let turns = 0, firstUser = null;
const editCounts = new Map(); // path → times edited (order-preserving via Map)
const spark = [];
for (const e of evs) {
if (!e || typeof e !== 'object') { continue; }
if (e.t) { updatedAt = e.t; }
switch (e.kind) {
case 'user': turns++; if (firstUser == null && e.content != null) { firstUser = String(e.content); } break;
case 'assistant': case 'agent': if (e.model) { model = e.model; } break;
case 'title': if (e.title != null) { title = String(e.title); } break;
case 'end': if (e.state) { state = String(e.state); } break;
case 'label':
if (e.lifecycle) { lifecycle = String(e.lifecycle); }
if (typeof e.pinned === 'boolean') { pinned = e.pinned; }
break;
}
// files edited + the activity sparkline come from agent turns (explicit `edits` + `tools` count)
if (e.kind === 'agent' || e.kind === 'edit') {
const paths = e.kind === 'edit' ? (e.path ? [e.path] : []) : (Array.isArray(e.edits) ? e.edits.map((x) => x && x.path).filter(Boolean) : []);
for (const p of paths) { editCounts.set(p, (editCounts.get(p) || 0) + 1); }
}
if (e.kind === 'agent') { spark.push(Number.isFinite(e.tools) ? e.tools : (Array.isArray(e.edits) ? e.edits.length : 0)); }
}
if (title == null) { title = (meta && meta.title != null) ? String(meta.title) : (firstUser != null ? truncate(firstUser, 56) : null); }
const filesEdited = [...editCounts.entries()].sort((a, b) => b[1] - a[1]).map(([p]) => p);
return { id, title, createdAt, updatedAt, turns, model, state, lifecycle, pinned,
filesEdited, spark, preview: firstUser != null ? truncate(firstUser, PREVIEW_MAX) : null };
}
// ── writing (append-only) ────────────────────────────────────────────────────────────────────────
/** Create a session: its directory + the meta birth line. Idempotent-ish (a re-create just rewrites meta). */
function createSession(root, slug, id, projectPath, createdAtIso, title, forkedFrom) {
fs.mkdirSync(projectDir(root, slug), { recursive: true });
fs.writeFileSync(sessionFile(root, slug, id), encodeEvent(metaLine(id, projectPath, createdAtIso, title, forkedFrom)));
return sessionFile(root, slug, id);
}
/** Append ONE event line. The only write on the hot (per-turn) path — a tiny, crash-safe append. */
function appendEvent(file, event) { fs.appendFileSync(file, encodeEvent(event)); }
// ── reading ONE session (the switch path — exactly one file) ─────────────────────────────────────
/** Read + parse a single session file. This is what `resume`/switch calls: one file, size-bounded. */
function readSession(file) { return parseSession(fs.readFileSync(file, 'utf8')); }
// ── the index (cache; rebuildable by scanning) ───────────────────────────────────────────────────
function listSessionFiles(root, slug) {
let names;
try { names = fs.readdirSync(projectDir(root, slug)); }
catch { return []; } // no project dir yet → no sessions
return names.filter((n) => n.endsWith('.jsonl')).map((n) => path.join(root, slug, n));
}
/** Sort newest-first for the default History order. */
function sortEntries(entries) {
return entries.slice().sort((a, b) => String(b.updatedAt || b.createdAt || '').localeCompare(String(a.updatedAt || a.createdAt || '')));
}
/**
* The SOURCE OF TRUTH: derive every entry by reading the files. O(corpus) and only paid when the cache is
* absent or corrupt — never on a normal list. This is what makes the index safe to treat as a throwaway.
*/
function scanProject(root, slug) {
const entries = [];
for (const file of listSessionFiles(root, slug)) {
try { const { meta, events } = readSession(file); const e = deriveEntry(meta, events); if (e.id) { entries.push(e); } }
catch { /* a single unreadable file must not sink the scan */ }
}
return sortEntries(entries);
}
/**
* Load the index CACHE for a project. Reads index.json and validates it; on ANY problem — missing,
* malformed, wrong/newer schema, not an array — it silently REBUILDS by scanning and reports `rebuilt`,
* so a corrupt index can never hide sessions that still exist on disk. Returns { v, entries, rebuilt }.
*/
function loadIndex(root, slug) {
try {
const raw = fs.readFileSync(indexFile(root, slug), 'utf8');
const obj = JSON.parse(raw);
if (obj && obj.v === SCHEMA_V && Array.isArray(obj.entries)) {
return { v: SCHEMA_V, entries: sortEntries(obj.entries), rebuilt: false };
}
} catch { /* fall through to rebuild */ }
return { v: SCHEMA_V, entries: scanProject(root, slug), rebuilt: true };
}
/** Persist the index atomically (tmp + rename) so a crash never leaves a half-written cache. */
function writeIndex(root, slug, entries) {
fs.mkdirSync(projectDir(root, slug), { recursive: true });
const tmp = indexFile(root, slug) + '.' + process.pid + '.tmp';
fs.writeFileSync(tmp, JSON.stringify({ v: SCHEMA_V, entries: sortEntries(entries) }));
fs.renameSync(tmp, indexFile(root, slug));
}
/** Incrementally replace-or-insert one entry (by id) — the per-seal update, no full rescan. */
function upsertEntry(entries, entry) {
const out = (Array.isArray(entries) ? entries : []).filter((e) => e && e.id !== entry.id);
out.push(entry);
return sortEntries(out);
}
// ── FAST RETRIEVAL (pure, in-memory — no disk) ───────────────────────────────────────────────────
/**
* Look up one entry by id in an ALREADY-LOADED index. Pure: it takes the in-memory `index` and never
* touches disk, so retrieving a 25-day-old (or any-age) session's metadata is a memory op, independent
* of the corpus. For very large corpora a Map cache is a drop-in; a linear find over a few thousand
* entries is already sub-millisecond.
*/
function getEntry(index, id) {
const entries = index && Array.isArray(index.entries) ? index.entries : [];
for (const e of entries) { if (e && e.id === id) { return e; } }
return null;
}
/** The sorted list view (pure). Optional lifecycle filter (e.g. 'active' hides archived). */
function listEntries(index, opts) {
const o = opts || {};
let entries = (index && Array.isArray(index.entries) ? index.entries : []).slice();
if (o.lifecycle) { entries = entries.filter((e) => (e.lifecycle || 'active') === o.lifecycle); }
return sortEntries(entries);
}
module.exports = {
SCHEMA_V,
projectSlug, newSessionId, projectDir, sessionFile, indexFile,
metaLine, encodeEvent, parseSession, deriveEntry,
createSession, appendEvent, readSession,
listSessionFiles, scanProject, loadIndex, writeIndex, upsertEntry,
getEntry, listEntries
};