Status: shipped (Phases 1–3 complete; Phase 4 partial — see §8) · Scope: extensions/levelcode-ai (chat + agent) · Depends on: nothing new; reuses the M5 compaction machinery
The problem, in one sentence: "New Chat" is destructive. conversation and
agentMessages are module-level in-memory arrays; newChat() wipes them, and so does a
window reload or a crash. A user who starts a new chat — or whose editor restarts —
loses the old one forever. Every serious AI editor now treats a chat as a session:
saved automatically, listed, resumable.
Goals
- Every chat is saved automatically, locally, without the user doing anything.
- "New Chat" starts a fresh session; the previous one appears in a History list.
- Any listed session can be resumed — even one that no longer fits the model's context window (that is where summarization enters, and only there).
- Sessions survive window reloads, crashes, editor updates, and folder renames.
- Everything is inspectable plain text on the user's disk — the hackable ethos, and the BYOK privacy promise: nothing leaves the machine.
Non-goals (for this milestone)
- Cross-device sync (that is M9 / LevelCode Sync territory; this design feeds it).
- Sharing sessions (the LevelLinks concept builds on this file format later).
- Restoring files to a session's point in time — per-turn checkpoints already own file restore and remain per-live-session.
extension.js:let conversation = [](chat) andagentMessages(agent loop) are in-memory only.newChat()resets both, kills background commands and MCP servers, finalizes pending reviews, and postsresetto the webview. Nothing is written anywhere.- Compaction already exists:
compactAgentMemory()summarizes the bulky head of a live session into a briefing usingCOMPACT_SYSTEM/COMPACT_INSTRUCTIONS, cutting only at a user-message boundary so tool_use/tool_result pairs are never orphaned. This is exactly the machinery session resume needs — it just needs to run against a loaded transcript, not only the live one. - Keep/Undo pending reviews persist in
globalState(levelcode.ai.pendingReviews) and already survive reloads, keyed by file URI — orthogonal to sessions, but a session must reference its pending-review era so resume tells the truth. - The webview (
media/chat.html) renders from posted messages; it can replay a transcript it is fed. No storage of its own.
- Mechanics: one JSON file per session under
…/User/workspaceStorage/<workspace-hash>/chatSessions/, plus a session index instate.vscdb. History quick-pick + export/import commands. - What works: plain JSON per session; per-workspace scoping; auto titles.
- What fails — loudly, in public issues: the workspace hash is derived from the
folder URI, so renaming/moving a folder, saving an untitled workspace, or reopening
in a dev container orphans every session (microsoft/vscode #285059, #301793,
#305818); a corrupted
state.vscdbindex hides sessions that still exist on disk (community repair tools exist for exactly this). Lesson: never make a URI-derived hash the only key, and never let an index be load-bearing.
- Mechanics: everything in SQLite (
state.vscdb) — globalcursorDiskKVtable withcomposerData:<id>(session metadata) andbubbleId:<composerId>:<bubbleId>(每 message), workspace DBs for the rest. History panel, checkpoints, long-context condensation. - What works: robust incremental writes (one row per bubble); global storage means sessions survive workspace-identity changes; snappy history UI.
- What fails: the store is opaque — users need third-party exporter tools to read their own history, WAL/corruption incidents lock people out, and nothing is greppable. Lesson: an AI editor's memory should not need a reverse-engineered schema to read.
- Mechanics: one JSONL file per session under
~/.claude/projects/<project-path-slug>/<session-id>.jsonl— append-only events, one JSON object per line.--continueresumes the last session,--resumeshows a picker; sessions are titled; when a resumed/long session approaches the context limit it is auto-compacted: the head is summarized, recent turns stay verbatim. - What works — and why this is the model to copy: append-only = crash-safe by
construction (a crash costs at most the line being written); the project path slug
is human-readable and survives editor reinstalls; plain JSONL means
grep,jq, and third-party tooling work day one; storage is verbatim and lossless while summarization is reserved for the one place it is needed — fitting an old conversation back into a finite context window.
| Decision | Choice | Because |
|---|---|---|
| Storage format | Append-only JSONL, one file per session | Crash-safe, greppable, hackable (Claude Code); no opaque DB (anti-Cursor) |
| Location | ~/.levelcode/sessions/<project-slug>/ |
Survives reinstalls & workspace-identity churn (anti-Copilot); dataFolderName is already .levelcode |
| Keying | Human-readable project path slug plus the real path stored inside each file | A renamed folder degrades to "listed under old name", never to "lost" |
| Index | index.json per project, rebuildable by scanning |
An index may cache, it must never be load-bearing (anti-Copilot #vscdb-corruption) |
| What gets summarized | Nothing at rest. Transcripts are stored verbatim | Disk is free; tokens are not. Summarizing to store pays money to lose information |
| Where summarization IS used | (a) resume when the transcript exceeds the context budget — reuse compactAgentMemory; (b) async title generation |
The user's instinct ("summarize with an LLM") lands here, not in storage |
| Privacy | Local files only; never uploaded | The BYOK promise; M9 sync may later offer opt-in encrypted sync of this same format |
~/.levelcode/sessions/
<project-slug>/ e.g. -Users-ada-code-thin-ly (path, slashes → dashes)
index.json cache: [{id, title, createdAt, updatedAt, turns, model, preview}]
2026-07-28T09-12-33-8f3k.jsonl
2026-07-28T14-02-10-p9q2.jsonl
Session file = one meta line + append-only events (schema-versioned):
{"kind":"meta","v":1,"id":"2026-07-28T09-12-33-8f3k","project":"/Users/ada/code/thin.ly","createdAt":"…","title":null}
{"kind":"user","t":"…","content":"Add idempotency to RefundService…","contextFiles":["app/services/refund.rb"]}
{"kind":"assistant","t":"…","content":[…provider content blocks, verbatim…]}
{"kind":"agent","t":"…","messages":[…the agentMessages delta for this turn…]}
{"kind":"event","t":"…","type":"checkpoint","n":3}
{"kind":"title","t":"…","title":"Idempotent refunds via Redis keys"}
{"kind":"compact","t":"…","briefing":"…","coversThrough":41}Rules:
- Verbatim provider shapes.
conversationandagentMessagesentries are stored as-is (the same objects sent to the provider), so resume rebuilds byte-identical arrays — no lossy re-parsing. Schemavguards future migrations. - Append-only. One
fs.appendFileper turn (debounced 500ms), fsync'd. Never rewrite the file exceptcompactevents, which are also appended — a compaction is an event in the history, not a rewrite of it. - The meta line is written at session birth, so even a one-message crash leaves a listable session.
- Caps: a session file is soft-capped (default 20 MB — huge tool outputs are
already truncated upstream); beyond it, oldest
agenttool-result payloads are elided on load (they are never resent to the model anyway once compacted). index.jsonis written atomically (tmp + rename) after each turn; on any read error or mismatch it is rebuilt by scanning the directory — self-healing.
Every turn appends its events. There is no Save button and no dirty state. The live
session id lives in workspaceState so a window reload re-opens the same session
(webview replays the transcript; background commands/MCP are not resurrected — a
event:"reload" line records the discontinuity honestly).
newChat() keeps its reaping semantics (kill commands/MCP, finalize reviews, drop
checkpoints) but first seals the current session (final index update, kick off
async title generation if still untitled) and then opens a fresh file. Nothing is
lost — the old session is one click away in History.
On seal (or after the 2nd user turn, whichever first): if untitled, generate one asynchronously with the fast/cheap lane (the same per-provider fast model routing autocomplete uses; ≤ 200 tokens: "6 words, imperative, no punctuation"). Failure or BYOK-frugal mode falls back to the first user message, truncated. Titles are events, so retitling is append-not-rewrite; a manual Rename writes the same event.
Let T = stored transcript tokens (estimated as today, chars/4), W = model context
window, B = the context budget resume may spend (default 40% of W):
- T ≤ B — verbatim resume. Arrays rebuilt exactly; the model sees the same conversation it left. No summarization, no cost.
- T > B, has prior
compactevent — incremental. Load the last briefing + turns aftercoversThrough; if still over budget, fall through to (3) on the remainder. - T > B — compact-on-resume. Run
compactAgentMemory()'s exact machinery over the head (cut at a user-message boundary, never orphaning tool pairs), keep the last N turns verbatim, append thecompactevent, resume on briefing + tail. One visible line in the chat says so: "Resumed from summary — full transcript in History." The full verbatim history remains on disk and in the History view; only the model's working context is summarized. *This is the honest version of "save by summarizing": summarize to fit, never to store.
Cost note: tier 3 costs one summarization call on the user's key/plan — the UI says so before running it when the estimated input exceeds a threshold (default 50k tokens), with "Resume from summary" / "Start fresh instead" choices.
- Keep/Undo reviews: already survive independently; the session records which
eras it owns. Switching sessions with pending reviews keeps today's
newChat()behavior (finalize = keep files, drop UI) but says so in one status line. - Running agent: switching aborts it (as
newChat()does today); the abort is recorded as an event so a resumed session shows "(run interrupted)" rather than a silent cliff. - Background commands / MCP servers: reaped, recorded as events. Never auto-restarted on resume — the resumed chat tells the model what died via the reload/interrupt events, so the agent re-establishes state deliberately.
Defaults: keep everything (it is the user's disk, and text is small). Settings for max sessions per project and max age; deletion from the History UI is per-session (move to OS trash, not unlink, for one level of oops-protection).
- History button in the chat header (clock icon) → in-webview list, newest first: title · relative time · turn count · model. Click = resume; hover actions: Rename, Delete, "Copy as Markdown".
- New Chat unchanged in placement; after it, a one-line toast: "Previous chat saved to History." — teaches the feature exactly once (dismiss = never again).
- Command palette:
LevelCode: New Chat,LevelCode: Chat History,LevelCode: Resume Last Chat(the--continueanalog),LevelCode: Export Chat as Markdown. - Multi-window: two windows on the same project each get their own live session; the History list shows both. A session file is owned by one window at a time (lockfile beside it, stale-lock timeout 30s) — the second window resuming the same session gets a read-only "open a copy?" prompt. No merge semantics.
| Setting | Default | Meaning |
|---|---|---|
levelcode.ai.sessions.enabled |
true |
master switch (off = today's ephemeral behavior) |
levelcode.ai.sessions.dir |
~/.levelcode/sessions |
hackability: point it anywhere (a dotfiles repo, an encrypted volume) |
levelcode.ai.sessions.autoTitle |
true |
cheap-lane titles; off = first-message truncation |
levelcode.ai.sessions.resumeBudgetPct |
40 |
share of the context window resume may fill |
levelcode.ai.sessions.confirmCompactOverTokens |
50000 |
ask before a paid compact-on-resume |
levelcode.ai.sessions.maxPerProject / maxAgeDays |
0 (unlimited) |
retention |
Phase 1 — persistence spine (M) — ✅ shipped. sessionStore.js (slug, ids, append-only JSONL, the rebuildable index) + sessionEvents.js (the event kinds and deriveEntry).
sessionStore.js (new, pure Node — unit-testable like update.js): slugging,
meta/append/read/scan/index, atomic index writes, lock files, schema versioning.
Wire conversation/agentMessages/checkpoint boundaries into per-turn appends;
live-session id in workspaceState; reload replays. Exit test: kill -9 the editor
mid-turn; reopen; the session lists and resumes with at most the in-flight turn
missing.
Phase 2 — History UI + New Chat sealing (M) — ✅ shipped. The Sessions panel, time buckets, the card, and seal-on-New-Chat.
Webview list + actions, toast, palette commands, rename/delete/export. newChat()
seals + rotates. Exit test: three chats in a row; all three listed, titled (fallback
titles), resumable; delete works; folder rename in Finder → sessions still listed
(under the stored real path) and resumable.
Phase 3 — resume tiers + titles (M) — ✅ shipped. sessionResume.js plans the three tiers against the model's window and describeResume writes the honest note; titles derive, and rename is an append-only event.
Three-tier resume with compactAgentMemory reuse; async cheap-lane titles; the
paid-compact confirmation; interrupted-run/reload honesty lines. Exit test: a 200k-
token stored session resumes into a 128k-window model via tier 3, the briefing chat
line appears, and the next agent turn acts on state only present in the summarized
head (proving the briefing carried it).
Phase 4 — polish (S) — ⌃⌘P switcher (that is E2 in the experience doc — see its §10), and the seal-to-history fly / resume-morph animations (E4).
Markdown export, retention settings, multi-window locks, sessions.dir relocation,
docs page + walkthrough entry. Exit test: EXIT-TEST.md additions all green.
Deliberately later: M9 encrypted sync of ~/.levelcode/sessions (same format —
this design is the sync payload); LevelLinks "share a run" (same format — a session
file is the replay source); cross-session memory ("what did we decide last week?" —
search over JSONL is trivially greppable, an agent tool over it is a natural S-size
follow-up).
test/sessionStore.test.js: slug edge cases (unicode paths, root, UNC), append/ scan roundtrip, index self-heal from deliberate corruption, lock stealing after timeout, schemavforward-refusal, cap-elision determinism.- Resume-tier unit tests with a fake tokenizer: boundary math at exactly
B, compact-event incremental path, tool-pair integrity across the cut (reuses the existing boundary-picking function — test it directly). - Manual matrix in EXIT-TEST.md: crash, reload, rename-folder, two-window, BYOK vs gateway title/compact costs surfaced correctly.
- Transcripts contain code. They already transit the model; at rest they are plain files in the user's home directory, same trust class as the code itself. Anything that later shares a session (LevelLinks) must scrub — that is that feature's burden, recorded here so it is not forgotten.
- Compact-on-resume costs money and briefings lose nuance. Mitigations: verbatim tier preferred, explicit confirmation over the threshold, full transcript always kept, briefing visibly marked in the chat.
- Two windows, one project is the only real concurrency surface; the lockfile + read-only fallback keeps it boring.
- Index drift is a solved non-risk by construction: scanning is the source of truth, the index is a cache.