# Agent Smith Architecture

## Overview

All file paths in this doc are relative to `<repo_root>/agent_smith/` directory.

Agent Smith is a Lilush shell **mode** (`agent_smith`, declared in
`lilpack.json`) that implements a tool-using coding agent on top of a
Chat-Completions-compatible client. 

The user types at the mode prompt; each non-command line becomes a **turn**
in which the model is called in a loop, calls tools, and the loop continues 
until the model stops or a terminal condition is reached.

The stack is layered:

```text
mode adapter        mode/agent_smith.lua      shell integration, UI, combos
      |
runtime             core/runtime.lua          session/turn lifecycle, services
      |
services            role model tool dispatch conversation persistence
      |             config ui prompt command  (composed, frozen handles)
      |
turn engine         core/turn.lua             the LLM/tool loop
      |
   +--------------------+-------------------+
   |                    |                   |
LLM client          tools               persistence
llm/                tool/               persistence/
```

The runtime owns no protocol, storage, or UI logic directly — it composes
**service handles** and drives the turn engine, which in turn calls the LLM
client and the tool executor. State is persisted through a key-value store.

---

## Runtime & services

Source: `core/runtime.lua`.

### Service composition

`compose_services(config)` (`core/runtime.lua`) builds ten service
handles by name:

```text
role  model  tool  dispatch  conversation
persistence  config  ui  prompt  command
```

Each handle is created via `<module>.new(service_options(config, name))`.
Two override hooks exist:

- `config.service_handles[name]` — inject a pre-built handle (used for
  testing and for the dispatch child shim).
- `config.services[name]` (or top-level `config[name]`) — per-service options
  table passed to `new(...)`.

### Frozen execution context

Services and the config snapshot are deep-frozen via `freeze_snapshot` /
`freeze_table` (`core/runtime.lua`) — frozen tables raise on write — so
every turn observes an immutable `execution_context` (`session_id`,
`active_role`, `conversation_id`, `config_snapshot`, `service_handles`).

### Runtime state

The runtime instance tracks:

- `current_turn` — the active turn record, or nil.
- `next_turn_id` — monotonic turn counter.
- `conversation_state` — the transcript accumulator (see *Persistence* and
  `conversation.lua`).
- `session` — status `created | running | stopped | failed`, plus
  `started_at`, `stopped_at`, `stop_reason`, `error`.
- `usage_tracker` — token totals for the parent plus per-child-role
  (`worker`, `explorer`).

### Per-turn scope

`recompute_turn_scope()` resolves, per turn: `cwd`, the git `project`
(`root`, `origin`, `normalized`, index file content), and the derived
`project_id` (see *Persistence → Project identity*).

### Lifecycle

- `start_session` / `stop_session` / `fail_session` move the session through
  its status states (stop/fail cancel any active turn).
- `begin_turn(request)` validates that no turn is active, mints a turn id and
  a root cancel token, and snapshots scope. `with_turn` wraps the body in
  `xpcall` and normalizes any thrown/returned error; `finish_turn` clears the
  active turn and finalizes the result.
- `process_raw_input(line, options)` routes `/`-prefixed lines to
  `run_command` and everything else to `run_turn` → `core.turn.run`.

### Mode adapter

`mode/agent_smith.lua` integrates the runtime with the Lilush shell:

- **Lazy runtime creation** with config merge on first use (defaults → user
  JSON → config repository → ephemeral overrides; see *Config overrides*).
- An **`on_event` subscription** that drives the UI: prompt status
  (thinking/streaming/tool/idle/error/cancelled), the spinner, the streamed
  text, and tool/dispatch result rendering.
- **Combo handling**: the cancel combo (default `ctrl-c`) cancels the active
  turn; the conversation-view combo (default `ALT+h`) renders the transcript to
  the pager; the inspect combo (default `ALT+i`) opens the Inspect View
  (`ui/inspect.lua`) over the persisted messages, with edit/delete writing back
  through the conversation repo and rebuilding the live session.

---

## Config overrides

Source: `config/loader.lua`, `config/schema.lua`, persisted via
`persistence/config.lua`.

### Layered merge

`loader.load_effective_config(options)` merges four layers, lowest to highest
precedence:

1. **Defaults** — built in (see below).
2. **User JSON** — `~/.config/lilush/agent_smith.json` (missing file tolerated
   when `allow_missing_user_config_json` is set).
3. **Persisted patch** — a partial config table from the config repository,
   deep-merged like the user-JSON layer. Written by `/config set` (see *Runtime
   overrides*).
4. **Ephemeral overrides** — in-memory, never persisted.

Merging is a recursive `deep_merge`: tables merge key-by-key, scalars
replace. The merged result is validated by `schema.validate` before use, and
the function returns both the `effective` config and the individual `layers`.

