|
| 1 | +# Chat Sessions — design |
| 2 | + |
| 3 | +**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 |
| 4 | + |
| 5 | +The problem, in one sentence: **"New Chat" is destructive.** `conversation` and |
| 6 | +`agentMessages` are module-level in-memory arrays; `newChat()` wipes them, and so does a |
| 7 | +window reload or a crash. A user who starts a new chat — or whose editor restarts — |
| 8 | +loses the old one forever. Every serious AI editor now treats a chat as a *session*: |
| 9 | +saved automatically, listed, resumable. |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## 1. Goals & non-goals |
| 14 | + |
| 15 | +**Goals** |
| 16 | + |
| 17 | +1. Every chat is **saved automatically, locally**, without the user doing anything. |
| 18 | +2. "New Chat" starts a fresh session; the previous one appears in a **History** list. |
| 19 | +3. Any listed session can be **resumed** — even one that no longer fits the model's |
| 20 | + context window (that is where summarization enters, and *only* there). |
| 21 | +4. Sessions survive window reloads, crashes, editor updates, and **folder renames**. |
| 22 | +5. Everything is inspectable plain text on the user's disk — the hackable ethos, and |
| 23 | + the BYOK privacy promise: nothing leaves the machine. |
| 24 | + |
| 25 | +**Non-goals (for this milestone)** |
| 26 | + |
| 27 | +- Cross-device sync (that is M9 / LevelCode Sync territory; this design feeds it). |
| 28 | +- Sharing sessions (the LevelLinks concept builds on this file format later). |
| 29 | +- Restoring *files* to a session's point in time — per-turn checkpoints already own |
| 30 | + file restore and remain per-live-session. |
| 31 | + |
| 32 | +--- |
| 33 | + |
| 34 | +## 2. Where we are today (grounded in the code) |
| 35 | + |
| 36 | +- `extension.js`: `let conversation = []` (chat) and `agentMessages` (agent loop) are |
| 37 | + in-memory only. `newChat()` resets both, kills background commands and MCP servers, |
| 38 | + finalizes pending reviews, and posts `reset` to the webview. Nothing is written |
| 39 | + anywhere. |
| 40 | +- **Compaction already exists**: `compactAgentMemory()` summarizes the bulky head of a |
| 41 | + live session into a briefing using `COMPACT_SYSTEM` / `COMPACT_INSTRUCTIONS`, cutting |
| 42 | + only at a user-message boundary so tool_use/tool_result pairs are never orphaned. |
| 43 | + This is exactly the machinery session resume needs — it just needs to run against a |
| 44 | + *loaded* transcript, not only the live one. |
| 45 | +- Keep/Undo pending reviews persist in `globalState` (`levelcode.ai.pendingReviews`) |
| 46 | + and already survive reloads, keyed by file URI — orthogonal to sessions, but a |
| 47 | + session must *reference* its pending-review era so resume tells the truth. |
| 48 | +- The webview (`media/chat.html`) renders from posted messages; it can replay a |
| 49 | + transcript it is fed. No storage of its own. |
| 50 | + |
| 51 | +## 3. Prior art — what the three incumbents actually do |
| 52 | + |
| 53 | +### VS Code Copilot Chat |
| 54 | +- **Mechanics:** one JSON file per session under |
| 55 | + `…/User/workspaceStorage/<workspace-hash>/chatSessions/`, plus a session index in |
| 56 | + `state.vscdb`. History quick-pick + export/import commands. |
| 57 | +- **What works:** plain JSON per session; per-workspace scoping; auto titles. |
| 58 | +- **What fails — loudly, in public issues:** the workspace **hash** is derived from the |
| 59 | + folder URI, so renaming/moving a folder, saving an untitled workspace, or reopening |
| 60 | + in a dev container orphans every session (microsoft/vscode #285059, #301793, |
| 61 | + #305818); a corrupted `state.vscdb` index hides sessions that still exist on disk |
| 62 | + (community repair tools exist for exactly this). **Lesson: never make a URI-derived |
| 63 | + hash the only key, and never let an index be load-bearing.** |
| 64 | + |
| 65 | +### Cursor |
| 66 | +- **Mechanics:** everything in SQLite (`state.vscdb`) — global `cursorDiskKV` table |
| 67 | + with `composerData:<id>` (session metadata) and `bubbleId:<composerId>:<bubbleId>` |
| 68 | + (每 message), workspace DBs for the rest. History panel, checkpoints, long-context |
| 69 | + condensation. |
| 70 | +- **What works:** robust incremental writes (one row per bubble); global storage means |
| 71 | + sessions survive workspace-identity changes; snappy history UI. |
| 72 | +- **What fails:** the store is **opaque** — users need third-party exporter tools to |
| 73 | + read their own history, WAL/corruption incidents lock people out, and nothing is |
| 74 | + greppable. **Lesson: an AI editor's memory should not need a reverse-engineered |
| 75 | + schema to read.** |
| 76 | + |
| 77 | +### Claude Code |
| 78 | +- **Mechanics:** one **JSONL file per session** under |
| 79 | + `~/.claude/projects/<project-path-slug>/<session-id>.jsonl` — append-only events, |
| 80 | + one JSON object per line. `--continue` resumes the last session, `--resume` shows a |
| 81 | + picker; sessions are titled; when a resumed/long session approaches the context |
| 82 | + limit it is **auto-compacted**: the head is summarized, recent turns stay verbatim. |
| 83 | +- **What works — and why this is the model to copy:** append-only = crash-safe by |
| 84 | + construction (a crash costs at most the line being written); the project *path slug* |
| 85 | + is human-readable and survives editor reinstalls; plain JSONL means `grep`, `jq`, |
| 86 | + and third-party tooling work day one; storage is verbatim and lossless while |
| 87 | + summarization is reserved for the one place it is needed — fitting an old |
| 88 | + conversation back into a finite context window. |
| 89 | + |
| 90 | +### The synthesis LevelCode adopts |
| 91 | + |
| 92 | +| Decision | Choice | Because | |
| 93 | +|---|---|---| |
| 94 | +| Storage format | Append-only JSONL, one file per session | Crash-safe, greppable, hackable (Claude Code); no opaque DB (anti-Cursor) | |
| 95 | +| Location | `~/.levelcode/sessions/<project-slug>/` | Survives reinstalls & workspace-identity churn (anti-Copilot); `dataFolderName` is already `.levelcode` | |
| 96 | +| 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" | |
| 97 | +| Index | `index.json` per project, **rebuildable by scanning** | An index may cache, it must never be load-bearing (anti-Copilot #vscdb-corruption) | |
| 98 | +| What gets summarized | **Nothing at rest.** Transcripts are stored verbatim | Disk is free; tokens are not. Summarizing to store pays money to lose information | |
| 99 | +| 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 | |
| 100 | +| Privacy | Local files only; never uploaded | The BYOK promise; M9 sync may later offer opt-in encrypted sync of this same format | |
| 101 | + |
| 102 | +## 4. Data model |
| 103 | + |
| 104 | +``` |
| 105 | +~/.levelcode/sessions/ |
| 106 | + <project-slug>/ e.g. -Users-ada-code-thin-ly (path, slashes → dashes) |
| 107 | + index.json cache: [{id, title, createdAt, updatedAt, turns, model, preview}] |
| 108 | + 2026-07-28T09-12-33-8f3k.jsonl |
| 109 | + 2026-07-28T14-02-10-p9q2.jsonl |
| 110 | +``` |
| 111 | + |
| 112 | +**Session file = one meta line + append-only events** (schema-versioned): |
| 113 | + |
| 114 | +```jsonl |
| 115 | +{"kind":"meta","v":1,"id":"2026-07-28T09-12-33-8f3k","project":"/Users/ada/code/thin.ly","createdAt":"…","title":null} |
| 116 | +{"kind":"user","t":"…","content":"Add idempotency to RefundService…","contextFiles":["app/services/refund.rb"]} |
| 117 | +{"kind":"assistant","t":"…","content":[…provider content blocks, verbatim…]} |
| 118 | +{"kind":"agent","t":"…","messages":[…the agentMessages delta for this turn…]} |
| 119 | +{"kind":"event","t":"…","type":"checkpoint","n":3} |
| 120 | +{"kind":"title","t":"…","title":"Idempotent refunds via Redis keys"} |
| 121 | +{"kind":"compact","t":"…","briefing":"…","coversThrough":41} |
| 122 | +``` |
| 123 | + |
| 124 | +Rules: |
| 125 | + |
| 126 | +- **Verbatim provider shapes.** `conversation` and `agentMessages` entries are stored |
| 127 | + as-is (the same objects sent to the provider), so resume rebuilds byte-identical |
| 128 | + arrays — no lossy re-parsing. Schema `v` guards future migrations. |
| 129 | +- **Append-only.** One `fs.appendFile` per turn (debounced 500ms), fsync'd. Never |
| 130 | + rewrite the file except `compact` events, which are *also appended* — a compaction |
| 131 | + is an event in the history, not a rewrite of it. |
| 132 | +- **The meta line is written at session birth**, so even a one-message crash leaves a |
| 133 | + listable session. |
| 134 | +- **Caps:** a session file is soft-capped (default 20 MB — huge tool outputs are |
| 135 | + already truncated upstream); beyond it, oldest `agent` tool-result payloads are |
| 136 | + elided on load (they are never resent to the model anyway once compacted). |
| 137 | +- `index.json` is written atomically (tmp + rename) after each turn; on any read |
| 138 | + error or mismatch it is **rebuilt by scanning the directory** — self-healing. |
| 139 | + |
| 140 | +## 5. Lifecycle |
| 141 | + |
| 142 | +### Autosave (the default state of the world) |
| 143 | +Every turn appends its events. There is no Save button and no dirty state. The live |
| 144 | +session id lives in `workspaceState` so a window reload re-opens the same session |
| 145 | +(webview replays the transcript; background commands/MCP are *not* resurrected — a |
| 146 | +`event:"reload"` line records the discontinuity honestly). |
| 147 | + |
| 148 | +### New Chat |
| 149 | +`newChat()` keeps its reaping semantics (kill commands/MCP, finalize reviews, drop |
| 150 | +checkpoints) but first **seals the current session** (final index update, kick off |
| 151 | +async title generation if still untitled) and then opens a fresh file. Nothing is |
| 152 | +lost — the old session is one click away in History. |
| 153 | + |
| 154 | +### Titles |
| 155 | +On seal (or after the 2nd user turn, whichever first): if untitled, generate one |
| 156 | +asynchronously with the **fast/cheap lane** (the same per-provider fast model routing |
| 157 | +autocomplete uses; ≤ 200 tokens: "6 words, imperative, no punctuation"). Failure or |
| 158 | +BYOK-frugal mode falls back to the first user message, truncated. Titles are events, |
| 159 | +so retitling is append-not-rewrite; a manual **Rename** writes the same event. |
| 160 | + |
| 161 | +### Resume — the three-tier rule |
| 162 | +Let `T` = stored transcript tokens (estimated as today, chars/4), `W` = model context |
| 163 | +window, `B` = the context budget resume may spend (default 40% of `W`): |
| 164 | + |
| 165 | +1. **T ≤ B — verbatim resume.** Arrays rebuilt exactly; the model sees the same |
| 166 | + conversation it left. No summarization, no cost. |
| 167 | +2. **T > B, has prior `compact` event — incremental.** Load the last briefing + turns |
| 168 | + after `coversThrough`; if still over budget, fall through to (3) on the remainder. |
| 169 | +3. **T > B — compact-on-resume.** Run `compactAgentMemory()`'s exact machinery over |
| 170 | + the head (cut at a user-message boundary, never orphaning tool pairs), keep the |
| 171 | + last N turns verbatim, append the `compact` event, resume on briefing + tail. One |
| 172 | + visible line in the chat says so: *"Resumed from summary — full transcript in |
| 173 | + History."* The full verbatim history remains on disk and in the History view; |
| 174 | + only the model's working context is summarized. **This is the honest version of |
| 175 | + "save by summarizing": summarize to *fit*, never to *store*.* |
| 176 | + |
| 177 | +Cost note: tier 3 costs one summarization call on the user's key/plan — the UI says |
| 178 | +so before running it when the estimated input exceeds a threshold (default 50k |
| 179 | +tokens), with "Resume from summary" / "Start fresh instead" choices. |
| 180 | + |
| 181 | +### Pending work at switch time |
| 182 | +- **Keep/Undo reviews:** already survive independently; the session records which |
| 183 | + eras it owns. Switching sessions with pending reviews keeps today's `newChat()` |
| 184 | + behavior (finalize = keep files, drop UI) but says so in one status line. |
| 185 | +- **Running agent:** switching aborts it (as `newChat()` does today); the abort is |
| 186 | + recorded as an event so a resumed session shows *"(run interrupted)"* rather than |
| 187 | + a silent cliff. |
| 188 | +- **Background commands / MCP servers:** reaped, recorded as events. Never |
| 189 | + auto-restarted on resume — the resumed chat *tells* the model what died via the |
| 190 | + reload/interrupt events, so the agent re-establishes state deliberately. |
| 191 | + |
| 192 | +### Retention |
| 193 | +Defaults: keep everything (it is the user's disk, and text is small). Settings for |
| 194 | +max sessions per project and max age; deletion from the History UI is per-session |
| 195 | +(move to OS trash, not unlink, for one level of oops-protection). |
| 196 | + |
| 197 | +## 6. UI (classic, minimal) |
| 198 | + |
| 199 | +- **History** button in the chat header (clock icon) → in-webview list, newest first: |
| 200 | + title · relative time · turn count · model. Click = resume; hover actions: |
| 201 | + Rename, Delete, "Copy as Markdown". |
| 202 | +- **New Chat** unchanged in placement; after it, a one-line toast: *"Previous chat |
| 203 | + saved to History."* — teaches the feature exactly once (dismiss = never again). |
| 204 | +- Command palette: `LevelCode: New Chat`, `LevelCode: Chat History`, |
| 205 | + `LevelCode: Resume Last Chat` (the `--continue` analog), `LevelCode: Export Chat |
| 206 | + as Markdown`. |
| 207 | +- Multi-window: two windows on the same project each get their own live session; |
| 208 | + the History list shows both. A session file is owned by one window at a time |
| 209 | + (lockfile beside it, stale-lock timeout 30s) — the second window resuming the |
| 210 | + same session gets a read-only "open a copy?" prompt. No merge semantics. |
| 211 | + |
| 212 | +## 7. Settings |
| 213 | + |
| 214 | +| Setting | Default | Meaning | |
| 215 | +|---|---|---| |
| 216 | +| `levelcode.ai.sessions.enabled` | `true` | master switch (off = today's ephemeral behavior) | |
| 217 | +| `levelcode.ai.sessions.dir` | `~/.levelcode/sessions` | hackability: point it anywhere (a dotfiles repo, an encrypted volume) | |
| 218 | +| `levelcode.ai.sessions.autoTitle` | `true` | cheap-lane titles; off = first-message truncation | |
| 219 | +| `levelcode.ai.sessions.resumeBudgetPct` | `40` | share of the context window resume may fill | |
| 220 | +| `levelcode.ai.sessions.confirmCompactOverTokens` | `50000` | ask before a paid compact-on-resume | |
| 221 | +| `levelcode.ai.sessions.maxPerProject` / `maxAgeDays` | `0` (unlimited) | retention | |
| 222 | + |
| 223 | +## 8. Implementation phases |
| 224 | + |
| 225 | +**Phase 1 — persistence spine** *(M)* — ✅ **shipped.** `sessionStore.js` (slug, ids, append-only JSONL, the rebuildable index) + `sessionEvents.js` (the event kinds and `deriveEntry`). |
| 226 | +`sessionStore.js` (new, pure Node — unit-testable like `update.js`): slugging, |
| 227 | +meta/append/read/scan/index, atomic index writes, lock files, schema versioning. |
| 228 | +Wire `conversation`/`agentMessages`/checkpoint boundaries into per-turn appends; |
| 229 | +live-session id in `workspaceState`; reload replays. *Exit test: kill -9 the editor |
| 230 | +mid-turn; reopen; the session lists and resumes with at most the in-flight turn |
| 231 | +missing.* |
| 232 | + |
| 233 | +**Phase 2 — History UI + New Chat sealing** *(M)* — ✅ **shipped.** The Sessions panel, time buckets, the card, and seal-on-New-Chat. |
| 234 | +Webview list + actions, toast, palette commands, rename/delete/export. `newChat()` |
| 235 | +seals + rotates. *Exit test: three chats in a row; all three listed, titled (fallback |
| 236 | +titles), resumable; delete works; folder rename in Finder → sessions still listed |
| 237 | +(under the stored real path) and resumable.* |
| 238 | + |
| 239 | +**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. |
| 240 | +Three-tier resume with `compactAgentMemory` reuse; async cheap-lane titles; the |
| 241 | +paid-compact confirmation; interrupted-run/reload honesty lines. *Exit test: a 200k- |
| 242 | +token stored session resumes into a 128k-window model via tier 3, the briefing chat |
| 243 | +line appears, and the next agent turn acts on state only present in the summarized |
| 244 | +head (proving the briefing carried it).* |
| 245 | + |
| 246 | +**Phase 4 — polish** *(S)* — ⚠️ **partial.** Shipped: pins, archive + 30-day auto-archive, Undo behind Done/Delete, the sparkline, files-touched chips, state pills, empty states, and Copy-as-Markdown export. **Not built:** in-panel search and the `⌃⌘P` switcher (that is E2 in the experience doc — see its §10), and the seal-to-history fly / resume-morph animations (E4). |
| 247 | +Markdown export, retention settings, multi-window locks, `sessions.dir` relocation, |
| 248 | +docs page + walkthrough entry. *Exit test: EXIT-TEST.md additions all green.* |
| 249 | + |
| 250 | +**Deliberately later:** M9 encrypted sync of `~/.levelcode/sessions` (same format — |
| 251 | +this design is the sync payload); LevelLinks "share a run" (same format — a session |
| 252 | +file is the replay source); cross-session memory ("what did we decide last week?" — |
| 253 | +search over JSONL is trivially greppable, an agent tool over it is a natural S-size |
| 254 | +follow-up). |
| 255 | + |
| 256 | +## 9. Test plan (beyond exit tests) |
| 257 | + |
| 258 | +- `test/sessionStore.test.js`: slug edge cases (unicode paths, root, UNC), append/ |
| 259 | + scan roundtrip, index self-heal from deliberate corruption, lock stealing after |
| 260 | + timeout, schema `v` forward-refusal, cap-elision determinism. |
| 261 | +- Resume-tier unit tests with a fake tokenizer: boundary math at exactly `B`, |
| 262 | + compact-event incremental path, tool-pair integrity across the cut (reuses the |
| 263 | + existing boundary-picking function — test it directly). |
| 264 | +- Manual matrix in EXIT-TEST.md: crash, reload, rename-folder, two-window, BYOK vs |
| 265 | + gateway title/compact costs surfaced correctly. |
| 266 | + |
| 267 | +## 10. Risks, honestly |
| 268 | + |
| 269 | +- **Transcripts contain code.** They already transit the model; at rest they are |
| 270 | + plain files in the user's home directory, same trust class as the code itself. |
| 271 | + Anything that later *shares* a session (LevelLinks) must scrub — that is that |
| 272 | + feature's burden, recorded here so it is not forgotten. |
| 273 | +- **Compact-on-resume costs money** and briefings lose nuance. Mitigations: verbatim |
| 274 | + tier preferred, explicit confirmation over the threshold, full transcript always |
| 275 | + kept, briefing visibly marked in the chat. |
| 276 | +- **Two windows, one project** is the only real concurrency surface; the lockfile + |
| 277 | + read-only fallback keeps it boring. |
| 278 | +- **Index drift** is a solved non-risk by construction: scanning is the source of |
| 279 | + truth, the index is a cache. |
0 commit comments