bots-garden/mini-mepublic Fork
⑂ feature/selectors
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. 0394624 · on feature/selectors · k33g · Sep 21, 2026
architecture.md · 66 lines · 9.1 KBmarkdown
Blame HistoryOpen raw

Architecture: one loop, three front ends — explanation

What is this about?

mm is deliberately small: an agent loop, a handful of tools, and a configuration file. What makes it worth looking at is how the pieces are cut so that the same loop can be driven from a terminal — line by line or in a window — and from an editor without duplicating anything.

The entry point, main.go, only does wiring: it loads the settings, opens the engine, builds the tool list, prints the banner, and hands everything to one of three front ends. Each concern lives in its own package under internal/:

Package Responsibility
config Every setting in one struct, with built-in defaults overridden by YAML and a few environment variables.
engine The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, the provider registry, and the reasoning a thinking model streams beside its answer (see Thinking models).
tools The built-in tools the model can call.
fileedit Exact-replacement file editing, the rules behind edit_file.
lsp The language-server client behind the lsp tool: starts the configured servers on first use (config lsp.servers), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as path:line:column <line> text. Built on go.lsp.dev/protocol and go.lsp.dev/jsonrpc2.
skills Discovery of markdown procedures and rendering of the read_skill catalogue.
command The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's / picker lists it.
clipboard Puts text on the system clipboard: a native tool on PATH first (pbcopy, wl-copy, xclip, xsel), OSC 52 as the fallback, and it reports which. Used by /copy in the REPL and the window.
store Per-project session files (<dir>/<id>.json, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when sessions.enabled.
mention The @path notation of a question: existing paths become [attached file: …] lines, the same shape as an editor's resource_link. Used by both front ends.
detector Detection of the same action repeated with the same result.
compact Compression of the history into a model-written summary.
spinner The "still working" indicator on a terminal.
version Which mm this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by --version, by the start-up banner and by the window's status bar.
ui Where human-facing output goes, and the event sink the front ends share.
agent The terminal REPL.
acp The Agent Client Protocol façade.
tui The single-window front end (mm -tui): a ui.Sink drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines.

A draw.io diagram of these packages and their dependencies is kept at docs/diagrams/packages.drawio.

Why it is designed this way

Tools report failures as text, never as errors. A command that exits non-zero, a file that does not exist, an edit that is ambiguous: all of it goes back to the model as the tool's output. The model is the one that must react, so it is the one that must read the message. A Go error would end the turn instead.

The full history is kept even when a turn fails. Genkit only returns the last message; the engine returns the whole conversation including tool calls and results. The front ends keep that history after an abort, a watchdog cut or a maxTurns overrun, because the commands that already ran are facts the model must not re-run or invent on the next question. Only a question that got no answer at all is dropped.

Output is shown before the model sees it. In the terminal, bash echoes the first lines of a command's output before returning it. "Show me that file" means show it; leaving it to the model to summarise was observed to leave the user with nothing.

One loop, three front ends. The terminal REPL, the window and the ACP façade receive exactly the same engine, system prompt and tool list. What differs is where events go. In the terminal, the tools print their own 🛠️ lines and nobody asks permission. In ACP mode, stdout belongs to JSON-RPC, so main.go redirects the human output to stderr, disables the spinner, and the façade installs a ui.Sink for each turn. The tools check for an active sink: with one, they emit ToolStart, Allow, ToolRunning, ToolEnd events instead of printing. The ui package knows nothing about the protocol; acp is the only place that translates those events into session/update notifications and permission requests. The window mode is the same trick a second time: tui installs its own ui.Sink for each turn, and the events become entries of a transcript drawn on the screen — so the tools needed no change to gain a third home, and the inline permission prompt of permissions: ask is the terminal's version of the editor's dialog.

One command table. The slash commands live in internal/command, marked with the front ends that offer them. The REPL and the window read the same table; the ACP façade advertises only the Editor slice (/new, /compact) through available_commands_update, because the editor owns the process and cancels turns itself. A command added in one place is therefore offered — or deliberately not — everywhere at once.

The window draws from a value, on one goroutine. The engine streams chunks and the tools emit events from their own goroutines; the window records them into a Transcript (a mutex-guarded value) and posts a wake-up event. Only the event loop draws, and it redraws from the transcript's current state every frame, so a dropped wake-up costs a late frame and never a lost one. This is the rule turbo-core's agent window follows, and the reason the window needs no widget library: a painter, a transcript, a layout function and a key map are enough.

Permission lives in the sink, not in the tools. A permission dialog is the main thing an editor adds and the terminal does not have. Putting the policy in the ACP sink keeps the tools identical in both modes; "allow always" is remembered per tool name for the session.

Turns are serialised in ACP mode. The active sink is process-global and the engine was never built for concurrent generations, so a second session/prompt waits for the first to finish. The protocol leaves that choice to the agent, and editors do not send overlapping prompts anyway.

The loop detector is separate from the history. It records tool, input and output triples; the same triple three times in a row makes the tool append an instruction to change approach. It is deliberately not reset by /compact: forgetting a conversation does not make a repeated command new. Advice is not enough for every model: on 2026-09-18 a 12B model repeated the same refused edit_file call thirty times, [LOOP_DETECTED] appended to each answer, until the user pressed Escape. So the detector has a second level: three flagged repeats after the threshold — six identical calls — and the tool returns a Go error instead of text, the one deliberate exception to "tools return text": the agent is stopping itself, not reporting a failure for the model to read. The engine shows the detector's sentence as the turn's error, does not retry the turn, and the session file records it. A third guard sits in the stream itself: the arguments of a tool call arrive delta by delta and none of it is shown, so a model that derails while composing one — "Greet 2023-06-15 11:16:00 +0000 UTC 2023-06-15 …" for four minutes, seen the same day — looks like nothing happening. The engine watches those deltas and stops the generation as soon as they repeat a pattern or pass a quarter megabyte.

Rejected alternatives

  • One binary per front end. Rejected because the point of the exercise is to prove the loop is the same; sharing the wiring in main.go is what makes that visible.
  • Printing directly from the ACP façade. Impossible: the specification forbids anything but protocol messages on stdout.
  • Returning Go errors from tools. Rejected because it ends the turn instead of letting the model recover.

How it relates to the rest

Package map

The import graph of every package, with a one-line purpose on each, is kept as a draw.io diagram at docs/diagrams/packages.drawio (opens in diagrams.net or the VS Code Draw.io extension). It is regenerated from go list whenever a package is added, removed or re-wired.

 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
# Architecture: one loop, three front ends — explanation

## What is this about?

`mm` is deliberately small: an agent loop, a handful of tools, and a configuration file. What makes it worth looking at is how the pieces are cut so that the same loop can be driven from a terminal — line by line or in a window — and from an editor without duplicating anything.

The entry point, `main.go`, only does wiring: it loads the settings, opens the engine, builds the tool list, prints the banner, and hands everything to one of three front ends. Each concern lives in its own package under `internal/`:

| Package | Responsibility |
|---------|----------------|
| `config` | Every setting in one struct, with built-in defaults overridden by YAML and a few environment variables. |
| `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, the provider registry, and the reasoning a thinking model streams beside its answer (see [Thinking models](thinking-models.md)). |
| `tools` | The built-in tools the model can call. |
| `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |
| `lsp` | The language-server client behind the `lsp` tool: starts the configured servers on first use (config `lsp.servers`), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as `path:line:column  <line>` text. Built on `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`. |
| `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |
| `command` | The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's `/` picker lists it. |
| `clipboard` | Puts text on the system clipboard: a native tool on `PATH` first (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 as the fallback, and it reports which. Used by `/copy` in the REPL and the window. |
| `store` | Per-project session files (`<dir>/<id>.json`, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when `sessions.enabled`. |
| `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. |
| `detector` | Detection of the same action repeated with the same result. |
| `compact` | Compression of the history into a model-written summary. |
| `spinner` | The "still working" indicator on a terminal. |
| `version` | Which `mm` this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by `--version`, by the start-up banner and by the window's status bar. |
| `ui` | Where human-facing output goes, and the event sink the front ends share. |
| `agent` | The terminal REPL. |
| `acp` | The Agent Client Protocol façade. |
| `tui` | The single-window front end (`mm -tui`): a `ui.Sink` drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines. |

A draw.io diagram of these packages and their dependencies is kept at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio).