### Defaults

The built-in defaults applied by `loader.load_effective_config` (the lowest merge
layer) are:

```json
{
  "index_file": "INDEX.md",
  "llm_config": {
    "api_key_env": "LLM_API_KEY",
    "retry_max_retries": 2,
    "retry_backoff_ms": [2000, 8000],
    "stream_retry_after_output": false
  },
  "empty_response_retry_max_retries": 2,
  "empty_response_retry_backoff_ms": [500, 1000],
  "context_management": {
    "enabled": true,
    "tool_output_keep_rounds": 7,
    "tool_output_elide_over_chars": 1024,
    "trim_threshold_pct": 80
  }
}
```

Two additional defaults are **not** supplied by the config loader and so do not
appear above:

- **`active_role`** defaults to `direct`. It is a plain top-level config field
  (`direct` or `orchestrator`) with a final `"direct"` fallback applied when the
  runtime builds the execution context (`core/runtime.lua`); it is not a
  config-loader default.
- The **transport timeouts** (`llm_config.timeout`, `connect_timeout`,
  `send_timeout`, `recv_timeout`, `stream_stall_timeout`) are defaulted inside the
  LLM client (`llm/client.lua`), not the config loader — see *LLM timeouts and
  retries* below.

### Schema

`schema.validate` checks the merged config:

- `default_model` — non-empty string (or absent).
- `index_file` — relative **safe** path (no leading `/`, no `..` segments, no
  drive prefix).
- `active_role` — `direct` or `orchestrator` (or absent).
- `tool_step_budget` — positive integer (or absent — unlimited): the per-turn
  tool-call budget enforced by the turn loop.
- `models` — map of non-empty string key → non-empty string model id.
- `services` — table (or absent).
- `llm_config` — `api_key_env` (non-empty string, or absent), `retry_max_retries`
  (non-negative number), `retry_backoff_ms`
  (non-empty array of non-negative numbers), `stream_retry_after_output`
  (boolean), and the transport timeouts `timeout`, `connect_timeout`,
  `send_timeout`, `recv_timeout`, `stream_stall_timeout` (positive numbers, or
  absent — defaulted in `llm/client.lua`).
- `empty_response_retry_max_retries`, `empty_response_retry_backoff_ms` — as
  above.
- `context_management` — table (or absent): `enabled` (boolean),
  `tool_output_keep_rounds` / `tool_output_elide_over_chars` (non-negative
  numbers), `trim_threshold_pct` (number in `(0, 100]`).

### Context management

Source: `conversation/window.lua`, driven from `core/turn.lua`. Before every LLM
call the model-bound copy of the transcript (system + history, never the
persisted state) is run through a two-layer policy:

1. **Elide old tool output (always on).** Tool results from the most recent
   `tool_output_keep_rounds` tool-call rounds are kept verbatim. Older results
   larger than `tool_output_elide_over_chars` are replaced with a short
   `[elided N chars of tool output]` placeholder. The assistant `tool_calls` and
   the `tool` message structure are preserved, so linkage and history stay valid.
   When a result is **recoverable** from a durable store — the message carries a
   `recall_key` (e.g. dispatch results, auto-persisted to the scratchpad) — the
   placeholder names it: `... retrieve with recall(key="dispatch/<id>")`, so the
   model can pull the full content back instead of treating it as lost. This is
   what makes eliding an otherwise non-recoverable dispatch result safe.
2. **Drop under token pressure.** Size is estimated with a `chars/4` heuristic
   (no tokenizer exists). When the estimate exceeds
   `context_window * trim_threshold_pct/100` — where `context_window` comes from
   the resolved model (`model.lua`, `128000` fallback) — the oldest **whole
   rounds** are dropped, newest-first, until it fits.

Both layers preserve the invariants: the first system message and the most
recent user message are always kept, and an assistant `tool_calls` message is
never kept unless all its `tool` results are. Setting `enabled` to `false`
disables both layers (no trimming at all). The policy clones any message it
rewrites, so the persisted transcript and `/pager` always show the full,
un-elided history.

The `context_management.*` leaves are in the runtime-override allowlist (below),
so they can be changed at runtime with `/config set` (e.g.
`/config set context_management.enabled false`) as well as through the user JSON
config file.

### Runtime overrides (`/config set`)

Persisted runtime overrides are a single **partial-config patch table**, stored
in the config keyspace under `config:runtime_overrides` (`persistence/config.lua`)
and deep-merged as layer 3 of the effective config. There is no entry/domain/
provenance machinery — the patch is just a config subtree.

- `/config set <dot.path> <value>` sets a leaf; `/config unset <dot.path>`
  removes it (pruning now-empty parent tables).
