bots-garden/mini-mepublic Fork
⑂ fix/plan-mode
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP
🛟 Updated. 05db870 · on fix/plan-mode · k33g · 2d ago
persistent-sessions.md · 71 lines · 12.7 KBmarkdown
Blame HistoryOpen raw

Persistent sessions — explanation

What is this about?

A session is one conversation: the system prompt, the questions, the answers and the tool calls in between. Until 2026-09-16 it lived in memory and died with the process; an editor that restarted mm could only open a new thread, which is what Zed's "Loading or resuming sessions is not supported by this agent" said. Persistent sessions (sessions.enabled) write each conversation to a file and let both front ends get it back.

Why files, per project

Sessions live under the project, in .mm/sessions/, one JSON file each, rewritten whole after every turn. Three reasons. A conversation is about a project — its files, its commands — and belongs next to them, not in a home-wide database where projects mix. Files are inspectable: jq answers "what did the model read?" without a tool. And a whole-file rewrite is what compression needs: /compact replaces old turns by a summary, and the file must say the same as the memory, not the memory plus everything it used to hold.

The id is the creation time plus a random tail (20260916-101530-4f2a9c1e), so a listing sorts by time and two front ends never collide.

Why a start is a new session

When mm starts, it starts a new session and says how to get the old ones back, rather than resuming the latest on its own. This mirrors docker agent (cagent): its run command never resumes by itself — --session -1 or the TUI's /sessions browser do — and it is the safer default. The last conversation may be about something else; resuming it silently would hand the model a stale context, and the user would only notice when the answers went wrong. An explicit /resume costs one line and keeps the surprise out.

The terminal writes the file on the first question, not at start: a launch that asked nothing leaves no trace, and /sessions lists conversations, not launches.

Why session/load and a full replay

The Agent Client Protocol offers two ways back into a session. session/load is in the stable spec: the agent replays the conversation as session/update notifications and the editor rebuilds the thread from them. session/resume is the other: the agent only re-registers the session, and the editor keeps whatever it was showing. docker agent chose the second (loadSession: false, Resume capability, SQLite store in ~/.cagent/session.db). mini-me chose the first, because it was the stable one and because a replay puts the thread back even in an editor that kept nothing.

That choice still holds, but it is no longer exclusive. When it was made, session/list and session/resume were unstable extensions. The SDK mini-me uses (acp-go-sdk v0.13.5) now has them in its stable Agent interface, with no unstable marker — unlike session/fork and session/delete —, and Zed shows an agent's past threads only when the agent advertises sessionCapabilities.list. So since ticket 95 mini-me answers both: session/list gives the editor the project's stored conversations, titled by their first question, and session/resume re-registers one without replaying it, for an editor that still shows it. session/load and its replay are unchanged, for an editor that kept nothing. The list leaves out conversations without a question: every ACP thread writes its file at session/new, and a picker of empty threads helps nobody.

"Replay" means re-sending records. Each tool call comes back as a single, already-completed tool_call carrying the input the model gave and the output the tool returned at the time. Nothing is executed again: reloading a thread that once ran rm runs nothing.

A reloaded or resumed session continues under the current system prompt, not the one stored with it: the prompt is the agent's, and a change to agent.yaml must reach old conversations too.

/new in the two front ends

In the terminal /new simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one /new closed.

Why errors are recorded next to the history, not in it

A turn can fail — the server is down, the model is not pulled, the watchdog gave up, the summary of a compaction came back empty — and the front end says so in one red line. That line was the only trace: gone with the scrollback, absent from the file, because the file holds what the model sees and a failed turn leaves nothing for the model to see (the unanswered question is dropped). Since 2026-09-18 the session file also carries an errors array: one record per line shown, with the time, the turn count, the first line of the question being answered, the line as shown, and the raw error when it says more. Three kinds are recorded: generation (a failed answer), compaction (a failed summary, automatic or /compact) and resume (a /resume that could not load its session). A cancelled turn is not an error and is not recorded. Since 2026-09-27 each record also names the model that failed, and the file the provider and model it was last written with: the first thing an investigation needs, and the one it could not recover — a summary that comes back empty means nothing until you know the model reasons before it writes.