## Why it is designed this way

**Tools report failures as text, never as errors.** A command that exits non-zero, a file that does not exist, an edit that is ambiguous: all of it goes back to the model as the tool's output. The model is the one that must react, so it is the one that must read the message. A Go error would end the turn instead.

**The full history is kept even when a turn fails.** Genkit only returns the last message; the engine returns the whole conversation including tool calls and results. The front ends keep that history after an abort, a watchdog cut or a `maxTurns` overrun, because the commands that already ran are facts the model must not re-run or invent on the next question. Only a question that got no answer at all is dropped.

**Output is shown before the model sees it.** In the terminal, `bash` echoes the first lines of a command's output before returning it. "Show me that file" means show it; leaving it to the model to summarise was observed to leave the user with nothing.

**One loop, three front ends.** The terminal REPL, the window and the ACP façade receive exactly the same engine, system prompt and tool list. What differs is where events go. In the terminal, the tools print their own `🛠️` lines and nobody asks permission. In ACP mode, stdout belongs to JSON-RPC, so `main.go` redirects the human output to stderr, disables the spinner, and the façade installs a `ui.Sink` for each turn. The tools check for an active sink: with one, they emit `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` events instead of printing. The `ui` package knows nothing about the protocol; `acp` is the only place that translates those events into `session/update` notifications and permission requests. The window mode is the same trick a second time: `tui` installs its own `ui.Sink` for each turn, and the events become entries of a transcript drawn on the screen — so the tools needed no change to gain a third home, and the inline permission prompt of `permissions: ask` is the terminal's version of the editor's dialog.