- **Allowlist.** The path must be in the curated overridable set
  (`schema.is_overridable_path`): the exact paths `default_model`, `index_file`,
  `active_role`, `tool_step_budget`, any `models.<key>`, and the enumerated
  `llm_config.*`, `context_management.*`, and `empty_response_retry_*` leaves.
  Unknown paths are rejected (catches typos).
- **Value coercion.** Values arrive as strings and are JSON-decoded first, so
  `80`, `false`, and `[2000,8000]` become typed; anything that isn't valid JSON
  (e.g. a model id like `openrouter/qwen/qwen3.6-27b`) falls back to the string.
- **Validate before persist.** The coerced `path = value` is run through
  `schema.validate` as a single-leaf candidate object; invalid values are
  rejected and nothing is written.

The pre-release entry-document shape (`{ entries = [...] }`) is no longer
migrated on read; if an old database still holds it, clear the overrides
(`/config unset` each path, or delete the database) and re-set them.

### LLM timeouts and retries

All timeouts are in **seconds**. The transport timeouts live under `llm_config`
and are defaulted by the LLM client (`llm/client.lua`); override any of them
per-field (via the user JSON or `/config set`, validated as positive numbers).

| Key | Default | Covers |
|-----|---------|--------|
| `llm_config.timeout` | `600` | Overall deadline for the **non-streaming** chat request. Not used by the streaming path. |
| `llm_config.connect_timeout` | `120` | TCP connect + TLS handshake + status/header read for the streaming (SSE) request. |
| `llm_config.send_timeout` | `30` | Sending the streaming request body. |
| `llm_config.recv_timeout` | `0.5` | Per-poll read granularity while reading the SSE stream. |
| `llm_config.stream_stall_timeout` | `120` | Wall-clock with the SSE connection open but **no event arriving** before the stream fails with `stream_stalled`. There is no overall cap on a stream that keeps producing output. |

Retries are two independent layers:

| Key | Default | Meaning |
|-----|---------|---------|
| `llm_config.retry_max_retries` | `2` | Transport-error retries, including HTTP 5xx and 429 (→ up to 3 attempts). Cancellation is never retried. |
| `llm_config.retry_backoff_ms` | `[2000, 8000]` | Backoff per retry (ms); the last value repeats for further retries. |
| `llm_config.stream_retry_after_output` | `false` | When `false`, a streaming call that already emitted output is not retried. |
| `empty_response_retry_max_retries` | `2` | Retries when the model returns a valid-but-empty response (→ up to 3 attempts). |
| `empty_response_retry_backoff_ms` | `[500, 1000]` | Backoff per empty-response retry (ms). |

The two retry layers nest (empty-response wraps transport), so the worst-case
attempt count with defaults is `(2+1) × (2+1) = 9` LLM calls. Transport and
empty-response retries are applied by the turn layer (`core/turn.lua`), not the
raw LLM client; see [LLM.md](./LLM.md) for the streaming/retry contract.

---

## Roles

Source: `role.lua` (catalog), `role/matrix.lua` (policy), `model.lua`
(model resolution).

### Catalog

Four roles are registered. Each `role_spec` carries `name`, `user_facing`,
`can_dispatch`, `dispatch_targets`, `prompt_profile`, and `default_model_key`:

| Role | user_facing | dispatch targets | prompt_profile |
|------|-------------|------------------|----------------|
| `direct` | yes | `worker`, `explorer` | `direct` |
| `orchestrator` | yes | `worker`, `explorer` | `orchestrator` |
| `worker` | no | `explorer` | `worker` |
| `explorer` | no | — | `explorer` |

`resolve_role(name)` and `list_roles()` query the catalog. All roles use
`default_model_key = "default"`.

### Policy matrix

`role/matrix.lua` holds two tables and two queries:

- `DISPATCH_TARGETS` + `check_dispatch(role, target)` — enforces the dispatch
  edges above.
- `TOOL_MATRIX` + `check_tool(role, tool_name)` → `"allow" | "deny"` (unknown
  role/tool → `"deny"`).

Tool availability per role:

| Tool | direct | orchestrator | worker | explorer |
|------|:---:|:---:|:---:|:---:|
| `bash` | ✓ | ✗ | ✓ | ✓ |
| `read` | ✓ | ✓ | ✓ | ✓ |
| `code_map` | ✓ | ✓ | ✓ | ✓ |
| `code_grep` | ✓ | ✓ | ✓ | ✓ |
| `code_files` | ✓ | ✓ | ✓ | ✓ |
| `code_symbols` | ✓ | ✓ | ✓ | ✓ |
| `code_outline` | ✓ | ✓ | ✓ | ✓ |
| `git` | ✗ | ✓ | ✗ | ✗ |
| `edit` | ✓ | ✗ | ✓ | ✗ |
| `write` | ✓ | ✗ | ✓ | ✗ |
| `web_search` | ✓ | ✗ | ✓ | ✓ |
| `web_fetch` | ✓ | ✗ | ✓ | ✓ |
| `note` | ✓ | ✓ | ✗ | ✗ |
| `dispatch` | ✓ | ✓ | ✓ | ✗ |
| `record` | ✓ | ✓ | ✗ | ✗ |
| `recall` | ✓ | ✓ | ✗ | ✗ |
| `report` | ✗ | ✗ | ✓ | ✓ |