The array sits beside messages, not inside it, on purpose. The model never reads it: an error is about the run, not about the project, and feeding it back would make the model explain its infrastructure instead of doing its task. /resume and session/load never replay it: both read messages alone, so an old session loads exactly as before and a new one with errors loads as if the field were not there. And compression leaves it alone: /compact rewrites messages, the trail stays whole. The record follows its conversation: over ACP, /new moves it to the archived file with the history it belongs to.

What it is for: asking afterwards, with the file in hand, why a run went wrong — jq .errors <file> for a human, or the file handed to a coding agent asked to explain the failures and propose changes to the code or the YAML (a longer watchdogTimeout, a different model, a smaller maxOutput). The /sessions listing and the /resume report show · ⚠️ N error(s) so a session worth investigating stands out. The trail is capped at 200 records, oldest first out, so a run that fails on every turn does not grow its file without end.

Why the window's ↑ reads the stored questions

In the window, ↑ on the box's first row recalls the questions already asked (ticket 68), and only when sessions are on. The questions are read from the session files themselves — the user messages of the conversation in memory, then of the project's other files, newest first — not from a separate list. That was the user's choice: nothing new is written, the recall covers every session the project has kept, including those written before the feature existed, and it disappears with them when the directory is cleaned.

The price is that a recalled question is what the model read, not exactly what was typed: the [attached file: …] lines an @path added, a paste in full rather than its token, an /ask in its [asked …] form; slash commands, never sent to the model, are not there at all. What mm itself wrote as a user message is left out — a /compact summary, the note after a broken tool call, the marker of a prompt a hook blocked — and so is the context a preGeneration hook appended. One gap the files cannot fill: a turn that fails before answering drops its question from the history, and that is the question one most wants to retry. The window keeps it in memory until the next question, so ↑ still finds it; it is not written anywhere.

Why the memory search matches words

/memory and the search_memory tool (ticket 48) scan the project's session files on every call: no index, no database, no embedding model. A project's sessions are a few hundred kilobytes — this repository's 43 files make 508 KB — and reading them is instant. An embedding model would find "database" in a session that said "storage", but it is one more model loaded next to the chat model, on machines where memory is the constraint; that kind of memory belongs to the hierarchic-memory work (ticket 74), not to a search.

The price was measured, on Qwen3.5 4B through llmman with two stored sessions (2026-10-20). Questions whose words were in a session — the API port, "the port we agreed on before" to write a file — were found 9 times out of 9 (one warm-up run, three of each, then one of each again after the last change), and the tool was never called on a greeting, a sum, a listing or a code request (0 of 12). A French question about a choice written in English found nothing: the model searched "base de données", then, told by the tool's no-hit line to try English, "database" — which the session never said ("storage", "SQLite"). It found the answer by reading the JSON files itself (5 runs of 6; the sixth was lost to a server stall).

Building demos/memory-search/ shaped three more rules, each from a wrong answer it caused. A question that matches comes with the answer its turn ended on: "format logs" found only the question "Quel format pour les logs ?" (the answer says "log/slog"), and the model concluded nothing had been decided. Words of one or two letters are dropped beside longer ones: "de" in "base de données" matched "Deployment", "modernc" and "Module", returned the whole history, and the right answer came by luck. And a snippet runs from the first word found to the last: cut after the first, a summary that named the deployment in its first line lost its backups line, and the model answered beside the question. The summary's wrapper line and the acknowledgement after it, mm's own words, are no longer searched.

Two choices follow from the tool being in the model's hands. The conversation in progress is left out by content, not by session id: no front end tells a tool which file it saves to, and a resumed or compacted session's file holds the very messages in memory, so "a message the model already has" is both what to skip and something every front end can compute. And the tool and the command have separate keys, both off by default: a tool in the catalogue of a small model changes what it does, even when unused, so declaring one is the user's decision.

