# M5d design: epochs, compaction, and the scheduler Status: approved by the owner, 2026-10-03, with section 11's answers. The implementer is Ornith (through `tools/run-plan.sh`), as for M5c. It rests on the brief (`docs/design.md`) as changed by P17 to P24, on M5a's files and index (`docs/specs/2026-09-24-m5a-local-memory.md`), and on M5c's Honcho ledger (`docs/specs/2026-09-27-m5c-honcho.md`). Where this document and the brief disagree, section 10 names the brief change. ## 1. What M5d proves Continuity inside a live session, and work that runs when the owner is not talking, without breaking the inference contract or the main slot's cache. The milestone is checked by: - a scheduled run leaves the main slot's cache intact; - a fact from epoch 1 is recalled in epoch 2, and a memory edit applied in epoch 1 is in epoch 2's baseline; - the tiering job promotes only trusted notes into identity, and its writes are journalled like any other. ## 2. Two halves One spec, two plans, each run and device-checked on its own (owner): 1. **Epochs and compaction (M5d-1)**: `loopd` writes `N.jsonl` / `N.baseline.json` for `N > 0`; turn numbers and `untrusted()` span every epoch; compaction after a turn whose last `Usage` is at or above a configured fraction of `expect.n_ctx`; the summary call on the background slot; `memoryd`'s Honcho walker reads every epoch file of a session. 2. **The scheduler (M5d-2)**: heartbeat and cron as isolated sessions on the background slot; tool-output summaries for tools that opt in; the tiering job. ## 3. Epochs and compaction (M5d-1) ### 3.1 Disk A session directory already has `0.baseline.json` and `0.jsonl`. After compaction it also has: ``` sessions//0.jsonl ends with EpochEnd { next: 1, summary } sessions//1.baseline.json rebuilt from memoryd at compaction sessions//1.jsonl starts with SessionStart { epoch: 1, … } ``` `LogRecord::EpochEnd { time, next, summary }` is already in `bxcore` and unused. M5d-1 writes it. The old log is kept. `memoryd`'s indexer already walks every `.jsonl` under a session (`memoryd::index::logs`). `Session::open` loads the highest epoch that has both a baseline file and a log, and the summary carried from the previous epoch's `EpochEnd` (none for epoch 0). `untrusted()` is true if any `ToolResult` in any epoch of the session is labelled untrusted. `turns()` counts `User` records across every epoch, in epoch order then file order; the first `User` of the session is turn 1 for its life. ### 3.2 Turn numbers and Honcho `memoryd` keys a turn as `Item::Turn { session, turn }` and today reads only `0.jsonl` (`honcho::turns::log_path`). After M5d-1 it concatenates every epoch's records in epoch order and uses the same `turn_records` / `turns` helpers. Because `User` records are never written for the carried summary, those helpers stay correct. `loopd` reports `SessionUpdated { turn: session.turns() }` as it does now; that number is monotonic. ### 3.3 When compaction runs After a turn has finished (the session is not in the busy set for a live turn), if the current epoch's last `Usage` has `cache_n + prompt_n` at or above `threshold × expect.n_ctx`, `loopd` compacts that session before it accepts another turn on it. Default `threshold` is `0.85`. It is a float in `loopd.toml` (`[compact] threshold`) so it can move once this agent's own `Usage` records exist. There is no idle timer: a session that sits for hours under the threshold stays on its current epoch. If a live request returns `exceed_context_size_error` (`TurnError::SessionFull` today), `loopd` compacts and retries that user message once in the new epoch. If the retry is still too large, the turn ends `session_full` as today. Compaction holds the session busy. Incoming channel turns wait, as they do for any other turn. ### 3.4 The summary call On the **background** slot, with **no tools**, thinking on. The messages are the current epoch's replay (`messages(baseline, records)`), then one user line whose text is `config/compact.md` (next to `system.md`; a starter ships in the repository). The completion's `content` is the summary: tag-neutralised, capped to `[compact] summary_chars` (default 4,000 characters). It is not stuffed into the baseline, so it does not count against the 3,000-token budget. The session's live slot (the one on `SessionStart`, main for a Mattermost thread) is unchanged. The next live turn of the new epoch is a cold start on that slot: new prefix. If the summary call itself is refused as context-full, `loopd` writes a fallback summary: the epoch's `User` texts, newest last, cut to `summary_chars`. Compaction still proceeds. The large prompt shares the server's unified KV pool. That pressure is accepted; running it on the background slot keeps it off a live main-slot request. ### 3.5 The new epoch's prefix 1. Append `EpochEnd` to the old log. 2. Ask `memoryd` for a fresh memory block; write `.baseline.json` (system.md + block + the same core tool schemas). Identity edits applied during the old epoch land here. 3. Create `.jsonl` with `SessionStart { epoch: n+1, slot: the session's live slot, baseline: hash of the new file }`. 4. The in-memory `Session` switches to the new baseline, empty records (the `SessionStart`), and `carry = Some(summary)`. `messages` for epoch `n > 0` is: the new system message, then one `ChatMessage::User` whose content is a fixed heading plus `carry` (the heading is built from pieces in code, named in words), then the new epoch's records. That user message is **not** a `LogRecord::User`, so it is not a turn, not reported to Honcho, and not counted by `turns()`. Within an epoch the request is still a strict extension of the previous one. A new epoch is a new prefix by definition. ### 3.6 Trust across compaction `Session::untrusted()` reads every epoch file, so a compacted session that had read untrusted content still does. `TurnDone.untrusted` follows that, and `gatewayd` keeps escaping links. `brokerd`'s `broker/sessions/.json` is already per session, not per epoch, and stays the authority for taint on tool calls. ## 4. Isolated sessions and the scheduler (M5d-2) Scheduled work is a session like any other: same core tools, same `brokerd` path, same memory tools. It runs on the **background** slot (`SessionStart.slot = cfg.slots.background`). Live Mattermost and `bxctl chat` sessions keep `cfg.slots.main`. `run_turn` uses the session's slot, not always `slots.main` (today's `turn.rs`). The scheduler is a thread inside `loopd serve` (the only role that can start inference). It shares `Context` and the busy set. It never talks to `gatewayd`. Answers stay in `sessions//`. The owner reads them with `bxctl chat --session ` or by opening the log. Session ids: | Kind | Id | Lives | |---|---|---| | Heartbeat | `heartbeat` | one standing session, compacted like any other | | A cron run | `job--t` (`[a-z0-9-]`) | one session per run | | Mattermost | `mm-` as today | unchanged | A heartbeat or cron turn has no speaker, so Honcho skips it (M5c: no single speaker). The local index still holds the log. Priority, when the background slot is contended: a compaction summary first, then a cron run that is due, then heartbeat, then a tool-output summary. A live turn on the main slot is independent. `SlotGate` already serialises requests on one slot. ### 4.1 Heartbeat `[heartbeat]` in `loopd.toml`: `every_s` (0, the default, disables it) and `prompt` (a path next to the config file, default `heartbeat.md`). A starter `config/heartbeat.md` ships in the repository. When `every_s` has elapsed since the last heartbeat turn (or since start, if none), the scheduler runs one turn on session `heartbeat` whose user text is the file's contents. Volatile time belongs in that newest message: the scheduler prepends one line `It is .` ahead of the file. ### 4.2 Cron Owner-written files `{home}/jobs/.toml`. `name` is `[a-z0-9-]+`, short enough that `job--t` fits in a `SessionId` (64 bytes). Unknown keys are errors. `loopd` reads them and cannot write them. ```toml schedule = "0 7 * * *" # five-field UTC crontab: minute hour day-of-month month day-of-week prompt = "Check overnight mail and write a note of anything that needs the owner." ``` `prompt` is the user text of that run, with the same time line prepended as heartbeat. A new session each run. If a previous run of the same `name` is still busy, this tick is skipped (the schedule does not queue). The matcher is a small module in `loopd` (UTC, no cron crate): `*`, a single number, `n-m`, `*/n`. Day-of-week 0 is Sunday. Tested against a table of (schedule, timestamp, due) cases. `loopd` wakes at least once a minute while any job file exists. A run ledger `{home}/jobs/runs.jsonl` records `{time, name, session, status}` (`started`, `finished`, `failed`). It is how "previous run still busy" is answered after a restart: a `started` line with no later `finished`/`failed` for that `name` means skip until the process that owns the busy set is this one and the session is not busy. On start, a leftover `started` from a dead process is closed as `failed`. Scheduled jobs re-check current grants on every tool call: that is already `brokerd`'s rule. ### 4.3 Tool-output summaries M5a's index records owner text, assistant replies, and tool **calls**; it does not record tool **results**. A fetch's body is therefore invisible to `recall` except as whatever the assistant restated. A tool that opts in gets a second record, for the index only: ```text LogRecord::ToolSummary { time, call, tool_call_id, content, class, untrusted } ``` `messages()` ignores it, so the epoch's prefix does not change. `class` and `untrusted` are copied from the `ToolResult` they summarise. `memoryd` indexes `content` as kind `summary`, same labels; `recall` tags it `memory` or `memory-tainted` as for any other session hit. M5d opts in `http_fetch` only. `[compact] summarise = ["http_fetch"]` is the list; unknown names are a config error. After a turn that logged an opted-in `ToolResult` with no matching `ToolSummary` yet, the scheduler runs a no-tools completion on the background slot: the user text is `config/summarise.md` plus the result, capped to the tool-result cap. The completion is tag-neutralised, capped to `summary_chars`, and appended as `ToolSummary`. The model call does not hold the session busy; appending the record does (the same log lock as any append). A live turn on that session is never delayed by summarising. Compaction of a session waits until in-flight summaries for it have appended or failed. ### 4.4 The tiering job An ordinary cron job. Nothing in `loopd` special-cases it. A starter `{repo}/config/jobs/tier.toml.example` documents a weekly schedule and a prompt that says: read the listing, then `consolidate` / `add` / `remove` / `note` as needed; promote only trusted notes; do not fetch. The listing is the job's user text after the time line, from a new `memoryd` admin request `MemoryCommand::Tiers`: each identity entry and each note, with how many times `recalls/` named it and when it was last found (M5a already writes that log so counts survive a reindex). Untrusted notes are included and marked; the prompt forbids promoting them, and `memoryd` still refuses identity edits from an untrusted session, so a job that fetches a page cannot promote. `bxctl memory tiers` prints the same listing for the owner. ## 5. Config New tables on `loopd.toml`. Unknown keys are errors. Defaults disable scheduled work. ```toml [compact] threshold = 0.85 summary_chars = 4000 summarise = ["http_fetch"] [heartbeat] every_s = 0 prompt = "heartbeat.md" # next to this config file ``` `expect.n_ctx` is already required. Depth is `cache_n + prompt_n` of the current epoch's last `Usage`. Starter files in the repository, copied like `soul.md` / `system.md`: `config/compact.md`, `config/heartbeat.md`, `config/summarise.md`, `config/jobs/tier.toml.example`. ## 6. Components | Piece | Change | |---|---| | `bxcore` | `LogRecord::ToolSummary`; `MemoryCommand::Tiers` and its reply. `EpochEnd` stays as it is. | | `loopd` | Current epoch on open; `turns` / `untrusted` over every epoch; `run_turn` uses the session's slot; compaction (section 3); scheduler thread (section 4). | | `memoryd` | Honcho reads every epoch file of a session; indexes `ToolSummary`; `Tiers` from `recalls/`. | | `gatewayd` | None. `TurnDone.untrusted` keeps doing the work if `loopd` is right. | | `brokerd` | None beyond using the session's existing state. | | `bxctl` | `bxctl memory tiers`. Heartbeat and job sessions are ordinary `--session` ids. | `loopd` still depends only on `bxcore`. No new crate for cron. ## 7. Testing Given tests, first, against the fake llama server and a fake `memoryd`: - **Open / resume**: a directory with `0` and `1` loads epoch 1 and the carry from `0`'s `EpochEnd`; a missing carry on epoch 1 is an error (the file exists but has no `EpochEnd`). - **Turns**: two `User` records in epoch 0 and one in epoch 1 → `turns() == 3`; Honcho `turn_records` of turn 3 is the epoch-1 user. - **Trust**: an untrusted `ToolResult` in epoch 0, none in epoch 1 → `untrusted()` is true (the open item in `docs/decisions.md`). - **Prefix**: property test per epoch; after compaction the first request of epoch 1 is the new baseline plus carry, not an extension of epoch 0. - **Trigger**: last `Usage` at 84% of `n_ctx` leaves the epoch; at 85% the next turn waits on compaction (the fake server records a no-tools request on the background slot) and then runs on epoch 1. An idle gap with no turn does nothing. - **SessionFull**: a context-full error on epoch 0 compacts and retries once. - **Slot**: a `heartbeat` turn's request carries `id_slot = background`; a `mm-*` turn still carries `main`. A heartbeat turn in between two main-slot turns of another session leaves the fake server's recorded cache prefix of the main session intact. - **Heartbeat / cron**: `every_s = 1` fires; `schedule = "* * * * *"` fires; a still-busy run of the same name is skipped; a leftover `started` after a restart is `failed`; unknown keys in a job file refuse start; a due run with no speaker is not a Honcho item (existing skip). - **ToolSummary**: an `http_fetch` result produces a `ToolSummary` with the same labels; `messages()` is unchanged; `recall` finds the summary text and taints when `untrusted`. - **Tiers**: a trusted note with hits is listed; an untrusted note is listed and marked; a tiering session that is untrusted cannot `add` to `memory.md` (existing M5a rule). **On the device** (straylight, Ornith slot 0 for the live session, slot 2 for background), with `[compact] threshold` lowered so the check does not need 220k tokens: - M5d-1: a `bxctl` session writes a distinctive fact into `memory.md`, continues until compaction, and the next turn's baseline contains the fact; `recall` of a phrase from epoch 0 still hits; `TurnDone.untrusted` stays true across the epoch change after a tainted recall. - M5d-2: a heartbeat turn runs on the background slot; a following main-slot turn of a second session shows a cache hit on its own prefix (`cache_n` near its previous total). A `jobs/tier.toml` for the check promotes only a trusted note; the journal has the edit; an untrusted note is still a note. Heartbeat and cron answers are not posted to Mattermost. The check reads the session log. ## 8. Runbook New entries, each with a pointer from the message: - `session-compact-failed`: the summary call and the fallback both failed; the session stays on the old epoch and the next turn is `session_full` if it would not fit. - `jobs-invalid`: a job file failed to load; `loopd` does not start. - `heartbeat-prompt-unreadable`: the prompt file exists but cannot be read; heartbeat is skipped and the line is logged once until it reads. ## 9. Out of scope - Subagents (M6) share the background slot; the scheduler's queue is the one they will join. - Optional delivery of a cron answer to Mattermost. - Session-scoped approvals (open row in `docs/decisions.md`). - Changing `Item::Turn` to carry an epoch: turn numbers are monotonic instead. - A cron crate; nicknames (`@daily`); seconds fields; non-UTC zones. ## 10. Changes to the brief and decisions With this spec, as their own commits: - `docs/design.md`, Inference contract 4: compaction runs after a turn, when `cache_n + prompt_n` is at or above a configured fraction of `n_ctx` (default 0.85), while the session is not in another turn. It does not run because the session has been quiet. - `docs/design.md`, Memory 3 / M5a index: `recall` also indexes `ToolSummary` records of tools that opt in, labelled as the tool result was. Raw tool results stay unindexed. - `docs/decisions.md`: close "A session's trust across compaction" (section 3.6); row for monotonic turn numbers; row for scheduled sessions staying off Mattermost; P20's "M5c scheduler" wording already moved to M5d by P24. ## 11. The owner's answers (2026-10-03) 1. **Heartbeat delivery**: answers stay in the session log. They are not posted to Mattermost. 2. **Cron**: five-field UTC crontab, `{home}/jobs/.toml`, a new session each run, a tick skipped when that job is still live. 3. **Compaction trigger**: 85% of `expect.n_ctx` from the last `Usage`, configurable. Retune from this agent's `Usage` records once they exist. A quiet session is not a reason to compact (the owner may have gone to sleep). 4. **Tool summaries**: on the background slot; a `ToolSummary` log record with the result's labels, FTS-indexed; `http_fetch` opts in. 5. **Split**: M5d-1 (epochs and compaction), then M5d-2 (the scheduler), two plans.