bots-garden/mini-mepublic Fork
⑂ fix/word-wrap
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. 8de2ad8 · on fix/word-wrap · k33g · Sep 18, 2026
persistent-sessions.md · 50 lines · 6.8 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/list and session/resume are unstable extensions: 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 is the stable one and because a replay puts the thread back even in an editor that kept nothing.

"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.

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.

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.
  • 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 (unstable) instead of session/load. Rejected: not in the stable spec, and it relies on the editor having kept the thread.
  • Replaying only text messages. Rejected by the user: the tool calls are the part of a coding session worth seeing again.
  • 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
# 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/list` and `session/resume` are unstable extensions: 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 is the stable one and because a replay puts the thread back even in an editor that kept nothing.

"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.

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.

## 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.
- **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` (unstable) instead of `session/load`.** Rejected: not in the stable spec, and it relies on the editor having kept the thread.
- **Replaying only text messages.** Rejected by the user: the tool calls are the part of a coding session worth seeing again.
- **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.