Rejected alternatives

  • Resume the latest session automatically at start. Rejected for the stale-context reason above, and because docker agent does not do it either.
  • Append-only log per session. Rejected: compression shrinks the history, and a log would keep saying what the memory no longer holds.
  • An index or embeddings for the memory search (ticket 48). Rejected for v1: the files are small enough to scan, and an embedding model is one more model in memory. The cost — no match across languages or synonyms — is measured above.
  • SQLite, like docker agent. Rejected for a minimal agent: one more dependency, opaque files, and a per-user database where mini-me wants per-project files.
  • session/resume instead of session/load. Rejected: it relies on the editor having kept the thread, and when the choice was made it was not in the stable spec. Since ticket 95 it is offered beside session/load, not instead of it.
  • A /resume slash command over ACP. Rejected (ticket 95): the editor draws the thread, so an old conversation replayed into an open thread would land after what it already shows, and the file would no longer match the editor's session id. Listing sessions to the editor is the protocol's way.
  • Replaying only text messages. Rejected by the user: the tool calls are the part of a coding session worth seeing again.
  • A separate list of typed inputs in the session file (ticket 68). It would have recalled exactly what was typed — slash commands and paste tokens included — but it was a new field to keep in step with the history, and empty for every existing file. The user preferred the stored messages.
  • Recording errors as messages in the history. Rejected: the model would read them, session/load would have to replay them as something, and an old file would load differently from a new one. A separate field costs nothing on load and changes nothing for the model.

How it relates to the rest

internal/store is the file layer, used by internal/agent (a keeper that saves after each turn and serves /sessions and /resume) and by internal/acp (a file per session, session/load, the replay in replay.go). internal/session.WithSystem swaps the stored prompt for the current one. Compression (internal/compact) is unaware of persistence: the front ends save after it runs.

 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
# Persistent sessions — explanation

## What is this about?

A session is one conversation: the system prompt, the questions, the answers and the tool calls in between. Until 2026-09-16 it lived in memory and died with the process; an editor that restarted `mm` could only open a new thread, which is what Zed's "Loading or resuming sessions is not supported by this agent" said. Persistent sessions (`sessions.enabled`) write each conversation to a file and let both front ends get it back.

## Why files, per project

Sessions live under the project, in `.mm/sessions/`, one JSON file each, rewritten whole after every turn. Three reasons. A conversation is about a project — its files, its commands — and belongs next to them, not in a home-wide database where projects mix. Files are inspectable: `jq` answers "what did the model read?" without a tool. And a whole-file rewrite is what compression needs: `/compact` replaces old turns by a summary, and the file must say the same as the memory, not the memory plus everything it used to hold.

The id is the creation time plus a random tail (`20260916-101530-4f2a9c1e`), so a listing sorts by time and two front ends never collide.

## Why a start is a new session

When `mm` starts, it starts a new session and says how to get the old ones back, rather than resuming the latest on its own. This mirrors docker agent (cagent): its `run` command never resumes by itself — `--session -1` or the TUI's `/sessions` browser do — and it is the safer default. The last conversation may be about something else; resuming it silently would hand the model a stale context, and the user would only notice when the answers went wrong. An explicit `/resume` costs one line and keeps the surprise out.

The terminal writes the file on the first question, not at start: a launch that asked nothing leaves no trace, and `/sessions` lists conversations, not launches.

## Why `session/load` and a full replay

The Agent Client Protocol offers two ways back into a session. `session/load` is in the stable spec: the agent replays the conversation as `session/update` notifications and the editor rebuilds the thread from them. `session/resume` is the other: the agent only re-registers the session, and the editor keeps whatever it was showing. docker agent chose the second (`loadSession: false`, `Resume` capability, SQLite store in `~/.cagent/session.db`). mini-me chose the first, because it was the stable one and because a replay puts the thread back even in an editor that kept nothing.

