# LLM Context From Events

Design overview for making any LLM-driven chat persona (Timmy at the
gateway, Sally at a single sled, future personas) aware of huskies
events without the user having to re-narrate them.

## Goal

**Update the LLM's context non-intrusively when a state transition
happens.** No new LLM turn is fired; events are simply visible to the
LLM the next time the user (or anything else) does cause it to run.
The LLM should never need to be told what already happened inside
huskies.

## Guiding Principle

**Transports have nothing to do with LLMs.** A transport (Matrix bot,
web UI, CLI, future TUIs) is a pure courier — it relays user text in,
LLM text out, and never owns LLM-facing state. Anything the LLM needs
to know lives in huskies, behind a single `assemble_prompt_context`
helper that the transport calls. Adding a new transport must require
zero changes to the event-awareness path.

## Three things this doc is NOT

1. **Triggers** — `on StoryMerged{1122} do Rebuild`. These are
   deterministic subscribers; they should never invoke the LLM. Covered
   in a separate design.
2. **Proactive wake** — running an LLM turn *because* an event fired,
   without the user typing. Costs tokens, risks ramble. Explicitly out
   of scope here; a separate decision to make later.
3. **A transport feature** — this design assumes any transport that
   invokes the LLM uses the same context-assembly helper. Matrix bot,
   web UI, CLI all funnel through it.

## Why Past Attempts Have Failed

- **Buffer lived on the transport**, not on huskies. The current
  `BotContext.pending_pipeline_events` (`server/src/chat/transport/matrix/bot/context.rs:103-116`)
  is Matrix-only; web UI users see nothing of the kind, and the buffer
  dies with the bot process.
- **Process-local, RAM-only**. Server rebuild → buffer empty. Any
  events between the old binary's last user turn and the new binary's
  first are silently lost.
- **Unbounded `mpsc` channels drop under lag.** The server logs
  routinely show `[xxx-sub] Subscriber lagged, skipped N event(s)`.
  When the subscriber feeding the buffer falls behind, events vanish
  without being recorded.
- **No end-to-end test.** Nothing asserts "fire event E, send user
  message M, the LLM's prompt contains E."
- **No cross-process aggregation.** Events in a sled have no path to
  the gateway-side LLM context without bespoke plumbing per event type.

## Architecture at a Glance

```
┌────────────┐  ┌────────────┐  ┌────────────┐
│  Sled A    │  │  Sled B    │  │  Sled C    │
│ event_log/ │  │ event_log/ │  │ event_log/ │  ◄── source of truth
└─────┬──────┘  └─────┬──────┘  └─────┬──────┘     (CRDT-backed)
      │               │               │
      └───────────────┼───────────────┘
                      ▼
              ┌────────────────────┐
              │ Gateway aggregator │  ◄── tail-merges all sled logs
              │  event_view/       │     into a single ordered stream
              └─────────┬──────────┘
                        │
                        ▼
            ┌────────────────────────┐
            │ Per-LLM-session state  │  ◄── scope filter +
            │  sessions/<id>/        │     high-water mark per stream
            └─────────┬──────────────┘
                      │
                      ▼
            ┌────────────────────────┐
            │ assemble_prompt_context│  ◄── single helper used by
            │   (session_id) -> Str  │     every transport before
            └─────────┬──────────────┘     each LLM turn
                      │
       ┌──────────────┼──────────────┐
       ▼              ▼              ▼
   Matrix bot      Web UI         CLI / TUI
```

## Event Model — Reuse What Already Exists

There is no need to invent a parallel event taxonomy. Huskies already
has a complete typed enum and a single broadcast bus:

- `server/src/pipeline_state/transition.rs` defines `PipelineEvent`
  with **30 variants** covering every state-machine transition
  (`DepsMet`, `GatesStarted/Passed/Failed`, `QaSkipped`,
  `MergeSucceeded/Failed/FailedFinal`, `Accepted`, `Block/Unblock`,
  `Abandon`, `Supersede`, `ReviewHold/Cleared`, `Reject`, `Triage`,
  `Close`, `Demote`, `Freeze/Unfreeze`, `MergemasterAttempted`,
  `FixupRequested`, `ReQueuedForQa`, `MergeAborted`,
  `HotfixRequested`, `MergeRetryStarted`).
- The same module defines `ExecutionEvent` with 7 variants for agent
  lifecycle (`SpawnRequested`, `SpawnedSuccessfully`, `Heartbeat`,
  `HitRateLimit`, `Exited`, `Stopped`, `Reset`).
- Every transition fires a `TransitionFired` event on a single internal
  bus. Ten subscribers already consume it (audit-log,
  worktree-create-sub, worktree-cleanup-sub, merge-failure-sub,
  merge-block-sub, done-archive-sub, content-gc, cost-rollup-sub,
  stage-notification-sub, event-triggers).

**The LLM context injector is just the 11th subscriber on the same
bus.** It writes typed events into the per-sled CRDT event log
described below; everything downstream reuses the existing taxonomy.

Each persisted entry carries:

```
struct LoggedEvent {
    id: EventId,                  // monotonic per sled
    sled_id: SledId,
    timestamp: UnixSeconds,
    transition: TransitionFired,  // story_id + from + to + PipelineEvent
                                  // (or ExecutionEvent — see open question)
}
```