**One command table.** The slash commands live in `internal/command`, marked with the front ends that offer them. The REPL and the window read the same table; the ACP façade advertises only the Editor slice (`/new`, `/compact`) through `available_commands_update`, because the editor owns the process and cancels turns itself. A command added in one place is therefore offered — or deliberately not — everywhere at once.

**The window draws from a value, on one goroutine.** The engine streams chunks and the tools emit events from their own goroutines; the window records them into a `Transcript` (a mutex-guarded value) and posts a wake-up event. Only the event loop draws, and it redraws from the transcript's current state every frame, so a dropped wake-up costs a late frame and never a lost one. This is the rule turbo-core's agent window follows, and the reason the window needs no widget library: a painter, a transcript, a layout function and a key map are enough.

**Permission lives in the sink, not in the tools.** A permission dialog is the main thing an editor adds and the terminal does not have. Putting the policy in the ACP sink keeps the tools identical in both modes; "allow always" is remembered per tool name for the session.

**Turns are serialised in ACP mode.** The active sink is process-global and the engine was never built for concurrent generations, so a second `session/prompt` waits for the first to finish. The protocol leaves that choice to the agent, and editors do not send overlapping prompts anyway.

**The loop detector is separate from the history.** It records tool, input and output triples; the same triple three times in a row makes the tool append an instruction to change approach. It is deliberately not reset by `/compact`: forgetting a conversation does not make a repeated command new. Advice is not enough for every model: on 2026-09-18 a 12B model repeated the same refused `edit_file` call thirty times, `[LOOP_DETECTED]` appended to each answer, until the user pressed Escape. So the detector has a second level: three flagged repeats after the threshold — six identical calls — and the tool returns a Go error instead of text, the one deliberate exception to "tools return text": the agent is stopping itself, not reporting a failure for the model to read. The engine shows the detector's sentence as the turn's error, does not retry the turn, and the session file records it. A third guard sits in the stream itself: the arguments of a tool call arrive delta by delta and none of it is shown, so a model that derails while composing one — `"Greet 2023-06-15 11:16:00 +0000 UTC 2023-06-15 …"` for four minutes, seen the same day — looks like nothing happening. The engine watches those deltas and stops the generation as soon as they repeat a pattern or pass a quarter megabyte.

## Rejected alternatives

- **One binary per front end.** Rejected because the point of the exercise is to prove the loop is the same; sharing the wiring in `main.go` is what makes that visible.
- **Printing directly from the ACP façade.** Impossible: the specification forbids anything but protocol messages on stdout.
- **Returning Go errors from tools.** Rejected because it ends the turn instead of letting the model recover.

## How it relates to the rest

- The provider registry that the engine uses is described in [providers](providers.md).
- Why and how the history is shortened is in [context compression](context-compression.md).
- The exact protocol surface is in the [ACP reference](../reference/acp.md); the tools' contract is in the [tools reference](../reference/tools.md).

## Package map

The import graph of every package, with a one-line purpose on each, is kept as a draw.io diagram at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio) (opens in diagrams.net or the VS Code Draw.io extension). It is regenerated from `go list` whenever a package is added, removed or re-wired.