The read-only `code_*` exploration tools are available to every role. The `git`
tool is **orchestrator-only**: it is the one role without `bash`, so it relies on
`git` to review what a worker changed without a mutation vector or unbudgeted
output; the other roles use `bash` for git. The orchestrator also has **no web
tools**: web research is delegated to an `explorer`, keeping that bulk out of its
context. `report` is the terminal tool required of delegated child roles;
`record` / `recall` are the scratchpad for the **user-facing** roles (`direct`
and `orchestrator`) — both dispatch, and dispatch results are auto-persisted to
the scratchpad (see *Dispatch & delegation*), so both need read access to recall
them. 

### Model resolution

`model.lua` `select_model(request, role_spec, runtime_config)` walks a
deterministic eight-step fallback. The first step with a non-`nil` value wins;
later steps are only consulted if every earlier one is `nil`.

| # | Source expression | Resulting `source` | Notes |
|---|-------------------|--------------------|-------|
| 1 | `request.model_override` | `override` | Per-call override; set by `llm.chat` callers and slash commands that need to bypass the rest of the chain. |
| 2 | `options.models[role]` | `role` | Per-role model from the on-disk config (`options.models.<role>`). |
| 3 | `options.models[default_model_key]` | `role` | Per-role model addressed by the role's `default_model_key` indirection. |
| 4 | `runtime_config.models[role]` | `role` | Same as #2, but from the in-memory runtime overrides (`/config set`). |
| 5 | `runtime_config.models[default_model_key]` | `role` | Same as #3, but from the in-memory runtime overrides. |
| 6 | `options.default_model` | `default` | Global default model from the on-disk config. |
| 7 | `runtime_config.default_model` | `default` | Global default from the in-memory runtime overrides. |
| 8 | `role_spec.default_model_key` | `fallback` | Hard-coded last resort defined by the role's catalog entry; always resolves because role specs require it. |

The return value is `{ model, source, key, context_window, max_output_tokens }`.
`context_window` and `max_output_tokens` are looked up from the LLM model
catalog (`llm/model/catalog.lua`, cached after first fetch) and used by the
context-management and streaming layers; see [LLM.md](./LLM.md).

### Role system prompts

High-level behavior:

1. Resolve active role (`direct` / `orchestrator` / `worker` / `explorer`).
2. Build prompt blocks in fixed order.
3. Join non-empty blocks with double newlines.
4. Prepend result as `system` message before transcript messages.
5. Build tool specs separately (`tool:llm_specs(role)`), independent from prompt text.

Primary assembly entrypoint: `prompt.lua` (`build_system_prompt`, `build_messages`).

#### System-prompt block order

`build_system_prompt(context, role_spec, project_memories)` in `prompt.lua` builds blocks in this order:

1. **Role frame**
2. **Project context**
3. **Project-memory index** (only when memories are provided)

Assembly behavior:

- The `assemble` helper in `prompt/template.lua` filters empty blocks and concatenates non-empty blocks with `"\n\n"` (double newline).

#### Per-block details

##### Role frame

- Source: `prompt/role.lua`
- Selector: `role_spec.prompt_profile` from role catalog (`role.lua`)
- API: `get_prompt_fragment(profile_name)` (`prompt/role.lua`)

Profiles provided:

- `direct`: full coding assistant, can dispatch worker/explorer, has record/recall.
- `orchestrator`: planning/delegation assistant, no file mutation, use record/recall.
- `worker`: delegated implementation agent, may dispatch to explorer.
- `explorer`: read-focused investigation agent, do not mutate files.

##### Project context

- Source: `prompt/project.lua`
- The block is emitted only when `turn.project` is present.
- Markdown shape:

```md
## Project information
- CWD: <turn.cwd>                       # included when available
- Repository root: <project.root|cwd>   # included when available
- Git origin: <project.origin>           # optional
- Normalized origin: <project.normalized># optional
- Project ID: <turn.project_id>          # optional

## Project context                         # optional section
<contents of <repo-root>/<runtime.index_file>>
```

- `runtime.index_file` defaults to `INDEX.md` (config loader default).
- The index file is resolved only at Git repository root (`<repo-root>/<runtime.index_file>`), not relative to arbitrary CWD locations.
- The `## Project context` section is appended only when the resolved index file exists and is non-empty.

