+
+
${formatMessage(msg.content)}
+
`;
+ },
+```
+
+---
+
+## How the scanner reads settings
+
+The scanner calls `GlobalConfig.Get()` **every tick** for these keys:
+
+| Key | Read by | How stored |
+|-----|---------|------------|
+| `auto_compaction_enabled` | `isEnabled()` | `{value: true/false}` |
+| `auto_compaction_threshold` | `getThreshold()` | `{value: 0.70}` (float 0-1) |
+| `auto_compaction_cooldown_minutes` | `getCooldownDuration()` | `{value: 30}` |
+
+The admin UI stores a combined `auto_compaction` key (for display) AND
+the individual keys (for scanner consumption). This avoids the scanner
+needing to parse a composite object.
+
+Interval and concurrency are set at startup via `ScannerConfig` and
+require a restart to change. This is intentional — they affect the
+goroutine pool size and ticker, which shouldn't be hot-swapped.
+
+## Verification
+
+1. `cd server && go test ./compaction/` — estimator tests pass (no DB)
+2. `cd server && go build .` — compiles cleanly
+3. Manual test: Admin → Settings → enable auto-compaction → Save →
+ check server logs for `🔍 compaction scanner started`
+4. Manual test: with utility role configured and a long conversation,
+ verify auto-compaction fires after the threshold is exceeded
+5. Verify cooldown: same channel should not re-compact within 30 minutes
+6. Verify kill switch: disable auto-compaction → scanner stops processing
+ on next tick
diff --git a/docs/CHANGES-0.15.0-phase4.md b/docs/CHANGES-0.15.0-phase4.md
new file mode 100644
index 0000000..deb3147
--- /dev/null
+++ b/docs/CHANGES-0.15.0-phase4.md
@@ -0,0 +1,106 @@
+# v0.15.0 Phase 4 — Guard Rail + Integration Tests
+
+## Files
+
+```
+server/compaction/compaction.go UPDATED — context budget guard rail
+server/compaction/compaction_test.go NEW — scanner integration tests (DB required)
+server/compaction/testmain_test.go NEW — TestMain for compaction package
+server/database/seed_helpers.go NEW — SeedTestMessage, SeedTestMessages, SeedTestCursor
+server/handlers/live_compaction_test.go NEW — e2e tests with Venice/Qwen3-4B
+```
+
+## Changes
+
+### 1. Context Budget Guard Rail (`compaction.go`)
+
+Added between prompt construction and LLM call. Prevents sending
+truncated input to small utility models.
+
+**How it works:**
+1. Estimates total prompt tokens (system + conversation content)
+2. Resolves the utility model's `max_context` from catalog
+3. Reserves 20% for output (the summary itself)
+4. If `estimated_prompt > max_context × 0.80`, returns `ErrContextBudget`
+
+**Resolution chain** for utility model context:
+- `Resolver.GetConfig()` → primary binding → `Catalog.GetByModelID()`
+- Fallback: `Catalog.GetByModelIDAny()` (different provider)
+- Hard fallback: `DefaultBudget` (128K)
+
+The scanner's `doCompact()` already handles errors gracefully — a budget
+error just logs a warning and retries next cycle (by which time the
+conversation may have been manually compacted or the admin may have
+upgraded the utility model).
+
+**New export:** `ErrContextBudget` — callers can use `errors.Is()` to
+distinguish budget failures from LLM errors.
+
+### 2. Test Seed Helpers (`database/seed_helpers.go`)
+
+New file in the `database` package alongside `testhelper.go`:
+
+- `SeedTestMessage(t, channelID, parentID, role, content) → msgID`
+- `SeedTestMessages(t, channelID, count, contentSize) → []msgIDs`
+ Creates a linear alternating user/assistant chain
+- `SeedTestCursor(t, channelID, userID, leafID)`
+ Sets the active leaf for tree path resolution
+
+### 3. Scanner Integration Tests (`compaction/compaction_test.go`)
+
+All DB-dependent tests use `database.RequireTestDB(t)` and skip
+gracefully when no DB is configured.
+
+| Test | What it verifies |
+|------|-----------------|
+| `FindCandidates_ReturnsQualifying` | Channel with ≥10 msgs, ≥20K chars, proper age range appears |
+| `FindCandidates_ExcludesTooFewMessages` | <10 messages → excluded |
+| `FindCandidates_ExcludesTooRecent` | Updated just now → excluded (activity gap) |
+| `FindCandidates_ExcludesArchived` | `is_archived=true` → excluded |
+| `Cooldown` | Timestamps recorded and checked correctly |
+| `InFlightDedup` | sync.Map prevents concurrent compaction of same channel |
+| `ShouldCompact_ChannelOptOut` | `settings.auto_compaction=false` → rejected |
+| `IsEnabled` | Reads `auto_compaction_enabled` from global_settings |
+| `GetThreshold_Default` | Returns 0.70 when no override |
+| `GetThreshold_GlobalOverride` | Reads from global_settings |
+| `GetThreshold_ChannelOverride` | Channel setting takes precedence |
+| `GetCooldownDuration_Default` | Returns 30min default |
+| `GetCooldownDuration_Override` | Reads from global_settings |
+| `GuardRailMath` | Validates token math for 32K vs 128K models |
+
+### 4. Live E2E Tests (`handlers/live_compaction_test.go`)
+
+Requires: `TEST_DATABASE_URL` + `VENICE_API_KEY`
+
+| Test | What it verifies |
+|------|-----------------|
+| `CompactionFullPipeline` | Seeds 10 realistic messages → Compact() → verifies summary message in DB, metadata fields, cursor update, usage log entry |
+| `CompactionContextBudgetGuardRail` | Seeds 160K chars of content → Compact() returns ErrContextBudget with 32K model |
+
+## Running Tests
+
+```bash
+# Unit only (no DB)
+cd server && go test ./compaction/ -run TestEstimate -v
+
+# Integration (requires DB)
+cd server && go test ./compaction/ -v
+
+# Live e2e (requires DB + Venice key)
+cd server && VENICE_API_KEY=... go test ./handlers/ -run TestLive_Compaction -v
+```
+
+## Surgical Edit
+
+### `database/testhelper.go`
+
+Add this import if not already present (the new `seed_helpers.go` file
+is a separate file in the same package, so no edit needed to
+`testhelper.go` itself):
+
+```go
+// No changes needed — seed_helpers.go is a new file in package database
+```
+
+The `strings` import in `seed_helpers.go` is used by `SeedTestMessages`
+for `strings.Repeat`.
diff --git a/DESIGN-0.12.0.md b/docs/DESIGN-0.12.0.md
similarity index 100%
rename from DESIGN-0.12.0.md
rename to docs/DESIGN-0.12.0.md
diff --git a/DESIGN-0.13.1.md b/docs/DESIGN-0.13.1.md
similarity index 100%
rename from DESIGN-0.13.1.md
rename to docs/DESIGN-0.13.1.md
diff --git a/DESIGN-0.14.0.md b/docs/DESIGN-0.14.0.md
similarity index 100%
rename from DESIGN-0.14.0.md
rename to docs/DESIGN-0.14.0.md
diff --git a/docs/DESIGN-0.15.0.md b/docs/DESIGN-0.15.0.md
new file mode 100644
index 0000000..b6c8e7a
--- /dev/null
+++ b/docs/DESIGN-0.15.0.md
@@ -0,0 +1,584 @@
+# DESIGN-0.15.0 — Compaction
+
+## Overview
+
+Automatic conversation compaction. A background service monitors channels
+for context pressure and triggers summarization via the utility model role,
+replacing the manual "Summarize & Continue" button as the primary
+compaction path. Manual summarization remains available as an explicit
+user action.
+
+Depends on: utility model role (v0.10.0), summarize handler (v0.10.2).
+
+**Design principle: don't reinvent the wheel.** The existing
+`SummarizeHandler` already has the full pipeline — path loading, summary
+boundary detection, prompt construction, role resolution, tree insertion,
+cursor update, usage logging. This design extracts that core logic into
+a shared `compaction` package and adds a background scanner on top.
+
+---
+
+## 1. Extract: `compaction` Package
+
+Move the reusable summarization pipeline out of `handlers/summarize.go`
+into `server/compaction/compaction.go`. The HTTP handler becomes a thin
+wrapper.
+
+### Core type
+
+```go
+package compaction
+
+// Service provides conversation compaction (summarization).
+// Used by both the HTTP handler (manual) and the background scanner (auto).
+type Service struct {
+ stores store.Stores
+ resolver *roles.Resolver
+}
+
+func NewService(stores store.Stores, resolver *roles.Resolver) *Service {
+ return &Service{stores: stores, resolver: resolver}
+}
+```
+
+### Extracted method: `Compact`
+
+The current `SummarizeHandler.Summarize` body becomes `Service.Compact`:
+
+```go
+type CompactRequest struct {
+ ChannelID string
+ UserID string // channel owner
+ TeamID *string // for role resolution
+ Trigger string // "manual" | "auto"
+}
+
+type CompactResult struct {
+ SummaryID string
+ SummarizedCount int
+ Model string
+ UsedFallback bool
+ Content string
+ InputTokens int
+ OutputTokens int
+}
+
+func (s *Service) Compact(ctx context.Context, req CompactRequest) (*CompactResult, error)
+```
+
+The method performs the same steps as today's handler:
+
+1. Load active path via `getActivePath` (already exported within `handlers`)
+2. Find summary boundary, collect messages to summarize
+3. Guard: minimum 4 messages since last summary
+4. Build summarization prompt
+5. Call `resolver.Complete(ctx, RoleUtility, ...)`
+6. Insert summary tree node with metadata
+7. Update cursor
+8. Log usage
+
+The only new metadata field is `"trigger"` (`"manual"` or `"auto"`) so
+the frontend can distinguish how the summary was created.
+
+### Refactored handler
+
+`handlers/summarize.go` shrinks to:
+
+```go
+func (h *SummarizeHandler) Summarize(c *gin.Context) {
+ // ownership check, rate limit check (unchanged)
+ // ...
+ result, err := h.compaction.Compact(c.Request.Context(), compaction.CompactRequest{
+ ChannelID: channelID,
+ UserID: userID,
+ TeamID: teamID,
+ Trigger: "manual",
+ })
+ // return JSON response (unchanged)
+}
+```
+
+### Tree helpers
+
+`getActivePath`, `isSummaryMessage`, `nextSiblingIndex`, `updateCursor`
+are currently unexported in `handlers/tree.go`. Two options:
+
+**Option A** — Export them from `handlers` and import in `compaction`.
+Clean but creates a dependency from `compaction` → `handlers`.
+
+**Option B** — Move tree helpers into a `treepath` (or similar) package
+imported by both `handlers` and `compaction`.
+
+Recommend **Option B** to keep the dependency graph clean:
+`handlers` → `compaction` → `treepath` ← `handlers`. The `treepath`
+package is pure data + SQL queries with no handler logic.
+
+```
+server/
+ treepath/ ← NEW: extracted from handlers/tree.go
+ path.go (getActivePath, getPathToLeaf, getActiveLeaf)
+ summary.go (isSummaryMessage, summary metadata helpers)
+ siblings.go (getSiblingCount, getSiblings, findLeafFromMessage)
+ cursor.go (updateCursor, nextSiblingIndex)
+ compaction/ ← NEW
+ compaction.go (Service, Compact)
+ scanner.go (Scanner, background loop)
+ estimator.go (token estimation)
+ handlers/
+ tree.go → thin wrappers or deleted, imports treepath
+ summarize.go → thin HTTP wrapper, delegates to compaction.Service
+ completion.go → imports treepath for path building
+```
+
+---
+
+## 2. Server-Side Token Estimation
+
+The background scanner needs to evaluate context pressure without making
+an LLM call. The frontend already does this with a `chars / 4` heuristic
+(`tokens.js`). Replicate server-side:
+
+```go
+// compaction/estimator.go
+
+// EstimateTokens returns a rough token count using the ~4 chars/token
+// heuristic. Matches the frontend Tokens.estimate() for consistency.
+func EstimateTokens(text string) int {
+ return (len(text) + 3) / 4
+}
+
+// EstimatePath returns total estimated tokens for a message path,
+// including system prompt overhead.
+func EstimatePath(path []treepath.PathMessage, systemPromptLen int) int {
+ total := 0
+ if systemPromptLen > 0 {
+ total += EstimateTokens(string(make([]byte, systemPromptLen))) + 4
+ }
+ for _, m := range path {
+ total += EstimateTokens(m.Content) + 4 // +4 per-message overhead
+ }
+ return total
+}
+```
+
+Not exact, but it's the same bar the user already sees in the UI. If we
+need better accuracy later, swap in a tokenizer library without changing
+the interface.
+
+### Context budget resolution
+
+The scanner needs `max_context` for whatever model the channel is using.
+Resolution chain:
+
+1. Channel has a `model` field → look up `model_catalog` for capabilities
+2. Channel has a `provider_config_id` → use that provider's catalog entry
+3. Fallback: use the utility role's model context window as a proxy (it's
+ the model that would be doing the summarization anyway)
+4. Hard fallback: 128K tokens (conservative default)
+
+```go
+func (s *Scanner) getContextBudget(ctx context.Context, ch *models.Channel) int {
+ if ch.Model != "" {
+ if caps, err := s.stores.Catalog.GetCapabilities(ctx, ch.Model); err == nil {
+ if caps.MaxContext > 0 {
+ return caps.MaxContext
+ }
+ }
+ }
+ return DefaultContextBudget // 128000
+}
+```
+
+---
+
+## 3. Background Scanner
+
+Follows the Ingester pattern from `knowledge/ingest.go`: goroutine pool
+with semaphore, WaitGroup for graceful shutdown.
+
+### Scanner type
+
+```go
+// compaction/scanner.go
+
+type Scanner struct {
+ service *Service
+ stores store.Stores
+ sem chan struct{}
+ wg sync.WaitGroup
+ stopCh chan struct{}
+ ticker *time.Ticker
+}
+
+type ScannerConfig struct {
+ Enabled bool // global kill switch
+ Interval time.Duration // scan frequency (default: 5 minutes)
+ Concurrency int // max parallel compactions (default: 2)
+}
+
+func NewScanner(svc *Service, stores store.Stores, cfg ScannerConfig) *Scanner
+func (sc *Scanner) Start()
+func (sc *Scanner) Stop() // signals stop + drains in-flight
+```
+
+### Scan loop
+
+```
+every