Skip to content

Commit 37cd2ca

Browse files
authored
Merge pull request #68 from levelcodeai/docs/status-truth
docs: state what is actually built, and what is not
2 parents c8d7a15 + 7f35d3f commit 37cd2ca

3 files changed

Lines changed: 285 additions & 6 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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

Comments
 (0)