That choice still holds, but it is no longer exclusive. When it was made, `session/list` and `session/resume` were unstable extensions. The SDK mini-me uses (acp-go-sdk v0.13.5) now has them in its stable `Agent` interface, with no unstable marker — unlike `session/fork` and `session/delete` —, and Zed shows an agent's past threads only when the agent advertises `sessionCapabilities.list`. So since ticket 95 mini-me answers both: `session/list` gives the editor the project's stored conversations, titled by their first question, and `session/resume` re-registers one without replaying it, for an editor that still shows it. `session/load` and its replay are unchanged, for an editor that kept nothing. The list leaves out conversations without a question: every ACP thread writes its file at `session/new`, and a picker of empty threads helps nobody.

"Replay" means re-sending records. Each tool call comes back as a single, already-completed `tool_call` carrying the input the model gave and the output the tool returned at the time. Nothing is executed again: reloading a thread that once ran `rm` runs nothing.

A reloaded or resumed session continues under the current system prompt, not the one stored with it: the prompt is the agent's, and a change to `agent.yaml` must reach old conversations too.

## `/new` in the two front ends

In the terminal `/new` simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one `/new` closed.

## Why errors are recorded next to the history, not in it

A turn can fail — the server is down, the model is not pulled, the watchdog gave up, the summary of a compaction came back empty — and the front end says so in one red line. That line was the only trace: gone with the scrollback, absent from the file, because the file holds what the model sees and a failed turn leaves nothing for the model to see (the unanswered question is dropped). Since 2026-09-18 the session file also carries an `errors` array: one record per line shown, with the time, the turn count, the first line of the question being answered, the line as shown, and the raw error when it says more. Three kinds are recorded: `generation` (a failed answer), `compaction` (a failed summary, automatic or `/compact`) and `resume` (a `/resume` that could not load its session). A cancelled turn is not an error and is not recorded. Since 2026-09-27 each record also names the `model` that failed, and the file the `provider` and `model` it was last written with: the first thing an investigation needs, and the one it could not recover — a summary that comes back empty means nothing until you know the model reasons before it writes.

The array sits beside `messages`, not inside it, on purpose. The model never reads it: an error is about the run, not about the project, and feeding it back would make the model explain its infrastructure instead of doing its task. `/resume` and `session/load` never replay it: both read `messages` alone, so an old session loads exactly as before and a new one with errors loads as if the field were not there. And compression leaves it alone: `/compact` rewrites `messages`, the trail stays whole. The record follows its conversation: over ACP, `/new` moves it to the archived file with the history it belongs to.

What it is for: asking afterwards, with the file in hand, why a run went wrong — `jq .errors <file>` for a human, or the file handed to a coding agent asked to explain the failures and propose changes to the code or the YAML (a longer `watchdogTimeout`, a different model, a smaller `maxOutput`). The `/sessions` listing and the `/resume` report show ` · ⚠️ N error(s)` so a session worth investigating stands out. The trail is capped at 200 records, oldest first out, so a run that fails on every turn does not grow its file without end.

## Why the window's ↑ reads the stored questions

In the window, ↑ on the box's first row recalls the questions already asked (ticket 68), and only when sessions are on. The questions are read from the session files themselves — the `user` messages of the conversation in memory, then of the project's other files, newest first — not from a separate list. That was the user's choice: nothing new is written, the recall covers every session the project has kept, including those written before the feature existed, and it disappears with them when the directory is cleaned.

The price is that a recalled question is what the model read, not exactly what was typed: the `[attached file: …]` lines an `@path` added, a paste in full rather than its token, an `/ask` in its `[asked …]` form; slash commands, never sent to the model, are not there at all. What mm itself wrote as a user message is left out — a `/compact` summary, the note after a broken tool call, the marker of a prompt a hook blocked — and so is the context a `preGeneration` hook appended. One gap the files cannot fill: a turn that fails before answering drops its question from the history, and that is the question one most wants to retry. The window keeps it in memory until the next question, so ↑ still finds it; it is not written anywhere.