##### Project-memory index

- Builder: `prompt.lua`
- Added only when `project_memories` is non-empty
- Structure:
  - `## Project memory index`
  - `project_id`
  - per-entry `id` + `label` (the memory's first content line, capped ~80 chars)

---

## Tools and tool loops

Source: `tool/registry.lua`, `tool/executor.lua`, `tool/builtin/*`,
`tool/file_edit.lua`; the loop itself lives in `core/turn.lua`.

### Registry

`tool/registry.lua` exposes `register / get / resolve / list / llm_specs`. A
tool spec is:

```text
{ name, schema, handler,
  description?, role_allowlist?, role_denylist?,
  result_renderer?, is_terminal? }
```

Availability is a **dual gate**: a tool must pass both the global
`role_matrix.check_tool(role, name)` and the spec's own local allow/deny
metadata before it is offered to a role.

### Executor

`tool/executor.lua` runs one tool call:

1. Decode JSON arguments.
2. Resolve the spec with role-policy enforcement.
3. Pre-execution cancellation check.
4. Run the handler with a wrapped context (`role`, `turn_id`, `cancel_token`,
   `is_cancelled()`), plus inherited scope fields.
5. Post-execution cancellation check.
6. Normalize the return into a `tool_result`:
   `{ call_id, ok, content, error, cancelled, mutation_state, metadata }`.

`mutation_state` (`none | attempted | unknown`) records whether a mutating
tool may have changed state when cancelled mid-flight.

### Built-in tools

Registered by `tool/builtin.lua`:

- `bash` — async shell execution with a timeout and cancellation; output
  truncated. No command guardrails (YOLO mode); isolation is the safety
  boundary, not pattern matching.
- `read` — file content with line count and a content hash.
- `code_map` / `code_grep` / `code_files` / `code_symbols` / `code_outline` —
  read-only code exploration backed by `rg`/`fd` (via `tool/code/*` helpers).
  Ignore-aware, ranked, deduped, and output-budgeted: a repo overview, content
  search, fuzzy filename search, repo-wide symbol lookup, and single-file
  symbol outline. Available to all roles (the orchestrator has no `bash`).
  Symbol extraction (`tool/code/symbols.lua`) is heuristic, for Lua and C.
- `git` — read-only git inspection (status/log/diff/show/blame) via a fixed
  subcommand whitelist; output-budgeted, cannot mutate. Granted to the
  **orchestrator only** (the one role without `bash`), so it can review what a
  worker changed; other roles use `bash` for git.
- `edit` — hash-guarded line-range edit (`replace` / `insert` / `delete`) via
  `tool/file_edit.lua`; the caller passes the last-seen full-file hash, and
  the edit is rejected if the file changed underneath. Returns `new_hash`,
  affected range, line delta, and a context snippet.
- `write` — create/overwrite a file, returning the content hash.
- `web_search`, `web_fetch` — web search and HTTP fetch. `web_search` defaults
  to the Linkup backend (`tool/backend/linkup.lua`, reads `LINKUP_API_KEY`);
  both still accept an injected `options.search` / `options.fetch` backend for
  tests. Linkup defaults are overridable via the `services.tool.web_search`
  config block (`api_key_env`, `endpoint`, `depth`, `output_type`,
  `max_results`, `timeout`).
- `report` — **terminal**; the structured completion a delegated child role
  must emit. Args are coerced then validated by `dispatch/report_schema.lua`:
  `coerce` is liberal in what it accepts (a bare-string `findings` becomes a
  one-element array; omitted role fields get empty defaults), so a well-formed
  report succeeds on the first call instead of bouncing off a strict schema. One
  hard rule: an explorer report carrying `findings` must include at least one
  `evidence` entry, so findings (function names, paths) are grounded in a source
  the child actually consulted rather than fabricated.
- `record` / `recall` — conversation-scoped scratchpad (key/value), available to
  the user-facing roles. Dispatch results are auto-persisted here (see *Dispatch
  & delegation*).
- `note` — project-memory CRUD (`list` / `get` / `upsert` / `delete`).
- `dispatch` — **non-terminal** delegation to a child role (see *Dispatch &
  delegation*).

### The tool loop

Inside a turn (`core/turn.lua`), the loop runs:

1. Build model messages and **trim for the context window**
   (`conversation/window.lua`) — invariants: always keep the first system
   message, the most recent user message, and any assistant/tool-result pair
   that must stay linked.
2. Call the LLM, with **transport retries** (`retry_max_retries`,
   `retry_backoff_ms`) and **empty-response retries**
   (`empty_response_retry_*`).
3. Extract tool calls; append the assistant message.
4. Execute calls in order, decrementing the per-turn tool budget
   (`tool_step_budget`, unlimited when unset) and checking cancellation before
   each.
5. Append tool results, then continue the loop.

The loop stops when: a **terminal** tool (`report`) succeeds, the turn is
**cancelled**, the tool **budget** is exhausted, or the model returns **no
further tool calls**. The outcome is reported as `stop_reason ∈
{ completed, cancelled, error, budget_exhausted }`. 
See [LLM.md](LLM.md) for the retry/streaming contract.

---

## Dispatch & delegation

Source: `dispatch/job.lua`, `dispatch/worker.lua`, `dispatch/result.lua`,
`dispatch/report_schema.lua`; orchestrated by `dispatch.lua`.

Dispatch lets a parent role hand a scoped sub-task to a child role and receive
a structured result, without the parent's full transcript leaking into the
child.

### Job

`dispatch/job.lua` builds a validated `dispatch_job`. The `parent_role` is a
**validation input** only — the `parent_role → target_role` edge must pass
`check_dispatch` — and is not stored on the job. The constructed job table holds:
`job_id`, `parent_turn_id`, `target_role`, `task`, structured `context`
(`user_request_summary` + `role_instructions`; a raw transcript is rejected
unless explicitly flagged), optional sorted `files`, and a derived
`cancel_token`.

### Worker

`dispatch/worker.lua` derives a child cancel token from the parent and builds
an **isolated child execution context**: `session-child-<job_id>`,
`active_role = target_role`, a child conversation scoped under the parent
(`<parent_conversation>:child:<job_id>`), and inherited config/service
handles. It runs a child turn that **must terminate via `report`**.

### Result

A single schema (`dispatch/report_schema.lua`) drives both the `report` tool
and result normalization (`dispatch/result.lua`):

- `status ∈ { ok, partial, blocked }`, non-empty `summary`.
- Worker fields: `changes`, `verification`.
- Explorer fields: `findings`, `evidence`.
- Common optional: `limitations`, `cancelled`, `error`, `usage`.

The parent receives the normalized `dispatch_result` as the `dispatch` tool's
content and continues its own loop; child token usage is attributed to the
child role in the usage tracker.

### Auto-persisted findings

A `dispatch_result` is **non-recoverable** — the child turn is finished and its
distilled findings cannot be regenerated — yet it is the parent's primary working
memory across a decompose-and-delegate task. So `dispatch.lua` writes the full
result to the parent conversation's scratchpad under `dispatch/<job_id>` (the same
`job_id` stamped onto the result), keyed off the parent's `conversation_id`. The
write is best-effort: a failure never fails the dispatch, and a `recall_key` is
surfaced (and threaded onto the tool-result message, then into any elision
placeholder) only when the write succeeded. This keeps findings durable and
addressable (`recall(prefix=true, key="dispatch/")` enumerates them), which is
what lets context-window elision shrink the inline copy without losing the
substance — and prevents the parent from re-dispatching work it already has or
fabricating findings it can no longer see.

