diff --git a/docs/superpowers/specs/2026-07-16-context-compaction-overhaul-design.md b/docs/superpowers/specs/2026-07-16-context-compaction-overhaul-design.md index 61d70ec..6f09202 100644 --- a/docs/superpowers/specs/2026-07-16-context-compaction-overhaul-design.md +++ b/docs/superpowers/specs/2026-07-16-context-compaction-overhaul-design.md @@ -86,7 +86,7 @@ Replace `src/app/runtime/shortsend.rs` with `src/app/runtime/context/`: ``` context/ - mod.rs — prepare() facade: dedup → count → maybe-shape + mod.rs — module registration only, no facade (see below) tokens.rs — unified token counting (tiktoken-rs) dedup.rs — cross-call tool-result deduplication squash.rs — per-result compression (log/json/generic), applied at @@ -109,9 +109,14 @@ internal `/3`, the auto-loop's `/4` (`actions/mod.rs` ~1146), and `status.rs:68` ### `dedup.rs` ```rust -pub fn collapse(messages: &[ChatMessage]) -> Vec +pub fn collapse(messages: &[ChatMessage]) -> (Vec, bool) ``` +The `bool` is `true` iff at least one message was replaced with a placeholder — callers use +it to decide whether the result is worth persisting/announcing, without needing `ChatMessage` +to implement `PartialEq` (it doesn't today, and adding it purely to diff whole message lists +would be needless surface area for what `collapse` already knows precisely mid-walk). + Flow: walk messages, pair each `Role::Tool` message to its originating `ToolCall` via `tool_call_id`. Key = `(function.name, sha256(canonical_json(function.arguments)))` (`sha2` is already a dependency). Track the last index seen per key. For any earlier occurrence of a @@ -187,36 +192,31 @@ blocks in `Action::Compact`, `spawn_turn`, and `view/status.rs` (×2). ### `mod.rs` -```rust -pub fn prepare( - messages: &[ChatMessage], - max_wire_tokens: usize, - force: bool, - client: Option<&LlmClient>, -) -> Vec -``` - -Orchestrates: `dedup::collapse` → per-message `squash::apply` is already baked into messages -by the time they arrive here (applied earlier, at construction) → `tokens::count_message_tokens` -sum → `shaping::should_shape` → conditionally `shaping::shape`. This is the single entry point -both `run_agent_turn`'s auto-loop and `Action::Compact` call. +No facade function — just `pub mod dedup; pub mod shaping; pub mod squash; pub mod tokens; +pub mod window;`. `dedup`, `shaping`, and `tokens` are called directly from each call site +(the auto-loop and `Action::Compact`), matching CLAUDE.md's "No DI — modules call ... +directly" convention rather than introducing an orchestration layer that only one of the two +callers would use generically (the auto-loop already needs per-stage control today — it +inspects `should_shape` itself to decide whether to emit `TurnEvent::Compacted` — and would +have to unpack a facade's result anyway). ## Data flow (per turn) 1. Tool executes → raw `output: String`. -2. `squash::apply(tool_name, &output)` — compress if over the size floor. +2. `squash::apply(tool_name, &output)` — compress if over the size floor (`read` exempted). 3. Wrapped into `ChatMessage::tool_result(...)`, archived, pushed to `msgs`. -4. Once per loop iteration: `context::prepare(&msgs, tc.context_window, force=false, - Some(&tc.client))` — dedup pass always runs; shaping runs only if `should_shape` trips. -5. Result pushed as `TurnEvent::Compacted` if shaping actually changed anything (same as - today), consumed on the main thread to update `SessionRuntime.messages`. +4. Once per loop iteration: `dedup::collapse(&msgs)` (always) → sum + `tokens::count_message_tokens` over the result → `shaping::should_shape` → conditionally + `shaping::shape_messages`. +5. Result pushed as `TurnEvent::Compacted` if dedup changed anything or shaping triggered, + consumed on the main thread to update `SessionRuntime.messages`. ## Fixing the manual/auto asymmetry `Action::Compact` (`actions/mod.rs:547`) currently runs synchronously inside `apply_action` and can't block on an LLM call. Fix: make it spawn a background `std::thread::spawn` — the -same pattern `spawn_turn` already uses (`actions/mod.rs:694`) — that calls -`context::prepare(&rt.messages, max_wire_tokens, force=true, Some(&client))` and reports back +same pattern `spawn_turn` already uses (`actions/mod.rs:694`) — that runs `dedup::collapse` +then unconditionally `shaping::shape_messages(.., force=true, Some(&client))` and reports back via `TurnEvent::Compacted`, identical to the automatic path. The toast sequence becomes "Compacting…" immediately (optimistic, non-blocking) then "History compacted" when the `TurnEvent` arrives. This gives manual `/compact` real LLM summarization instead of always