## Why the memory search matches words

`/memory` and the `search_memory` tool (ticket 48) scan the project's session files on every call: no index, no database, no embedding model. A project's sessions are a few hundred kilobytes — this repository's 43 files make 508 KB — and reading them is instant. An embedding model would find "database" in a session that said "storage", but it is one more model loaded next to the chat model, on machines where memory is the constraint; that kind of memory belongs to the hierarchic-memory work (ticket 74), not to a search.

The price was measured, on Qwen3.5 4B through llmman with two stored sessions (2026-10-20). Questions whose words were in a session — the API port, "the port we agreed on before" to write a file — were found 9 times out of 9 (one warm-up run, three of each, then one of each again after the last change), and the tool was never called on a greeting, a sum, a listing or a code request (0 of 12). A French question about a choice written in English found nothing: the model searched "base de données", then, told by the tool's no-hit line to try English, "database" — which the session never said ("storage", "SQLite"). It found the answer by reading the JSON files itself (5 runs of 6; the sixth was lost to a server stall).

Building `demos/memory-search/` shaped three more rules, each from a wrong answer it caused. A question that matches comes with the answer its turn ended on: "format logs" found only the question "Quel format pour les logs ?" (the answer says "log/slog"), and the model concluded nothing had been decided. Words of one or two letters are dropped beside longer ones: "de" in "base de données" matched "Deployment", "modernc" and "Module", returned the whole history, and the right answer came by luck. And a snippet runs from the first word found to the last: cut after the first, a summary that named the deployment in its first line lost its backups line, and the model answered beside the question. The summary's wrapper line and the acknowledgement after it, mm's own words, are no longer searched.

Two choices follow from the tool being in the model's hands. The conversation in progress is left out by content, not by session id: no front end tells a tool which file it saves to, and a resumed or compacted session's file holds the very messages in memory, so "a message the model already has" is both what to skip and something every front end can compute. And the tool and the command have separate keys, both off by default: a tool in the catalogue of a small model changes what it does, even when unused, so declaring one is the user's decision.

## Rejected alternatives

- **Resume the latest session automatically at start.** Rejected for the stale-context reason above, and because docker agent does not do it either.
- **Append-only log per session.** Rejected: compression shrinks the history, and a log would keep saying what the memory no longer holds.
- **An index or embeddings for the memory search (ticket 48).** Rejected for v1: the files are small enough to scan, and an embedding model is one more model in memory. The cost — no match across languages or synonyms — is measured above.
- **SQLite, like docker agent.** Rejected for a minimal agent: one more dependency, opaque files, and a per-user database where mini-me wants per-project files.
- **`session/resume` instead of `session/load`.** Rejected: it relies on the editor having kept the thread, and when the choice was made it was not in the stable spec. Since ticket 95 it is offered beside `session/load`, not instead of it.
- **A `/resume` slash command over ACP.** Rejected (ticket 95): the editor draws the thread, so an old conversation replayed into an open thread would land after what it already shows, and the file would no longer match the editor's session id. Listing sessions to the editor is the protocol's way.
- **Replaying only text messages.** Rejected by the user: the tool calls are the part of a coding session worth seeing again.
- **A separate list of typed inputs in the session file (ticket 68).** It would have recalled exactly what was typed — slash commands and paste tokens included — but it was a new field to keep in step with the history, and empty for every existing file. The user preferred the stored messages.
- **Recording errors as messages in the history.** Rejected: the model would read them, `session/load` would have to replay them as something, and an old file would load differently from a new one. A separate field costs nothing on load and changes nothing for the model.

## How it relates to the rest

`internal/store` is the file layer, used by `internal/agent` (a `keeper` that saves after each turn and serves `/sessions` and `/resume`) and by `internal/acp` (a file per session, `session/load`, the replay in `replay.go`). `internal/session.WithSystem` swaps the stored prompt for the current one. Compression (`internal/compact`) is unaware of persistence: the front ends save after it runs.