---

## Persistence

Source: `persistence.lua` (service), `persistence/repository.lua` (factory),
`persistence/schema.lua` (keys), `persistence/codec.lua` (serialization),
`persistence/mneme.lua` (adapter), plus the per-entity repositories.

### Backend

Storage is the **MNEME** key-value store (`persistence/mneme.lua`), with the
database at `~/.local/share/lilush/agent_smith.mneme` and full-text-search
keyspaces for searchable content. Batch writes are atomic with rollback.

### Repositories

`persistence/repository.lua` builds a set of typed repositories over a shared
adapter: `conversation`, `conversation_note`, `usage`, `project_memory`, and
`config`. All records are encoded/decoded by `codec.lua`, which validates
record types and emits deterministic (sorted-key) output.

### Keyspaces and scoping

`persistence/schema.lua` (schema version 2) defines the keyspaces and the key
formats. A key already lives inside a named keyspace, so keys **do not repeat
the keyspace name** — they are just the composite id segments, joined by `:`:

| Keyspace | Key format |
|----------|------------|
| `meta` | `schema_version`, `active_conversation_id` |
| `conversation` | `<conversation_id>` |
| `message` / `message_search` | `<conversation_id>:<seq¹²>` / `<conversation_id>:<message_id>` |
| `conversation_note` | `<conversation_id>:<note_id>` |
| `usage` | `<conversation_id>:<turn_id>:parent` or `…:child:<job_id>` |
| `project_memory` / `project_memory_search` | `<project_id>:<memory_id>` |
| `config` | `runtime_overrides` |