The few events that genuinely don't fit the pipeline state machine
(e.g. `ProjectAdopted`, `Rebuilt`, `GatewayHealthChanged`) live in a
small, separately-enumerated `InfraEvent` enum, but the same log and
the same subscriber pattern still apply.

## Session Model

An LLM session is a first-class CRDT entity:

```
struct LlmSession {
    id: SessionId,
    persona: Persona,        // "Timmy", "Sally", ...
    scope: ScopeFilter,      // { sleds: All } | { sleds: Set<SledId> }
    high_water: BTreeMap<SledId, EventId>,   // per-stream
    created: UnixSeconds,
}
```

The session id is what the transport carries; it's not the Matrix room,
not the web socket id. A given Matrix room may map to one session; a
web UI tab may map to another. Multiple transports for the same human
can share a session if you want — that's a separate UX call.

## Prompt Assembly Contract

Every transport calls one helper before invoking the LLM:

```
fn assemble_prompt_context(session_id: SessionId) -> String
```

Behavior:

1. Read the session's scope filter and high-water marks.
2. Fetch events from the gateway aggregator that match the scope and
   are newer than the high-water marks.
3. Render them as a single `<system-reminder>` block, ordered by sled
   then timestamp.
4. Advance the high-water marks to the latest event seen, atomically
   with the LLM-turn-start CRDT op (so a crash mid-turn doesn't double-
   inject).
5. Return the rendered block (empty string if no new events).

The transport prepends the result to the user's prompt and invokes the
LLM as usual.

## Persistence & Reliability Rules

- **Event log is CRDT-backed.** Survives sled restart.
- **High-water marks are CRDT-backed.** Survives gateway restart.
- **Aggregator uses bounded queues with drop-oldest semantics**, and
  every drop logs `[event-agg] dropped N events for session <id>; client
  must re-fetch from <high-water>`. The aggregator never silently
  swallows events — if the queue is full, the session gets a sentinel
  event `EventStreamGap { from, to }` so the LLM can see it missed
  context.
- **End-to-end test required**: `fire(Event::StoryMerged{1122}) → user
  sends "what's going on?" → assembled prompt contains "1122 merged"`.

## Multi-Persona Scoping

The same machinery serves both Timmy and Sally:

| Persona | Scope filter                       | Notes                          |
|---------|------------------------------------|--------------------------------|
| Timmy   | `{ sleds: All }`                   | Gateway-wide; aware of every sled |
| Sally   | `{ sleds: { huskies-server } }`    | Single-sled; sled-local events only |
| Manny   | `{ sleds: { huskies, ketflix } }`  | Hypothetical; subset           |

Sally never has to know Timmy exists, and vice versa. Their sessions
advance their own high-water marks against the same underlying log.

## Decisions

| Decision               | Choice                              | Alternative                                  |
|------------------------|-------------------------------------|----------------------------------------------|
| Event publication      | Each sled owns its log              | Single global log: cross-sled bottleneck     |
| Aggregation            | Gateway tail-merges                 | Each session pulls from each sled directly: N×M fanout |
| Buffer location        | CRDT-persisted                      | In-process: lost on rebuild (current bug)    |
| Event identity         | Typed enum                          | Strings: structured-log creep, no compile-time safety |
| Drop semantics         | Drop-oldest + `EventStreamGap`      | Silent drop (current bug): LLM lies confidently |
| Session ↔ transport    | Session is separate from transport  | One per transport: web tab + Matrix get different views |
| Proactive LLM wake     | OUT OF SCOPE                        | Wake on every event: cost + ramble           |

## Open Questions

1. **Session lifecycle**. How are sessions created and garbage-
   collected? Created on first transport message? GC'd after N days
   idle?
2. **Event retention**. How long are events kept in the log? Forever
   feels wrong; "since last terminal session turn" feels right but
   needs care for multi-session readers.
3. **Multi-transport same session**. Should one human's Matrix and web
   UI share a session by default, or always be separate?
4. **Render budget**. If 500 events accumulated between turns, do we
   render all 500 or summarize? A `summarize_events` fallback path is
   probably worth designing in from the start.
5. **Aggregator placement when there is no gateway**. A standalone
   single-sled install has no gateway — does the sled itself host the
   aggregator? (Probably yes; trivially "aggregates" its own log.)

## Phasing

- **Phase 0 (now):** this design doc.
- **Phase 1:** typed `Event` enum + per-sled CRDT-backed event log;
  one publisher subscribes to existing pipeline transitions and writes
  `StoryStaged` / `StoryMerged` / `StoryMergeFailed`.
- **Phase 2:** `LlmSession` CRDT entity + `assemble_prompt_context`
  helper, wired into the Matrix bot's `handle_message` (replaces the
  existing `pending_pipeline_events` Vec). End-to-end test covering the
  fire-event → user-turn → prompt-contains-event contract.
- **Phase 3:** Gateway aggregator over multiple sleds; Timmy's session
  scoped to `All`. Sally's session scoped to a single sled.
- **Phase 4:** Web UI and any other transports migrated onto
  `assemble_prompt_context`; the Matrix-specific Vec deleted.
- **Phase 5:** Bounded queues + `EventStreamGap` sentinel; observability
  for `assemble_prompt_context` runs (events injected, gaps observed).

Each phase ships independently. Phase 2 alone delivers the user-facing
fix: Timmy sees what merged when you next say anything, without you
needing to re-narrate.