Structural id segments (`conversation_id`, `turn_id`, `project_id`, `job_id`)
must be `:`-free; the trailing `note_id` segment is opaque and may contain `:`
(the scratchpad tools use `scratchpad:<key>` note ids — e.g. auto-persisted
dispatch results land under `scratchpad:dispatch/<job_id>`). Per-conversation
scans use the shared `<conversation_id>:` prefix via
`build_conversation_scope_prefix`.

Project memory is keyed by `project_id`, so it is **shared across
conversations** for the same repository; everything else is conversation- or
turn-scoped.

### Full-text indexes (written, not yet queried)

`message_search` and `project_memory_search` are full-text keyspaces populated
on every message / project-memory write (and rebuildable via
`rebuild_message_search_index` / `rebuild_search_index`). **No code queries
them yet** — they exist so a future `/search`-style feature has a maintained
index from day one. Treat the dual-writes as intentional, not dead code.

### Project identity

`persistence/project_identity.lua` normalizes a git origin URL (SSH / HTTPS /
file) to a canonical form and derives a 16-char SHA-256 `project_id`. This is
why project memory follows the repository across sessions and checkouts.

### Turn persistence

On turn completion, `persistence.lua` `persist_turn(record)` writes the turn's
transcript slice **verbatim, in true per-round order** (user message, then each
round's assistant `tool_calls`, its tool results, interleaved assistant text,
and the final assistant message — exactly as the turn loop appended them),
parent and child usage records, and a full turn-record JSON note under
`turn_record-<turn_id>`. Child usage is keyed by the real dispatch `job_id`
(a `std.nanoid()` minted by the dispatch tool and stamped onto its result),
not a synthetic counter.

### Auto-resume

`persist_turn` also records `meta:active_conversation_id`. On the first runtime
of a session, `mode/agent_smith.lua` reads it and, if it names a different,
non-empty conversation, transparently reopens it (pinning the id and seeding
the transcript). This makes a restart resume where the user left off. It runs
at most once per session and is a no-op for explicit `/new` and `/load`, and
after `/clear` (the emptied transcript skips resume).

---

## Cancellation & errors

### Cancellation

Source: `cancel.lua`. A cancel token wraps a `lev` cancel token and forms a
**parent → child tree** (`new_root`, `new_child`). `request(token)` cancels
eagerly and fans out to all children, so cancelling a parent turn cascades
into any in-flight dispatch child. Helpers: `is_cancelled`, `check`,
`with_token` (merges the token into an options table). The turn engine checks
cancellation at the top of its loop and before each tool call; dispatch child
tokens derive from the parent's.

### Errors

Source: `error.lua`. Errors are normalized to a canonical `agent_error`:

```text
{ kind, code, message, retryable, origin, data, cause }
```

`kind ∈ { transport, protocol, schema, tool, cancelled, budget_exhausted,
policy, persistence, internal }`. `normalize()` coerces strings, the literal
`"cancelled"`, and partial tables into this shape, applying defaults for any
missing fields. Errors are normalized at the runtime and turn boundaries and
surfaced through `turn_result.error` and `stop_reason`.

---

## Events

Source: `core/event.lua`.

The turn engine reports its progress over a tiny **event bus**. `M.new()` returns
a bus with `subscribe(fn)`, `emit(event)`, `events()` (peek), and `drain()`
(return-and-clear). `emit` calls every subscriber synchronously, each wrapped in
`pcall` so a faulty subscriber cannot break the turn, then appends the event to
an accumulated list. `M.event(type, turn_id, data)` builds a well-formed event,
stamping `ts` via `lev.now()` and validating that `type` is a non-empty string.

Event **types are not hard-enforced** — the turn loop supplies them as plain
strings. The vocabulary the turn loop emits is:

```text
turn_started            prompt_built            llm_request_started
llm_text_delta          llm_tool_call_detected  tool_execution_started
tool_execution_finished llm_request_completed   turn_completed
turn_persisted          turn_cancel_requested   llm_request_cancelled
tool_execution_cancelled dispatch_cancel_requested turn_failed
```

The mode adapter's `on_event` subscription (see *Mode adapter* above) is the
primary consumer: it maps these events onto prompt status, the thinking
indicator (spinner or matrix rain), streamed text, and tool/dispatch result
rendering through the UI service.

---

## UI & rendering

Source: `ui.lua` (service), `ui/renderer.lua`, `ui/status.lua`,
`ui/matrix.lua`, `ui/theme_accessor.lua`, and `theme.lua`.

The `ui` service handle (`ui.lua`) is thin: it exposes `renderer`, `status`, and
the `new_stream` shortcut. Agent Smith runs the terminal in **raw mode** (no
ONLCR), so both surfaces translate bare `\n` to `\r\n` (the `crnl` helper) to
keep streamed output from stair-stepping.

- **`ui/renderer.lua`** — streaming Markdown rendering and result formatting.
  `new_stream(options)` wraps the streaming Markdown renderer for live LLM text;
  `render_role_label`, `render_tool_call`, `render_command_result`,
  `render_dispatch_summary`, `render_error`, and `render_cancelled` format the
  discrete surfaces. `conversation_to_markdown` / `show_markdown_pager` back the
  `/pager` and `/prompt` views. Styling is applied per text span via the theme.
- **`ui/status.lua`** — transient status-line output: `write_line`,
  `count_output_lines`, `clear_previous_output`, and the spinner
  (`show_spinner` / `clear_spinner`) that reflects the thinking/streaming/tool/
  idle states driven by the event subscription.
- **`ui/matrix.lua`** — the Matrix "digital rain" thinking indicator for
  dispatched sub-agents (see *Matrix rain* below).
- **`ui/theme_accessor.lua`** — the single shared accessor used by the renderer,
  status, and prompt. `new(section)` subscribes to the host `theme` module for a
  section (default `agent_smith`) and returns a getter for the current
  theme-style-sheet table, degrading to `nil` when theming is unavailable.
- **`theme.lua`** — the theme section registered in `lilpack.json`. It is a
  palette-driven builder defining styles for the subagent **role labels**
  (`niobe`/`link`/`tank`/`smith`), the **tool** call display
  (bracket/name/args/result plus ok/failed/cancelled states), and the
  **prompt line** (see the README's *Prompt line format*).

### Matrix rain

Source: `ui/matrix.lua`.

While a **dispatched** sub-agent (Niobe/Link/Tank) waits on its LLM request,
the mode adapter shows a Matrix-style rain of falling glyphs instead of the
braille spinner — the rain *is* the thinking indicator for delegated work
(top-level "Smith" turns keep the stock `std.progress_text` spinner). Columns
of enigmatic 5×7 bitmap glyphs (digit-, symbol- and katakana-like), tinted
with the role's palette colour, fall inside a wide strip placed in the left
part of the screen.

**Rendering.** Frames are drawn into a `term.gfx` pixel canvas and displayed
through the Kitty graphics protocol at a **negative z-index** (`z=-10`), so
the terminal composites the rain *behind* the text layer — it can never
clobber a text cell, which is what lets it coexist with live tool-call
rendering. Pixels are transmitted via POSIX shared memory (the escape stream
carries only the shm name, ~100 bytes/frame); each frame re-transmits under a
fixed image id and re-places with a fixed placement id, atomically replacing
the previous frame. The strip is sized proportionally (~40% of terminal
width, glyph-column count capped) and the `c`/`r` placement box stretches
each glyph column across two terminal cells, so the canvas pixel budget stays
flat on any terminal width.

**Placement.** The strip is anchored *relative to the cursor* on every frame:
save cursor → `CUU rows+1` (clamps at the top margin) → `CHA` to the strip
column → place (`C=1`, no cursor move) → restore cursor. Re-placing each
frame keeps the rain behind the most recent output and makes it ride along as
text scrolls.

**Concurrency.** The animation is an in-process `lev.spawn` task ticking at
10 fps — the LLM request yields to the lev loop, so the task is scheduled
while the request is in flight. The tty has exactly **one writer**: each tick
is emitted as a single `io.write` (sync-wrapped), and the mode adapter never
runs the subprocess spinner while the rain is active (a second writer would
corrupt the spinner's backspace-erase accounting). The task deliberately
sleeps without a cancel token: `stop()` flips a flag and deletes the image;
the task wakes once within a tick, sees the flag, and exits without drawing.

**Wiring** (`mode/agent_smith.lua`, `run_user_turn_line`): `probe()` (a pure
tty/module check, cached) runs once per turn setup; on `llm_request_started`
with a `source_role`, `start(role)` is called — it returns an inert handle
(`active = false`) on non-dispatched roles or when graphics are unavailable,
in which case the stock spinner is started instead. `clear_matrix()` runs
alongside `clear_spinner()` on every output/terminal event and in the
post-turn cleanup.

### Completion

Slash-command and argument completion is a small peripheral subsystem
(`completion/slash.lua`, `completion/source.lua`), wired through the
`completion` block in `lilpack.json`. `completion/source.lua` enumerates the
slash commands (`DEFAULT_COMMANDS`) and feeds `/config` path, model-id, and role
candidates; `completion/slash.lua` does the prefix/suffix matching against the
current input tokens.

---

## Documentation map

- [README.md](../README.md) — user guide: overview, install, roles & modes, minimal config, commands.
- [ARCHITECTURE.md](ARCHITECTURE.md) — this document: runtime, services, contracts.
- [LLM.md](LLM.md) — embedded LLM client/tool/model contracts.
- [USER_COMMANDS.md](USER_COMMANDS.md) — user-defined command files.
